Browse Source

feat(outline): 增加独立快速模式并修复保存分类

大纲工作流增加 fast/standard,与正文 aiWorkflowMode 分开存储。
生成完成若缺少 outlineSaveRequest,按正文兜底弹出保存确认;
分类改为标题与用户原话优先,避免章纲正文引用「卷纲节拍」被误判为卷纲。
同时删除已停用的 deep-outline-generation 与 refine 验收脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 month ago
parent
commit
26c475db25

+ 0 - 84
scripts/run-refine-acceptance.ts

@@ -1,84 +0,0 @@
-import { createDirectory, readFile, writeFile } from "@/commands/fs"
-import { hasOutlineForRefinement, generateOutlineRefinementFiles } from "@/lib/novel/outline-generation"
-import { useWikiStore, type LlmConfig } from "@/stores/wiki-store"
-
-const PROJECT_PATH = "/acceptance-refine-project"
-const OUTLINES_DIR = `${PROJECT_PATH}/wiki/outlines`
-const OUTLINE_PATH = `${OUTLINES_DIR}/story-outline.md`
-
-const IMPORTED_OUTLINE = [
-  "---",
-  "type: outline",
-  'title: "导入示例总纲"',
-  "---",
-  "",
-  "# 导入示例总纲",
-  "",
-  "## 故事核心",
-  "- 主角因旧案回到故乡,卷入多方势力争夺。",
-  "",
-  "## 卷一目标",
-  "- 查明第一起失踪案的真实动机。",
-  "",
-  "## 卷二目标",
-  "- 揭示幕后组织与主角家族的关联。",
-  "",
-  "## 长线伏笔",
-  "- 失踪名单中的同姓者身份。",
-  "- 夜潮会账本缺页来源。",
-  "",
-].join("\n")
-
-const llmConfig: LlmConfig = {
-  provider: "custom",
-  apiKey: "",
-  model: "mock-refine-model",
-  ollamaUrl: "http://127.0.0.1:11434",
-  customEndpoint: "http://127.0.0.1:18080",
-  maxContextSize: 131072,
-  apiMode: "chat_completions",
-  reasoning: { mode: "off" },
-}
-
-async function main() {
-  console.log("[accept] start")
-  useWikiStore.getState().setNovelMode(true)
-  await createDirectory(OUTLINES_DIR)
-  await writeFile(OUTLINE_PATH, IMPORTED_OUTLINE)
-  console.log("[accept] outline imported:", OUTLINE_PATH)
-
-  const canRefine = await hasOutlineForRefinement(PROJECT_PATH)
-  console.log("[accept] hasOutlineForRefinement:", canRefine)
-  if (!canRefine) {
-    throw new Error("Outline import check failed: hasOutlineForRefinement=false")
-  }
-
-  console.log("[accept] submit refine generation ...")
-  const result = await generateOutlineRefinementFiles(
-    PROJECT_PATH,
-    llmConfig,
-    "请基于已有总纲,细化第一卷章节推进,并补全人物、组织、能力体系、伏笔与地点设定。",
-  )
-  console.log("[accept] refine generated:", result.writtenPaths.length)
-
-  const written = await Promise.all(
-    result.writtenPaths.map(async (path) => {
-      const content = await readFile(path)
-      const firstLine = content.split("\n").find((line) => line.trim().length > 0) ?? ""
-      return { path, firstLine, length: content.length }
-    }),
-  )
-
-  console.log(JSON.stringify({
-    projectPath: PROJECT_PATH,
-    importedOutline: OUTLINE_PATH,
-    primaryPath: result.primaryPath,
-    writtenCount: result.writtenPaths.length,
-    written,
-  }, null, 2))
-}
-
-main().catch((error) => {
-  console.error(error instanceof Error ? error.stack ?? error.message : String(error))
-  process.exit(1)
-})

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

@@ -99,6 +99,7 @@ beforeEach(() => {
     providerConfigs: { openai: { apiKey: "test-key", enabled: true, savedModels: [{ id: "gpt-4o", model: "gpt-4o", name: "GPT-4o", createdAt: 1 }] } },
     aiChatModel: "gpt-4o",
     aiOutlineModel: "",
+    outlineWorkflowMode: "standard",
   })
   outlineModelPreferenceMocks.saveAiOutlineModel.mockReset()
   outlineModelPreferenceMocks.saveAiOutlineModel.mockResolvedValue(undefined)
@@ -815,11 +816,179 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("runSubAgent: async (subAgentPlan)")
     expect(source).toContain("runSingleAgentFallback")
     expect(source).toContain("mergeResults")
-    expect(source).toContain("enableMultiAgent: true")
+    expect(source).toContain("enableMultiAgent: !fastMode")
+    expect(source).toContain('intentPhase: "generation"')
     expect(source).toContain("多 Agent 并行生成")
     expect(source).toContain("自动回退为单 Agent")
   })
 
+  it("快速模式系统提示去掉工作流强制段,仍保留保存协议和 Markdown 约束", () => {
+    const prompt = buildOutlineAgentSystemPrompt({ projectName: "测试项目", mode: "fast" })
+
+    expect(prompt).not.toContain("## AI大纲生成工作流")
+    expect(prompt).not.toContain("## 意图清晰度分析阶段")
+    expect(prompt).not.toContain("## AI大纲固定分析流程")
+    expect(prompt).toContain("outlineSaveRequest")
+    expect(prompt).toContain("Markdown 格式约束:结构化资料使用一级标题")
+    expect(prompt).toContain("像普通对话一样直接出结果")
+    expect(prompt).not.toContain("必须按 PRD 3.1 主流程执行")
+    expect(prompt).not.toContain("先提出最少必要澄清问题")
+  })
+
+  it("标准模式系统提示仍包含固定分析流程和生成工作流", () => {
+    const prompt = buildOutlineAgentSystemPrompt({ projectName: "测试项目", mode: "standard" })
+
+    expect(prompt).toContain("## AI大纲固定分析流程")
+    expect(prompt).toContain("## AI大纲生成工作流")
+    expect(prompt).toContain("## 意图清晰度分析阶段")
+    expect(prompt).toContain("outlineSaveRequest")
+    expect(prompt).toContain("必须按 PRD 3.1 主流程执行")
+    expect(prompt).toContain("先提出最少必要澄清问题")
+  })
+
+  it("快速模式源码跳过意图分析和多 Agent,人物小传不再降级为 analysis 预算", () => {
+    expect(source).toContain('outlineWorkflowMode === "fast"')
+    expect(source).toContain("enableMultiAgent = Boolean(options.enableMultiAgent) && outlineMode !== \"fast\"")
+    expect(source).toContain("outlineMode !== \"fast\"")
+    expect(source).toMatch(/const charRun = await runOutlineAgentOnce\([\s\S]{0,400}budgetStage: "generation"/)
+    expect(source).not.toMatch(/const charRun = await runOutlineAgentOnce\([\s\S]{0,400}budgetStage: "analysis"/)
+    expect(source).toContain('budgetStage: "generation"')
+    expect(source).toContain("intentPhase === \"intent_analysis\"")
+    expect(source).toContain('? "analysis"')
+    expect(source).toContain(': "generation"')
+  })
+
+  it("截断残稿不会自动弹出保存确认", () => {
+    expect(source).toContain("!deliverableTruncated")
+    expect(source).toContain("isOutlineOutputTruncated")
+    expect(source).toMatch(/if \(intentProtocol\.kind === "none" && !intentProtocolError && !deliverableTruncated\)/)
+    expect(source).toContain("handleAutoSaveOutlineRequests(capturedConvId, finalContent, isCurrentRun)")
+    expect(source).toContain("isSaveableOutlineDeliverable")
+    expect(source).toContain("生成完成后自动保存")
+    expect(source).toContain("if (isOutlineOutputTruncated(charRun.error)) deliverableTruncated = true")
+    expect(source).toContain("if (isOutlineOutputTruncated(subRun.error)) deliverableTruncated = true")
+    expect(source).toContain("if (agentError && !isOutlineOutputTruncated(agentError)) throw agentError")
+    expect(source).toContain("if (mergeError && !isOutlineOutputTruncated(mergeError)) throw mergeError")
+  })
+
+  it("快速向导不把多 Agent 计划写进用户消息", () => {
+    expect(source).toContain('buildOutlineWizardPrompt(request, { mode: "fast" })')
+    expect(source).toContain("buildOutlineWizardMultiAgentPrompt(request)")
+  })
+
+  it("AI 大纲输入区默认标准模式,下拉可切换到快速", 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 大纲执行模式"]')
+    expect(trigger).not.toBeNull()
+    expect(trigger?.textContent).toContain("标准")
+    expect(container.textContent).toContain("再交给 AI 分析和追问")
+
+    await act(async () => {
+      trigger?.click()
+      await new Promise((resolve) => requestAnimationFrame(resolve))
+      await new Promise((resolve) => requestAnimationFrame(resolve))
+    })
+
+    const fastOption = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button"))
+      .find((button) => button.getAttribute("role") === "option" && button.textContent?.includes("快速"))
+    expect(fastOption).toBeDefined()
+    await act(async () => {
+      fastOption?.click()
+    })
+
+    expect(useWikiStore.getState().outlineWorkflowMode).toBe("fast")
+    expect(container.querySelector('[aria-label="AI 大纲执行模式"]')?.textContent).toContain("快速")
+    expect(container.textContent).toContain("直接生成大纲正文")
+    expect(container.textContent).not.toContain("再交给 AI 分析和追问")
+  })
+
+  it("快速模式自由输入跳过意图分析,直接单轮生成", async () => {
+    useWikiStore.setState({ outlineWorkflowMode: "fast" })
+    const calls: Array<{ system: string; user: string }> = []
+    vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (_config, _registry, messages, callbacks) => {
+      const system = agentMessageContentText(messages.find((message) => message.role === "system")?.content ?? "")
+      const user = agentMessageContentText(messages.findLast((message) => message.role === "user")?.content ?? "")
+      calls.push({ system, user })
+      const text = "# 第236章 远洋投送\n\n## 本章目标\n完善远洋投送细节。"
+      callbacks.onText(text)
+      callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: text }
+    })
+    setOutlineConversations([conversation()], "outline-active")
+    const container = await renderOutlineChatPanel()
+    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, "把236章大纲补充详细")
+      input?.dispatchEvent(new Event("input", { bubbles: true }))
+      input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))
+      for (let attempt = 0; attempt < 100; attempt += 1) {
+        if (calls.length >= 1 && useOutlineChatStore.getState().runStates["outline-active"]?.status !== "running") break
+        await new Promise((resolve) => setTimeout(resolve, 5))
+      }
+    })
+
+    expect(calls).toHaveLength(1)
+    expect(calls[0].system).not.toContain("本轮阶段:意图分析")
+    expect(calls[0].system).toContain("像普通对话一样直接出结果")
+    expect(calls[0].user).toContain("把236章大纲补充详细")
+  })
+
+  it("单 Agent 输出被截断时不自动弹出保存确认", async () => {
+    useWikiStore.setState({ outlineWorkflowMode: "fast" })
+    const body = "# 总纲\n\n## 核心设定\n残稿"
+    const text = `${body}\n\n\`\`\`json\n${JSON.stringify({
+      outlineSaveRequest: {
+        targetFolder: "大纲",
+        fileName: "总纲.md",
+        fileType: "outline",
+        writeMode: "create",
+        referencedSkills: [],
+        sourceIntent: "生成总纲",
+        content: body,
+      },
+    })}\n\`\`\``
+    vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (_config, _registry, _messages, callbacks) => {
+      callbacks.onText(text)
+      callbacks.onError(new Error("输出被截断:模型已达到最大输出 token 上限"))
+      callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: text }
+    })
+    setOutlineConversations([conversation()], "outline-active")
+    const container = await renderOutlineChatPanel()
+    const input = container.querySelector<HTMLTextAreaElement>('[aria-label="引用输入框"]')
+    await act(async () => {
+      const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set
+      setValue?.call(input, "随便写点设定")
+      input?.dispatchEvent(new Event("input", { bubbles: true }))
+      input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }))
+      for (let attempt = 0; attempt < 100; attempt += 1) {
+        if (useOutlineChatStore.getState().runStates["outline-active"]?.status !== "running") break
+        await new Promise((resolve) => setTimeout(resolve, 5))
+      }
+    })
+
+    expect(container.textContent).toContain("输出被截断")
+    expect(container.textContent).not.toContain("请确认要保存的大纲文件")
+    expect(document.body.textContent).not.toContain("请确认要保存的大纲文件")
+  })
+
   it("AI 大纲多 Agent 过程写入消息状态并渲染结构化面板", () => {
     expect(source).toContain('import { OutlineMultiAgentPanel } from "@/components/sources/outline-multi-agent-panel"')
     expect(source).toContain("multiAgentRun")
@@ -858,10 +1027,10 @@ describe("OutlineChatPanel controls", () => {
   })
 
   it("saves AI outline results into the inferred outline category folder", () => {
-    expect(source).toContain("classifyOutlineSaveTarget")
-    expect(source).toContain("classification.targetFolder")
-    expect(source).toContain("classification.fileName")
+    expect(source).toContain("buildClassifiedOutlineSaveRequest")
+    expect(source).toContain("built.request")
     expect(source).toContain("保存大纲文件")
+    expect(source).toContain("手动保存 AI 大纲结果")
   })
 
   it("parses structured AI outline save requests and requires user confirmation before writing", () => {
@@ -879,7 +1048,7 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("OutlineSaveConfirmDialog")
     expect(source).toContain("OutlineSaveConfirmPayload")
     expect(source).toContain("extractCharacterSaveDrafts")
-    expect(source).toContain("classifyOutlineSaveTarget")
+    expect(source).toContain("buildClassifiedOutlineSaveRequest")
     expect(source).toContain("characterDraftsToSaveRequests")
     expect(source).toContain("splitConfirmRequiredSaveRequests")
     expect(source).toContain("mode={saveConfirmState.mode}")

+ 310 - 89
src/components/sources/outline-chat-panel.tsx

@@ -9,6 +9,7 @@ import {
 } from "react";
 import { createPortal } from "react-dom";
 import {
+  Check,
   ChevronDown,
   X,
   Save,
@@ -22,6 +23,12 @@ import {
   ArrowDown,
 } from "lucide-react";
 import { useWikiStore } from "@/stores/wiki-store";
+import {
+  resolveOutlineWorkflowMode,
+  type OutlineWorkflowMode,
+} from "@/lib/agent/workflow-mode";
+import { OUTPUT_TRUNCATED_ERROR_MARKER } from "@/lib/llm-client";
+import { Button } from "@/components/ui/button";
 import { saveAiOutlineModel } from "@/lib/project-store";
 import {
   useOutlineChatStore,
@@ -91,7 +98,11 @@ import {
   parseDynamicOutlinePlan,
 } from "@/lib/novel/outline-dynamic-agent-planner";
 import { buildScopedOutlineSubAgentContext } from "@/lib/novel/outline-agent-context";
-import { prepareOutlineSaveDraft, prepareOutlineSaveSourceContent } from "@/lib/outline-save";
+import {
+  buildClassifiedOutlineSaveRequest,
+  isSaveableOutlineDeliverable,
+  prepareOutlineSaveSourceContent,
+} from "@/lib/outline-save";
 import {
   type CharacterSaveDraft,
   extractCharacterSaveDrafts,
@@ -106,7 +117,6 @@ import {
   type CharacterAgentPlan,
   type CharacterAgentResult,
 } from "@/lib/novel/character-multi-agent";
-import { classifyOutlineSaveTarget } from "@/lib/novel/outline-save-classifier";
 import {
   characterDraftsToSaveRequests,
   formatOutlineSaveParseFeedback,
@@ -392,48 +402,86 @@ function buildOutlineAgentUserContent(
   ].join("\n");
 }
 
+function isOutlineOutputTruncated(error?: Error | null): boolean {
+  const message = error?.message ?? "";
+  return message.includes(OUTPUT_TRUNCATED_ERROR_MARKER) || message.includes("最大输出 token");
+}
+
+const OUTLINE_WORKFLOW_MODE_OPTIONS: Array<{
+  mode: OutlineWorkflowMode;
+  label: string;
+  description: string;
+  routeDescription: string;
+}> = [
+  {
+    mode: "fast",
+    label: "快速",
+    description: "普通对话",
+    routeDescription: "快速模式像普通对话一样直接出结果,可读取必要上下文,但不走意图分析、多 Agent 和强制需求分析。",
+  },
+  {
+    mode: "standard",
+    label: "标准",
+    description: "完整工作流",
+    routeDescription: "先做意图分析或向导多 Agent,再生成可保存的大纲,并保留澄清与分步生成。",
+  },
+];
+
 export function buildOutlineAgentSystemPrompt(options: {
   projectName?: string;
   webResearchContext?: string;
   soulDoc?: string;
+  mode?: OutlineWorkflowMode;
 }): string {
+  const mode = resolveOutlineWorkflowMode(options.mode);
+  const workflowRules = mode === "fast"
+    ? [
+      "快速模式下像普通对话一样直接出结果。可以按需读取必要上下文,但不要主动进入需求分析、意图分析或多 Agent 编排。",
+      "用户要求生成或修改大纲时,直接输出可保存的大纲正文;不要先追问方案或等待确认才开始写。",
+    ]
+    : [
+      "你必须通过可用工具读取项目大纲、章节、记忆、推演结果和历史对话后,再进行分析、回答、生成或修改建议。",
+      "不要假设引用内容已经注入上下文;不要跳过工具直接空泛回答。",
+      "回答必须基于已读取内容进行分析,说明关键判断依据。",
+      "## AI大纲固定分析流程",
+      "1. 先调用 list_outlines、list_chapters、list_memories、list_deductions 确认可用资料范围。",
+      "2. 再调用 read_outline、read_chapter、read_memory、read_deduction 读取用户 @ 引用和相关项目内容。",
+      "3. 分析冲突、缺口、伏笔、角色动机和章节承接,明确哪些判断来自已读取资料。",
+      "4. 最后再生成大纲建议;没有完成读取和分析前,不要直接给出结论。",
+      "## AI大纲生成工作流",
+      "固定向导提交的小说生成需求必须先进入“需求分析/生成方案”阶段:先判断缺失信息,信息足够时只输出生成方案、文件清单、保存位置和生成顺序,并询问用户是否确认开始生成;用户确认前不得生成完整文件,不得调用保存工具。",
+      "需求分析必须执行充分性闸门:缺少篇幅、频道、题材、故事灵感、核心卖点、作品规模、主要人物方向、世界观/背景方向或预期章节结构时,只追问最关键缺口。",
+      "长篇小说必须先卷后章:先形成核心设定、总纲、卷节拍表、卷时间线和卷纲,再生成章纲;不得从灵感直接跳到全书章纲。",
+      "章纲采用滚动章纲方式:优先生成前 10 章或用户指定范围,后续依据已确认章纲继续补齐,避免一次性生成整本导致承接断裂。",
+      "生成章纲后必须列出新增设定写回清单,包含新增角色、势力、世界观规则、伏笔、地图地点和状态变化;用户确认前不得写入设定文件。",
+      "## 意图清晰度分析阶段",
+      "仅当系统明确标记本轮为“意图分析”时,才输出 intent_clarity;正文生成阶段严禁再次输出该标记。",
+      "当本轮为意图分析时:",
+      "1. 调用 list_outlines、list_chapters、read_outline 读取已有资料",
+      "2. 判断用户意图是否清晰(能否确定具体生成范围)",
+      "3. 严格输出以下完整协议块:",
+      "<!-- intent_clarity -->",
+      '{"clarity":"clear|needs_input","module":"模块名","analysis":"判断依据","detectedScope":"明确范围","missingItems":[],"options":[],"question":""}',
+      "<!-- /intent_clarity -->",
+      "开闭标记必须成对出现;字段名必须使用 clarity,禁止使用 status。JSON 必须完整且可解析。",
+      "4. clear 时:只输出 JSON,不生成正文,等待系统自动注入生成指令",
+      "5. needs_input 时:只输出 JSON,在 question 和 options 中提供澄清问题与4个推荐选项",
+      "推荐选项必须包含:A.全部缺失项 B.基于已有内容推断 C.最近范围 D.自定义",
+      "用户选择或回复后,直接进入生成流程,不再二次分析。",
+    ];
   return [
     "你是专业小说大纲分析与创作助手。",
-    "你必须通过可用工具读取项目大纲、章节、记忆、推演结果和历史对话后,再进行分析、回答、生成或修改建议。",
     "如果用户提供 @ 引用,必须优先按路径、标题或会话ID调用对应读取工具获取正文内容。",
-    "不要假设引用内容已经注入上下文;不要跳过工具直接空泛回答。",
-    "回答必须基于已读取内容进行分析,说明关键判断依据;需要保存大纲时只输出 outlineSaveRequest 或 outlineSaveRequests JSON 块,禁止调用 write_outline_node;系统解析后弹出确认,用户确认后才写入文件。",
-    "## AI大纲固定分析流程",
-    "1. 先调用 list_outlines、list_chapters、list_memories、list_deductions 确认可用资料范围。",
-    "2. 再调用 read_outline、read_chapter、read_memory、read_deduction 读取用户 @ 引用和相关项目内容。",
-    "3. 分析冲突、缺口、伏笔、角色动机和章节承接,明确哪些判断来自已读取资料。",
-    "4. 最后再生成大纲建议;没有完成读取和分析前,不要直接给出结论。",
-    "## AI大纲生成工作流",
-    "固定向导提交的小说生成需求必须先进入“需求分析/生成方案”阶段:先判断缺失信息,信息足够时只输出生成方案、文件清单、保存位置和生成顺序,并询问用户是否确认开始生成;用户确认前不得生成完整文件,不得调用保存工具。",
-    "需求分析必须执行充分性闸门:缺少篇幅、频道、题材、故事灵感、核心卖点、作品规模、主要人物方向、世界观/背景方向或预期章节结构时,只追问最关键缺口。",
-    "长篇小说必须先卷后章:先形成核心设定、总纲、卷节拍表、卷时间线和卷纲,再生成章纲;不得从灵感直接跳到全书章纲。",
-    "章纲采用滚动章纲方式:优先生成前 10 章或用户指定范围,后续依据已确认章纲继续补齐,避免一次性生成整本导致承接断裂。",
-    "生成章纲后必须列出新增设定写回清单,包含新增角色、势力、世界观规则、伏笔、地图地点和状态变化;用户确认前不得写入设定文件。",
-    "## 意图清晰度分析阶段",
-    "仅当系统明确标记本轮为“意图分析”时,才输出 intent_clarity;正文生成阶段严禁再次输出该标记。",
-    "当本轮为意图分析时:",
-    "1. 调用 list_outlines、list_chapters、read_outline 读取已有资料",
-    "2. 判断用户意图是否清晰(能否确定具体生成范围)",
-    "3. 严格输出以下完整协议块:",
-    "<!-- intent_clarity -->",
-    '{"clarity":"clear|needs_input","module":"模块名","analysis":"判断依据","detectedScope":"明确范围","missingItems":[],"options":[],"question":""}',
-    "<!-- /intent_clarity -->",
-    "开闭标记必须成对出现;字段名必须使用 clarity,禁止使用 status。JSON 必须完整且可解析。",
-    "4. clear 时:只输出 JSON,不生成正文,等待系统自动注入生成指令",
-    "5. needs_input 时:只输出 JSON,在 question 和 options 中提供澄清问题与4个推荐选项",
-    "推荐选项必须包含:A.全部缺失项 B.基于已有内容推断 C.最近范围 D.自定义",
-    "用户选择或回复后,直接进入生成流程,不再二次分析。",
+    "需要保存大纲时只输出 outlineSaveRequest 或 outlineSaveRequests JSON 块,禁止调用 write_outline_node;系统解析后弹出确认,用户确认后才写入文件。",
+    ...workflowRules,
     "",
     "## 下一步推荐输出",
     "生成完成后,在回复末尾附加 <!-- next_step --> JSON 标记块。",
     "推荐方向仅限大纲体系内(人物小传、组织势力、力量体系等),严禁推荐正文生成。",
     "必须包含一个 id 为 D 的自定义选项。",
-    "当用户要求生成、完善或续写任何大纲分项时,必须按 PRD 3.1 主流程执行:提取请求关键词,识别用户意图,按意图读取资料,提取对小说创作有用的关键内容,结合用户要用的 skill + soul.md 约束生成内容,再做结果强约束收敛。",
+    ...(mode === "fast" ? [] : [
+      "当用户要求生成、完善或续写任何大纲分项时,必须按 PRD 3.1 主流程执行:提取请求关键词,识别用户意图,按意图读取资料,提取对小说创作有用的关键内容,结合用户要用的 skill + soul.md 约束生成内容,再做结果强约束收敛。",
+    ]),
     "关键内容提取必须服务于小说创作:只保留能帮助用户继续写小说的信息,例如章节目标、冲突推进、人物动机、伏笔状态、设定限制、时间线承接和结尾钩子。",
     "生成章纲时必须使用章纲标准结构:基础信息、上层依据、本章目标、核心事件、场景顺序、结构节点、章首钩子、爽点设计、章尾钩子、执行约束、人物状态、伏笔与追踪、待写回设定、写作约束、AI写作提示。核心事件不少于6条,场景顺序为2-4个场景。",
     "结构节点必须包含 CBN、CPNs、CEN;CEN 必须能承接下一章 CBN。执行约束必须包含必须覆盖节点和本章禁区。基础信息必须包含时间锚点、章内时间跨度和与上章时间差。",
@@ -463,7 +511,9 @@ export function buildOutlineAgentSystemPrompt(options: {
     "- **核心技能:** 高中/大学化学知识(有机/无机化学基础)、物理常识、急救知识",
     "- **性格:** 表面冷漠实则心软,前期被动应对,中后期主动布局",
     "最终回复只输出大纲标题和大纲正文;如果内容需要保存,末尾附加 AI 大纲输出协议 JSON 保存块(含 content)。禁止输出工具调用报告、分析过程、完成报告、下一步行动、无法直接保存的大段说明。",
-    "工具调用过程只应展示在工具调用 UI 中,不要混入最终正文。资料不足以生成完整正文时,先提出最少必要澄清问题,不要用流程说明冒充生成结果。",
+    mode === "fast"
+      ? "工具调用过程只应展示在工具调用 UI 中,不要混入最终正文。不要用流程说明冒充生成结果。"
+      : "工具调用过程只应展示在工具调用 UI 中,不要混入最终正文。资料不足以生成完整正文时,先提出最少必要澄清问题,不要用流程说明冒充生成结果。",
     "所有面向用户的回复必须使用中文。",
     options.projectName ? `当前项目:${options.projectName}` : "",
     options.soulDoc?.trim() ? `## 作品灵魂与总则\n${options.soulDoc}` : "",
@@ -1242,6 +1292,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const aiOutlineModel = useWikiStore((s) => s.aiOutlineModel);
   const defaultLlmModel = useWikiStore((s) => s.defaultLlmModel);
   const setAiOutlineModel = useWikiStore((s) => s.setAiOutlineModel);
+  const outlineWorkflowMode = resolveOutlineWorkflowMode(
+    useWikiStore((s) => s.outlineWorkflowMode),
+  );
+  const setOutlineWorkflowMode = useWikiStore((s) => s.setOutlineWorkflowMode);
+  const isOutlineFastMode = outlineWorkflowMode === "fast";
   const chatConversations = useChatStore((s) => s.conversations);
 
   const conversations = useOutlineChatStore((s) => s.conversations);
@@ -1373,6 +1428,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const insertReferenceTokensRef = useRef<InsertReferenceTokens>(null);
   const [hoveredConversationId, setHoveredConversationId] = useState<string | null>(null);
   const [historyOpen, setHistoryOpen] = useState(false);
+  const [workflowModeDropdownOpen, setWorkflowModeDropdownOpen] = useState(false);
+  const workflowModeTriggerRef = useRef<HTMLButtonElement | null>(null);
+  const workflowModeDropdownRef = useRef<HTMLDivElement | null>(null);
+  const [workflowModeDropdownStyle, setWorkflowModeDropdownStyle] = useState<CSSProperties | null>(null);
   const [pendingDeleteConversationId, setPendingDeleteConversationId] = useState<string | null>(null);
   const [pendingClearHistoryIds, setPendingClearHistoryIds] = useState<string[] | null>(null);
   const historyRef = useRef<HTMLDivElement | null>(null);
@@ -1571,6 +1630,29 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     setHistoryOpen(false);
   }, [activeConversationId]);
 
+  useEffect(() => {
+    if (!workflowModeDropdownOpen) {
+      setWorkflowModeDropdownStyle(null);
+      return;
+    }
+    function updatePosition() {
+      const rect = workflowModeTriggerRef.current?.getBoundingClientRect();
+      if (!rect) return;
+      setWorkflowModeDropdownStyle({
+        left: rect.left,
+        top: rect.top - 8,
+        width: Math.max(rect.width, 320),
+        transform: "translateY(-100%)",
+      });
+    }
+    const raf = requestAnimationFrame(updatePosition);
+    window.addEventListener("resize", updatePosition);
+    return () => {
+      cancelAnimationFrame(raf);
+      window.removeEventListener("resize", updatePosition);
+    };
+  }, [workflowModeDropdownOpen]);
+
   const setSaveStatus = useCallback((message: string) => {
     if (!/(失败|错误|不支持|无法)/.test(message)) return;
     toast.error(message, {
@@ -1735,14 +1817,57 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     [drainNextSaveBatch, project],
   );
 
+  const collectOutlineSaveSourceHint = useCallback((conversationId: string) => {
+    const intent = intentContextsRef.current[conversationId];
+    const conversation = useOutlineChatStore.getState().conversations.find(
+      (item) => item.id === conversationId,
+    );
+    const lastUser = [...(conversation?.messages ?? [])]
+      .reverse()
+      .find((message) => message.role === "user" && !isInternalOutlineMessage(message));
+    return [
+      intent?.originalRequest,
+      intent?.title,
+      lastUser?.content,
+      conversation?.title,
+    ].filter((item): item is string => Boolean(item?.trim())).join("\n");
+  }, []);
+
   const handleAutoSaveOutlineRequests = useCallback(
-    async (_conversationId: string, assistantContent: string, canApply: () => boolean) => {
+    async (conversationId: string, assistantContent: string, canApply: () => boolean) => {
       if (!project || !canApply()) return;
       const parsed = parseOutlineSaveRequests(assistantContent);
       if (parsed.requests.length === 0) {
         if (parsed.errors.length > 0) {
           showOutlineAutoSaveError(formatOutlineSaveParseFeedback(parsed.errors));
+          return;
         }
+        if (!isSaveableOutlineDeliverable(assistantContent)) return;
+        const built = buildClassifiedOutlineSaveRequest({
+          content: assistantContent,
+          sourceIntent: "生成完成后自动保存",
+          sourceHint: collectOutlineSaveSourceHint(conversationId),
+        });
+        if (!built || !canApply()) return;
+        if (built.classification.fileType === "character") {
+          const extracted = extractCharacterSaveDrafts(built.draft.content);
+          if (extracted.drafts.length === 0) return;
+          presentOrQueueSaveBatch({
+            title: "请确认要保存的人物角色",
+            mode: "character",
+            requests: [],
+            characterDrafts: extracted.drafts,
+          });
+          setSaveStatus("检测到人物小传,请确认要保存的人物角色。");
+          return;
+        }
+        presentOrQueueSaveBatch({
+          title: "请确认要保存的大纲文件",
+          mode: "normal",
+          requests: [built.request],
+          characterDrafts: [],
+        });
+        setSaveStatus("检测到可保存大纲,请确认后写入。");
         return;
       }
 
@@ -1816,7 +1941,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         showOutlineAutoSaveError(error instanceof Error ? error.message : String(error));
       }
     },
-    [presentOrQueueSaveBatch, project],
+    [collectOutlineSaveSourceHint, presentOrQueueSaveBatch, project],
   );
 
   const handleSend = useCallback(
@@ -1987,9 +2112,12 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       // 出错/中断时必须依靠这个变量判断有没有可保留的内容,
       // 避免整段结果被静默丢弃。
       let bestGeneratedText = "";
-      const outlineBudgetStage: OutlineBudgetStage = options.intentPhase === "generation"
-        ? "generation"
-        : "analysis";
+      let deliverableTruncated = false;
+      const outlineMode = resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode);
+      const enableMultiAgent = Boolean(options.enableMultiAgent) && outlineMode !== "fast";
+      const outlineBudgetStage: OutlineBudgetStage = options.intentPhase === "intent_analysis"
+        ? "analysis"
+        : "generation";
       const outlineRequestBudget = planOutlineRequestBudget({
         maxContextSize: effectiveLlmConfig.maxContextSize,
         stage: outlineBudgetStage,
@@ -2057,11 +2185,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         const soulDoc = await readSoulDoc(project.path).catch(() => "");
         const baseSystemPrompt = buildOutlineAgentSystemPrompt({
           projectName: project.name,
+          mode: outlineMode,
         });
         const legacySystemPrompt = buildOutlineAgentSystemPrompt({
           projectName: project.name,
           webResearchContext: webResearchMarkdown,
           soulDoc,
+          mode: outlineMode,
         }) + `\n\n## 本轮上下文策略\n${contextDecision.instruction}\n\n${historyPlan.instruction}`;
         const commonDynamicParts = [
           webResearchMarkdown ? `## 本轮联网资料\n${webResearchMarkdown}` : "",
@@ -2293,6 +2423,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           });
           let text = singleRun.text || "AI大纲未返回内容。";
           if (singleRun.error) {
+            if (isOutlineOutputTruncated(singleRun.error)) deliverableTruncated = true;
             const errorMsg = singleRun.error.message;
             text = text + "\n\n---\n\n⚠️ **注意**:" + errorMsg + "\n\n您可以在新消息中输入\"继续\"来让模型补全剩余内容,或点击保存尝试保存已生成的内容。";
           }
@@ -2306,7 +2437,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         const isCharacterMultiAgentTask =
           options.intentPhase === "generation" &&
           currentIntentContext?.title === "人物小传" &&
-          !options.enableMultiAgent;
+          !enableMultiAgent &&
+          outlineMode !== "fast";
 
         if (isCharacterMultiAgentTask) {
           setStreamingContent(capturedConvId, "正在分析需要生成的角色清单...");
@@ -2374,11 +2506,12 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                   skillNames: options.preferredSkillNames,
                   disableWriteTools: true,
                   streamToUser: false,
-                  budgetStage: "analysis",
+                  budgetStage: "generation",
                 });
-                if (charRun.error) {
+                if (charRun.error && !isOutlineOutputTruncated(charRun.error)) {
                   throw new Error(charRun.error.message);
                 }
+                if (isOutlineOutputTruncated(charRun.error)) deliverableTruncated = true;
                 return charRun.text;
               },
               onCharacterStart: (_charPlan) => {
@@ -2410,7 +2543,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               characterMultiAgentResults: multiAgentResult.characters,
             }));
           }
-        } else if (options.enableMultiAgent) {
+        } else if (enableMultiAgent) {
           const maxConcurrency = 3;
           const fallbackSubAgentPlan = planOutlineSubAgents({
             preferredSkillNames: options.preferredSkillNames ?? [],
@@ -2518,6 +2651,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                   skillNames: subAgentPlan.skillNames,
                   disableWriteTools: true,
                   statusText: `多 Agent 并行生成中...\n正在运行:${subAgentPlan.name}`,
+                  budgetStage: "generation",
                 });
               } catch (error) {
                 if (!isCurrentRun()) throw new Error("aborted");
@@ -2530,7 +2664,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 }));
                 throw error;
               }
-              if (subRun.error) {
+              if (subRun.error && !isOutlineOutputTruncated(subRun.error)) {
                 const message = subRun.error.message;
                 updateOutlineMultiAgentItem(convId, assistantId, subAgentPlan.id, (agent) => ({
                   ...agent,
@@ -2540,6 +2674,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 }));
                 throw new Error(message);
               }
+              if (isOutlineOutputTruncated(subRun.error)) deliverableTruncated = true;
               return subRun.text;
 
             },
@@ -2607,6 +2742,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                   disableWriteTools: true,
                   streamToUser: true,
                   statusText: "\u591a Agent \u5df2\u5b8c\u6210\uff0c\u6b63\u5728\u5408\u5e76\u5927\u7eb2\u7ed3\u679c...",
+                  budgetStage: "generation",
                 });
               } catch (error) {
                 if (isCurrentRun()) {
@@ -2626,6 +2762,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               if (!isCurrentRun()) throw new Error("aborted");
               let mergeText = mergeRun.text || "AI大纲未返回内容。";
               if (mergeRun.error) {
+                if (isOutlineOutputTruncated(mergeRun.error)) deliverableTruncated = true;
                 mergeText = mergeText + "\n\n---\n\n⚠️ **注意**:" + mergeRun.error.message + "\n\n您可以在新消息中输入\"继续\"来让模型补全剩余内容,或点击保存尝试保存已生成的内容。";
               }
               updateOutlineMultiAgentRun(convId, assistantId, (run) => run ? ({
@@ -2890,7 +3027,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             sessionKey: capturedConvId,
           });
         }
-        if (intentProtocol.kind === "none" && !intentProtocolError) {
+        if (intentProtocol.kind === "none" && !intentProtocolError && !deliverableTruncated) {
           await handleAutoSaveOutlineRequests(capturedConvId, finalContent, isCurrentRun);
         }
         if (!isCurrentRun()) return { started: true, sent: false };
@@ -3010,12 +3147,22 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     (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";
       intentContextsRef.current = setOutlineSessionValue(intentContextsRef.current, capturedConvId, {
         title,
         hint: requestHint,
         outputMode: config?.outputMode,
-        skillNames: getOutlineSkillNames(title),
+        skillNames: fastMode ? [] : getOutlineSkillNames(title),
       });
+      if (fastMode) {
+        void handleSend(buildGenerationPrompt(title, requestHint, requestHint, config?.outputMode), [], {
+          conversationId: capturedConvId,
+          intentPhase: "generation",
+          systemGenerated: true,
+          userDisplayText: `生成${title}`,
+        });
+        return;
+      }
       if (canTransitionOutlineWorkflow(outlineWorkflowStages[capturedConvId] ?? "idle", "intent_analysis")) {
         setOutlineWorkflowStages((stages) => setOutlineSessionValue(stages, capturedConvId, "intent_analysis"));
       }
@@ -3032,6 +3179,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
 
   const handleDirectSubmit = useCallback(
     async (text: string, references: ReferenceToken[] = []) => {
+      if (resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode) === "fast") {
+        return handleSend(text, references);
+      }
       const directRequest = classifyDirectOutlineGenerationRequest(text);
       if (!directRequest) return handleSend(text, references);
 
@@ -3257,8 +3407,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         }
         const skillConfig = await loadDeAiSkillConfig(project.path).catch((): DeAiSkillConfig | null => null);
         const soulDoc = await readSoulDoc(project.path).catch(() => "");
-        const baseSystemPrompt = buildOutlineAgentSystemPrompt({ projectName: project.name });
-        const legacySystemPrompt = buildOutlineAgentSystemPrompt({ projectName: project.name, soulDoc });
+        const resumeOutlineMode = resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode);
+        const baseSystemPrompt = buildOutlineAgentSystemPrompt({ projectName: project.name, mode: resumeOutlineMode });
+        const legacySystemPrompt = buildOutlineAgentSystemPrompt({ projectName: project.name, soulDoc, mode: resumeOutlineMode });
         const buildResumeSystemContent = (extraRules: string): AgentMessage["content"] => (
           contextHubResult
             ? buildContextHubSystemContent(baseSystemPrompt, contextHubResult, [extraRules])
@@ -3369,7 +3520,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             if (memoryDecision === undefined && record.userMemoryDecision !== undefined) {
               memoryDecision = record.userMemoryDecision;
             }
-            if (agentError) throw agentError;
+            if (agentError && !isOutlineOutputTruncated(agentError)) throw agentError;
             return runText;
           },
           mergeResults: async (subAgentResults) => {
@@ -3413,7 +3564,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             if (memoryDecision === undefined && record.userMemoryDecision !== undefined) {
               memoryDecision = record.userMemoryDecision;
             }
-            if (mergeError) throw mergeError;
+            if (mergeError && !isOutlineOutputTruncated(mergeError)) throw mergeError;
+            if (mergeError) {
+              mergeText = `${mergeText}\n\n---\n\n⚠️ **注意**:${mergeError.message}\n\n您可以在新消息中输入"继续"来让模型补全剩余内容,或点击保存尝试保存已生成的内容。`;
+            }
             return mergeText || "AI大纲未返回内容。";
           },
           onStatusChange: (event) => {
@@ -3542,11 +3696,15 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
 
   const handleSubmitOutlineWizard = useCallback(
     (request: OutlineWizardRequest) => {
-      const modelContent = buildOutlineWizardMultiAgentPrompt(request);
+      const fastMode = resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode) === "fast";
+      const modelContent = fastMode
+        ? buildOutlineWizardPrompt(request, { mode: "fast" })
+        : buildOutlineWizardMultiAgentPrompt(request);
       void handleSend(modelContent, outlineReferenceTokensRef.current, {
         disableWriteTools: true,
-        preferredSkillNames: getOutlineWizardSkillNames(request),
-        enableMultiAgent: true,
+        preferredSkillNames: fastMode ? [] : getOutlineWizardSkillNames(request),
+        enableMultiAgent: !fastMode,
+        intentPhase: "generation",
         novelGenerationRequest: createNovelGenerationRequestPackage(request, modelContent),
         systemGenerated: true,
       });
@@ -3625,7 +3783,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         || (targetAssistantMessage?.intentPhase == null
           && historicalIntent.kind !== "none"
           && Boolean(precedingUserMessage && classifyDirectOutlineGenerationRequest(precedingUserMessage.content)));
-      if (regenerateAsIntentAnalysis && precedingUserMessage) {
+      if (
+        regenerateAsIntentAnalysis
+        && precedingUserMessage
+        && resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode) !== "fast"
+      ) {
         const precedingUserContent = getOutlineMessageModelContent(precedingUserMessage);
         const directRequest = classifyDirectOutlineGenerationRequest(precedingUserContent);
         intentContextsRef.current = setOutlineSessionValue(intentContextsRef.current, capturedConvId, {
@@ -3734,9 +3896,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         const registry = new ToolRegistry();
         const baseSystemPrompt = buildOutlineAgentSystemPrompt({
           projectName: project.name,
+          mode: resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode),
         });
         const legacySystemPrompt = buildOutlineAgentSystemPrompt({
           projectName: project.name,
+          mode: resolveOutlineWorkflowMode(useWikiStore.getState().outlineWorkflowMode),
           soulDoc,
         });
         const regenerationPhaseRules = buildIntentPhaseSystemRules(regenerationIntentPhase);
@@ -4064,29 +4228,24 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         toast.error("当前没有活跃的对话,无法保存大纲");
         return;
       }
-      const cleanedContent = prepareOutlineSaveSourceContent(content);
-      if (!cleanedContent.trim()) {
-        toast.error("内容为空,无法保存为大纲");
-        return;
-      }
       const capturedConvId = activeConversationId;
       setSaveStatus("");
       try {
-        const draft = prepareOutlineSaveDraft(cleanedContent, []);
-        if (!draft.content.trim()) {
+        const built = buildClassifiedOutlineSaveRequest({
+          content,
+          sourceIntent: "手动保存 AI 大纲结果",
+          sourceHint: collectOutlineSaveSourceHint(capturedConvId),
+        });
+        if (!built) {
           toast.error("内容为空,无法保存为大纲");
           return;
         }
-        const classification = classifyOutlineSaveTarget({
-          title: draft.title,
-          content: draft.content,
-        });
 
         const currentConv = useOutlineChatStore.getState().conversations.find((c) => c.id === capturedConvId);
         const lastAssistantMsg = [...(currentConv?.messages ?? [])].reverse().find((m) => m.role === "assistant");
         const characterResults = lastAssistantMsg?.characterMultiAgentResults;
 
-        if (classification.fileType === "character") {
+        if (built.classification.fileType === "character") {
           if (characterResults && characterResults.length > 0) {
             const characterDrafts: CharacterSaveDraft[] = characterResults.map((r) => ({
               id: `${r.plan.roleType}:${r.plan.characterName}`,
@@ -4106,7 +4265,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             return;
           }
 
-          const extracted = extractCharacterSaveDrafts(draft.content);
+          const extracted = extractCharacterSaveDrafts(built.draft.content);
           if (extracted.drafts.length === 0) {
             setSaveStatus(extracted.errors.join(";"));
             return;
@@ -4120,24 +4279,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           return;
         }
 
-        const body = draft.content.replace(/^#\s+.+(?:\r?\n){1,2}/, "").trim();
-        const titleHeading = classification.fileName.replace(/\.md$/i, "");
-        const mdContent = body
-          ? `# ${titleHeading}\n\n${body}`
-          : draft.content.trim();
         presentOrQueueSaveBatch({
           title: "保存大纲文件",
           mode: "normal",
           characterDrafts: [],
-          requests: [{
-            targetFolder: classification.targetFolder,
-            fileName: classification.fileName,
-            fileType: classification.fileType,
-            writeMode: "create",
-            referencedSkills: [],
-            sourceIntent: "手动保存 AI 大纲结果",
-            content: mdContent,
-          }],
+          requests: [built.request],
         });
       } catch (err) {
         setSaveStatus(
@@ -4145,7 +4291,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         );
       }
     },
-    [activeConversationId, presentOrQueueSaveBatch, project],
+    [activeConversationId, collectOutlineSaveSourceHint, presentOrQueueSaveBatch, project],
   );
 
   const handleConfirmToolSave = useCallback(
@@ -4326,15 +4472,15 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               暂无大纲对话
             </span>
           )}
-          {outlineWorkflowStage !== "idle" && outlineWorkflowStage !== "saved" ? (
-            <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
-              {outlineWorkflowStage === "intent_analysis" ? "意图分析中" :
-               outlineWorkflowStage === "waiting_user_input" ? "等待选择" :
-               outlineWorkflowStage === "sufficiency_check" ? "生成中" :
-               "处理中"}
-            </span>
-          ) : null}
         </div>
+        {outlineWorkflowStage !== "idle" && outlineWorkflowStage !== "saved" ? (
+          <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 === "sufficiency_check" ? "生成中" :
+             "处理中"}
+          </span>
+        ) : null}
         <div className="relative shrink-0" ref={historyRef}>
           <button
             ref={historyButtonRef}
@@ -4502,7 +4648,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       <div className="shrink-0 border-t px-3 py-2">
         <div className="mb-2 flex items-center justify-between gap-2">
           <p className="text-xs text-muted-foreground">
-            通过固定选项生成大纲需求,再交给 AI 分析和追问
+            {isOutlineFastMode
+              ? "通过固定选项收集需求后,直接生成大纲正文"
+              : "通过固定选项生成大纲需求,再交给 AI 分析和追问"}
           </p>
           <button
             type="button"
@@ -4539,6 +4687,79 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 usage={liveContextUsage}
                 onCreateConversation={() => createConversation()}
               />
+              <div className="relative">
+                <Button
+                  ref={workflowModeTriggerRef}
+                  type="button"
+                  variant="outline"
+                  size="sm"
+                  aria-haspopup="listbox"
+                  aria-expanded={workflowModeDropdownOpen}
+                  aria-label="AI 大纲执行模式"
+                  className="h-8 shrink-0 rounded-full border px-2.5 text-xs"
+                  onClick={() => setWorkflowModeDropdownOpen((open) => !open)}
+                >
+                  <span className="mr-1">
+                    {OUTLINE_WORKFLOW_MODE_OPTIONS.find((option) => option.mode === outlineWorkflowMode)?.label ?? "标准"}
+                  </span>
+                  <ChevronDown className={`h-3.5 w-3.5 opacity-50 transition-transform ${workflowModeDropdownOpen ? "rotate-180" : ""}`} />
+                </Button>
+                {workflowModeDropdownOpen && workflowModeDropdownStyle
+                  ? createPortal(
+                    <>
+                      <div
+                        className="fixed inset-0"
+                        style={{ zIndex: 9998 }}
+                        onClick={() => setWorkflowModeDropdownOpen(false)}
+                      />
+                      <div
+                        ref={workflowModeDropdownRef}
+                        role="listbox"
+                        className="fixed rounded-md border bg-popover p-1 shadow-md"
+                        style={{
+                          left: workflowModeDropdownStyle.left,
+                          top: workflowModeDropdownStyle.top,
+                          width: workflowModeDropdownStyle.width,
+                          transform: workflowModeDropdownStyle.transform,
+                          zIndex: 9999,
+                        }}
+                      >
+                        {OUTLINE_WORKFLOW_MODE_OPTIONS.map(({ mode, label, description, routeDescription }) => (
+                          <button
+                            key={mode}
+                            type="button"
+                            role="option"
+                            aria-selected={outlineWorkflowMode === mode}
+                            className="flex w-full items-start gap-2 rounded-sm px-3 py-2 text-left hover:bg-accent"
+                            onClick={() => {
+                              setOutlineWorkflowMode(mode);
+                              setWorkflowModeDropdownOpen(false);
+                            }}
+                          >
+                            <Check
+                              className={`mt-0.5 h-4 w-4 shrink-0 ${
+                                outlineWorkflowMode === mode ? "opacity-100" : "opacity-0"
+                              }`}
+                            />
+                            <span className="min-w-0 flex-1">
+                              <span className="flex items-center gap-2 text-sm font-medium">
+                                <span>{label}</span>
+                                <span className="rounded border px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground">
+                                  {description}
+                                </span>
+                              </span>
+                              <span className="mt-1 block text-xs leading-5 text-muted-foreground">
+                                {routeDescription}
+                              </span>
+                            </span>
+                          </button>
+                        ))}
+                      </div>
+                    </>,
+                    document.body,
+                  )
+                  : null}
+              </div>
               <TooltipProvider delay={200}>
                 <OutlineGenerationMenu
                   disabled={submitDisabled}

+ 2 - 2
src/components/sources/outline-workbench-integration.spec.ts

@@ -33,10 +33,10 @@ describe("AI 大纲工作台页面接入", () => {
   it("移除旧的非对话式生成界面且不把独立生成模块接回工作台", () => {
     expect(existsSync(resolve(__dirname, "outline-generator-dialog.tsx"))).toBe(false)
     expect(existsSync(resolve(__dirname, "../../lib/novel/outline-generation.ts"))).toBe(true)
-    expect(existsSync(resolve(__dirname, "../../lib/novel/deep-outline-generation.ts"))).toBe(true)
+    expect(existsSync(resolve(__dirname, "../../lib/novel/deep-outline-generation.ts"))).toBe(false)
     expect(sourcesViewSource).not.toContain("runDeepOutlineGeneration")
     expect(toolbarSource).not.toContain("runDeepOutlineGeneration")
-    expect(promptTemplatesSource).toContain("outlineGeneration:")
+    expect(promptTemplatesSource).not.toContain("outlineGeneration:")
     expect(promptTemplatesSource).not.toContain("outlineRefinementGeneration:")
   })
 })

+ 1 - 54
src/i18n/en.json

@@ -1535,64 +1535,11 @@
       "ingestNoLlm": "No usable extract model configured. Please configure an LLM in Settings first.",
       "ingestCancelledNotification": "Outline ingestion cancelled",
       "bulkIngestError": "Bulk extraction failed: {{message}}",
-      "generationMayTakeLong": "Outline generation may take a while. You can hide this dialog and work on something else; we will remind you later.",
-      "hideAndContinue": "Hide and keep generating",
-      "generatingTitle": "Outline is generating in the background",
-      "openOutline": "Open Outline",
-      "generatedTitle": "Outline generation complete",
-      "generatedDescription": "Open it now, or continue by extracting the initial project memory.",
-      "ingestNow": "Extract Now",
-      "handleLater": "Later",
-      "generatedNotification": "Outline generation complete",
-      "openedNotification": "Outline opened",
       "ingestingNotification": "Extracting initial project memory...",
       "ingestSuccessNotification": "Initial project memory extracted successfully",
       "ingestSuccessTruncatedNotification": "Initial project memory extracted (outline truncated to fit context window: {{used}}/{{total}} chars, budget {{budget}} chars)",
       "ingestProgressTruncated": "{{name}} extracted (truncated {{used}}/{{total}} chars)",
-      "ingestFailedNotification": "Initial project memory extraction failed",
-      "ingestError": "Initial project memory extraction failed: {{message}}",
-      "addedToOutlineList": "Added to the outline list",
-      "addingToOutlineList": "Adding...",
-      "addToOutlineList": "Add to outline list",
-      "sectionGenerationTitle": "Continue generating outline sections",
-      "sectionGenerationDescription": "These sections are generated from the existing outline and memory and saved as outline pages.",
-      "sectionRequestLabel": "Section Generation Request",
-      "sectionRequestPlaceholder": "For example: generate chapter outlines from chapter 2 to chapter 20, focusing on the backlash from the Black Jade Token",
-      "sectionGenerating": "Generating {{title}}...",
-      "sectionGeneratingHint": "{{title}} selected. Add extra instructions, then click the generate button below.",
-      "sectionGenerated": "{{title}} was generated and saved to the outline pages",
-      "sectionGenerateFailed": "{{title}} generation failed: {{message}}",
-      "refineWriteModeLabel": "Save generated content as",
-      "refineWriteModeNewFile": "Create a new file and add it to the outline list",
-      "refineWriteModeAppendCurrent": "Write into the currently open outline file",
-      "refineWriteModeAppendHint": "To write into an existing outline, open an outline file from the outline list first.",
-      "sectionButtons": {
-        "chapterOutlines": "Generate Chapter Outlines",
-        "characterBriefs": "Generate Character Briefs",
-        "organizationsOutline": "Generate Factions",
-        "powerSystem": "Generate Power System",
-        "foreshadowingPlan": "Generate Foreshadowing",
-        "locationsOutline": "Generate Locations"
-      },
-      "ingestFailed": "Initial project memory extraction failed. Please check the LLM configuration and retry.",
-      "ready": "The outline is ready. Click “Extract Initial Memory” to write the initial characters, settings, and project memory.",
-      "genres": {
-        "mystery": "Mystery",
-        "xianxia": "Xianxia",
-        "romance": "Romance",
-        "military": "Military",
-        "scifi": "Sci-Fi",
-        "fantasy": "Fantasy",
-        "historical": "Historical",
-        "urban": "Urban",
-        "general": "General"
-      },
-      "scales": {
-        "short": "Short (100K words)",
-        "medium": "Medium (500K words)",
-        "long": "Long (1M words)",
-        "epic": "Epic (2M+ words)"
-      }
+      "ingestFailedNotification": "Initial project memory extraction failed"
     },
     "lint": {
       "title": "Consistency Check",

+ 0 - 13
src/i18n/i18n-parity.test.ts

@@ -76,19 +76,6 @@ function collectDynamicKeyWhitelist(): string[] {
     "novel.outline.type.story",
     "novel.outline.type.volume",
     "novel.outline.type.chapter",
-    "novel.outlineGenerator.genres.fantasy",
-    "novel.outlineGenerator.genres.xianxia",
-    "novel.outlineGenerator.genres.scifi",
-    "novel.outlineGenerator.genres.mystery",
-    "novel.outlineGenerator.genres.romance",
-    "novel.outlineGenerator.genres.historical",
-    "novel.outlineGenerator.genres.military",
-    "novel.outlineGenerator.genres.urban",
-    "novel.outlineGenerator.genres.general",
-    "novel.outlineGenerator.scales.short",
-    "novel.outlineGenerator.scales.medium",
-    "novel.outlineGenerator.scales.long",
-    "novel.outlineGenerator.scales.epic",
     "novel.search.keyword",
     "novel.search.vector",
     "novel.search.graph",

+ 1 - 54
src/i18n/zh.json

@@ -1477,64 +1477,11 @@
       "ingestNoLlm": "未配置可用的提取模型,请先在设置中配置 LLM。",
       "ingestCancelledNotification": "大纲摄取已取消",
       "bulkIngestError": "一键提取失败:{{message}}",
-      "generationMayTakeLong": "生成大纲可能需要较长时间,建议隐藏之后先去做其他的,稍后会提醒您。",
-      "hideAndContinue": "隐藏并后台生成",
-      "generatingTitle": "大纲正在后台生成",
-      "openOutline": "打开大纲",
-      "generatedTitle": "大纲已生成完成",
-      "generatedDescription": "可立即打开查看,或继续提取项目初始记忆。",
-      "ingestNow": "立即提取",
-      "handleLater": "稍后处理",
-      "generatedNotification": "大纲已生成完成",
-      "openedNotification": "已打开大纲",
       "ingestingNotification": "正在提取项目初始记忆...",
       "ingestSuccessNotification": "项目初始记忆提取成功",
       "ingestSuccessTruncatedNotification": "项目初始记忆提取成功(大纲过长,已按上下文窗口截断:{{used}}/{{total}} 字,预算 {{budget}} 字)",
       "ingestProgressTruncated": "{{name}} 提取完成(已截断 {{used}}/{{total}} 字)",
-      "ingestFailedNotification": "项目初始记忆提取失败",
-      "ingestError": "项目初始记忆提取失败:{{message}}",
-      "addedToOutlineList": "已加入到大纲列表",
-      "addingToOutlineList": "加入中...",
-      "addToOutlineList": "加入到大纲列表",
-      "sectionGenerationTitle": "继续生成大纲分项",
-      "sectionGenerationDescription": "这些内容会基于已有大纲和记忆生成,并保存到大纲页面。",
-      "sectionRequestLabel": "分项生成要求",
-      "sectionRequestPlaceholder": "例如:生成第2章到第20章细纲,重点围绕黑玉令反噬推进",
-      "sectionGenerating": "正在生成{{title}}...",
-      "sectionGeneratingHint": "已选择{{title}},可补充要求后点击下方生成按钮。",
-      "sectionGenerated": "{{title}}已生成并保存到大纲页面",
-      "sectionGenerateFailed": "{{title}}生成失败:{{message}}",
-      "refineWriteModeLabel": "生成内容保存方式",
-      "refineWriteModeNewFile": "生成新文件,并加入大纲列表",
-      "refineWriteModeAppendCurrent": "写入当前打开的大纲文件",
-      "refineWriteModeAppendHint": "如果要写入现有大纲,请先在大纲列表中打开一个大纲文件。",
-      "sectionButtons": {
-        "chapterOutlines": "生成细纲",
-        "characterBriefs": "生成人物小传",
-        "organizationsOutline": "生成势力",
-        "powerSystem": "生成金手指",
-        "foreshadowingPlan": "生成伏笔",
-        "locationsOutline": "生成地点"
-      },
-      "ingestFailed": "项目初始记忆提取失败,请检查 LLM 配置后重试",
-      "ready": "大纲已生成,可点击「提取初始记忆」写入人物、设定和项目初始记忆",
-      "genres": {
-        "mystery": "悬疑",
-        "xianxia": "仙侠",
-        "romance": "言情",
-        "military": "军事",
-        "scifi": "科幻",
-        "fantasy": "奇幻",
-        "historical": "历史",
-        "urban": "都市",
-        "general": "通用"
-      },
-      "scales": {
-        "short": "短篇(10万字)",
-        "medium": "中篇(50万字)",
-        "long": "长篇(100万字)",
-        "epic": "超长篇(200万字以上)"
-      }
+      "ingestFailedNotification": "项目初始记忆提取失败"
     },
     "graph": {
       "title": "小说图谱",

+ 14 - 0
src/lib/agent/workflow-mode.spec.ts

@@ -1,8 +1,10 @@
 import { describe, expect, it } from "vitest"
 import {
   DEFAULT_AI_WORKFLOW_MODE,
+  DEFAULT_OUTLINE_WORKFLOW_MODE,
   getWorkflowModeLabel,
   resolveAiWorkflowMode,
+  resolveOutlineWorkflowMode,
   type AiWorkflowMode,
 } from "./workflow-mode"
 
@@ -27,4 +29,16 @@ describe("workflow mode", () => {
     expect(getWorkflowModeLabel("standard")).toBe("标准")
     expect(getWorkflowModeLabel("strict")).toBe("严格")
   })
+
+  it("keeps the default outline workflow mode at standard", () => {
+    expect(DEFAULT_OUTLINE_WORKFLOW_MODE).toBe("standard")
+  })
+
+  it("maps outline workflow mode to fast or standard only", () => {
+    expect(resolveOutlineWorkflowMode("fast")).toBe("fast")
+    expect(resolveOutlineWorkflowMode("standard")).toBe("standard")
+    expect(resolveOutlineWorkflowMode("strict")).toBe("standard")
+    expect(resolveOutlineWorkflowMode(null)).toBe("standard")
+    expect(resolveOutlineWorkflowMode(undefined)).toBe("standard")
+  })
 })

+ 8 - 0
src/lib/agent/workflow-mode.ts

@@ -1,11 +1,19 @@
 export type AiWorkflowMode = "fast" | "standard" | "strict"
+export type OutlineWorkflowMode = Extract<AiWorkflowMode, "fast" | "standard">
 
 export const DEFAULT_AI_WORKFLOW_MODE: AiWorkflowMode = "standard"
+export const DEFAULT_OUTLINE_WORKFLOW_MODE: OutlineWorkflowMode = "standard"
 
 export function resolveAiWorkflowMode(value: AiWorkflowMode | null | undefined): AiWorkflowMode {
   return value ?? DEFAULT_AI_WORKFLOW_MODE
 }
 
+export function resolveOutlineWorkflowMode(
+  value: OutlineWorkflowMode | AiWorkflowMode | null | undefined,
+): OutlineWorkflowMode {
+  return value === "fast" ? "fast" : DEFAULT_OUTLINE_WORKFLOW_MODE
+}
+
 export function getWorkflowModeLabel(mode: AiWorkflowMode): string {
   switch (mode) {
     case "fast":

+ 0 - 248
src/lib/novel/deep-outline-generation.ts

@@ -1,248 +0,0 @@
-import type { LlmConfig } from "@/stores/wiki-store"
-import { useWikiStore } from "@/stores/wiki-store"
-import { hasUsableLlm } from "@/lib/has-usable-llm"
-import { streamChat, type ChatMessage, type RequestOverrides, type StreamCallbacks } from "@/lib/llm-client"
-import {
-  planOutlineRequestBudget,
-  type OutlineBudgetStage,
-} from "@/lib/context-budget"
-import {
-  getEffectiveMaxOutputTokens,
-  thinkingMinMaxTokens,
-} from "@/lib/llm-providers"
-import { USER_ABORT_MESSAGE } from "@/lib/user-abort"
-
-export interface DeepOutlineGenerationInput {
-  llmConfig: LlmConfig
-  userRequest: string
-  context: string
-  historyMessages?: ChatMessage[]
-}
-
-export interface DeepOutlineGenerationCallbacks {
-  onThinking?: (content: string) => void
-  onFinalContent?: (content: string) => void
-}
-
-export interface DeepOutlineGenerationResult {
-  finalContent: string
-  taskBrief: string
-  draftContent: string
-  selfCheck: string
-}
-
-export interface DeepOutlineGenerationDeps {
-  streamChat: (
-    config: LlmConfig,
-    messages: ChatMessage[],
-    callbacks: StreamCallbacks,
-    signal?: AbortSignal,
-    requestOverrides?: RequestOverrides,
-  ) => Promise<void>
-}
-
-const defaultDeps: DeepOutlineGenerationDeps = {
-  streamChat,
-}
-
-export async function runDeepOutlineGeneration(
-  input: DeepOutlineGenerationInput,
-  callbacks: DeepOutlineGenerationCallbacks = {},
-  deps: DeepOutlineGenerationDeps = defaultDeps,
-  signal?: AbortSignal,
-): Promise<DeepOutlineGenerationResult> {
-  const { providerConfigs } = useWikiStore.getState()
-  if (!hasUsableLlm(input.llmConfig, providerConfigs)) {
-    throw new Error("请先在设置中配置并选择一个可用的 AI 模型,或在大纲对话中选择模型后再试。")
-  }
-
-  const safeContext = ensureString(input.context)
-  const safeUserRequest = ensureString(input.userRequest)
-  const history = formatRecentHistory(input.historyMessages ?? [])
-
-  callbacks.onThinking?.(formatStageThinking(
-    "阶段1:大纲上下文分析",
-    [
-      `用户要求:${safeUserRequest || "未提供用户要求"}`,
-      safeContext.trim()
-        ? `已读取大纲/章节上下文,约 ${safeContext.length} 字。`
-        : "未读取到现有大纲或章节上下文,将仅基于本次要求生成。",
-      history ? "已纳入本轮大纲对话历史。" : "暂无可用的大纲对话历史。",
-    ].join("\n"),
-  ))
-
-  const taskBrief = await collectModelText(
-    input.llmConfig,
-    [{ role: "user", content: buildOutlineTaskBriefPrompt(safeContext, history, safeUserRequest) }],
-    deps,
-    signal,
-    (partial) => callbacks.onThinking?.(formatStageThinking("阶段2:大纲任务书", partial)),
-    "analysis",
-  )
-  callbacks.onThinking?.(formatStageThinking("阶段2:大纲任务书", taskBrief))
-
-  const draftContent = await collectModelText(
-    input.llmConfig,
-    [{ role: "user", content: buildOutlineDraftPrompt(safeContext, history, taskBrief, safeUserRequest) }],
-    deps,
-    signal,
-    (partial) => callbacks.onThinking?.(formatStageThinking("阶段3:大纲草稿", partial)),
-    "generation",
-  )
-  callbacks.onThinking?.(formatStageThinking("阶段3:大纲草稿", [
-    draftContent,
-    "",
-    `大纲草稿生成完成,约 ${draftContent.length} 字。`,
-  ].join("\n")))
-
-  const selfCheck = await collectModelText(
-    input.llmConfig,
-    [{ role: "user", content: buildOutlineSelfCheckPrompt(safeContext, history, taskBrief, draftContent, safeUserRequest) }],
-    deps,
-    signal,
-    (partial) => callbacks.onThinking?.(formatStageThinking("阶段4:大纲自检", partial)),
-    "analysis",
-  )
-  callbacks.onThinking?.(formatStageThinking("阶段4:大纲自检", selfCheck))
-  callbacks.onThinking?.(formatStageThinking("阶段5:完成", "采用自检后的大纲草稿作为最终输出。"))
-  callbacks.onFinalContent?.(draftContent)
-
-  return {
-    finalContent: draftContent,
-    taskBrief,
-    draftContent,
-    selfCheck,
-  }
-}
-
-async function collectModelText(
-  config: LlmConfig,
-  messages: ChatMessage[],
-  deps: DeepOutlineGenerationDeps,
-  signal?: AbortSignal,
-  onUpdate?: (content: string) => void,
-  budgetStage: OutlineBudgetStage = "generation",
-): Promise<string> {
-  let content = ""
-  let streamError: Error | null = null
-  const requestBudget = planOutlineRequestBudget({
-    maxContextSize: config.maxContextSize,
-    stage: budgetStage,
-    maxOutputTokens: getEffectiveMaxOutputTokens(config),
-    thinkingFloorTokens: thinkingMinMaxTokens(config.reasoning ?? { mode: "auto" }),
-  })
-
-  await deps.streamChat(
-    config,
-    messages,
-    {
-      onToken: (token) => {
-        content += token
-        onUpdate?.(content)
-      },
-      onDone: () => {},
-      onError: (error) => {
-        streamError = error
-      },
-    },
-    signal,
-    {
-      reasoning: config.reasoning,
-      max_tokens: requestBudget.outputTokens,
-    },
-  )
-
-  if (signal?.aborted) throw new Error(USER_ABORT_MESSAGE)
-  if (streamError) throw streamError
-  return content.trim()
-}
-
-function buildOutlineTaskBriefPrompt(context: string, history: string, userRequest: string): string {
-  return [
-    "你是小说大纲规划助手。请先输出一份大纲任务书,不要直接写大纲正文。",
-    "",
-    "任务书必须包含:",
-    "1. 本次要生成或细化的大纲范围。",
-    "2. 必须承接的已有剧情、人物状态、伏笔和时间线。",
-    "3. 必须避免推翻的既定设定。",
-    "4. 本次输出结构和重点。",
-    "",
-    `用户要求:${userRequest}`,
-    history ? `\n近期对话:\n${history}` : "",
-    "",
-    "已有大纲与章节上下文:",
-    context || "暂无可用上下文。",
-  ].join("\n")
-}
-
-function buildOutlineDraftPrompt(context: string, history: string, taskBrief: string, userRequest: string): string {
-  return [
-    "你是小说大纲写作助手。请根据大纲任务书生成大纲草稿。",
-    "",
-    "输出要求:",
-    "1. 只输出可保存为大纲的 Markdown 正文。",
-    "2. 不要输出思考过程、任务书、解释、引用来源或后续建议。",
-    "3. 必须承接已有大纲、章节内容、人物状态和伏笔。",
-    "4. 如果用户要求章节细纲,必须按以下五段结构输出:",
-    "   ### 本章钩子(开局1-2句如何抓住读者、期待感来源)",
-    "   ### 铺垫(本章塑造的舞台、规则、压力、配角作用)",
-    "   ### 爽点(具体反转、如何兑取绪释放)",
-    "   ### 结尾钩子(衔接下一章的悬念点)",
-    "   ### 作者手搓留白(标注哪些地方需要用人设卡/文风/玩梗手工填充,AI不可自行填充)",
-    "",
-    `用户要求:${userRequest}`,
-    history ? `\n近期对话:\n${history}` : "",
-    "",
-    "大纲任务书:",
-    taskBrief,
-    "",
-    "已有大纲与章节上下文:",
-    context || "暂无可用上下文。",
-  ].join("\n")
-}
-
-function buildOutlineSelfCheckPrompt(
-  context: string,
-  history: string,
-  taskBrief: string,
-  draftContent: string,
-  userRequest: string,
-): string {
-  return [
-    "你是小说大纲自检助手。请对大纲草稿做一次简短自检。",
-    "",
-    "只输出自检结论,不要改写正文。检查重点:",
-    "1. 是否承接用户要求。",
-    "2. 是否推翻已有大纲、人物状态、伏笔、时间线或正史规则。",
-    "3. 是否足够具体,能指导后续正文生成。",
-    "4. 是否存在明显缺口,并给出简短修正建议。",
-    "",
-    `用户要求:${userRequest}`,
-    history ? `\n近期对话:\n${history}` : "",
-    "",
-    "大纲任务书:",
-    taskBrief,
-    "",
-    "大纲草稿:",
-    draftContent,
-    "",
-    "已有上下文:",
-    context || "暂无可用上下文。",
-  ].join("\n")
-}
-
-function formatRecentHistory(messages: ChatMessage[]): string {
-  return messages
-    .filter((message) => message.role === "user" || message.role === "assistant")
-    .slice(-6)
-    .map((message) => `${message.role === "user" ? "用户" : "AI"}:${ensureString(message.content).slice(0, 1200)}`)
-    .join("\n\n")
-}
-
-function formatStageThinking(title: string, content: string): string {
-  return `## ${title}\n${ensureString(content).trim()}`
-}
-
-function ensureString(value: unknown): string {
-  return typeof value === "string" ? value : ""
-}

+ 0 - 46
src/lib/novel/outline-generation.spec.ts

@@ -103,58 +103,12 @@ vi.mock("@/stores/import-progress-store", () => ({
 
 import {
   assertOutlineIngestLlmReady,
-  buildOutlineGenerationPrompt,
-  buildOutlineRefinementContext,
   formatBulkOutlineIngestResult,
   OutlineIngestNotReadyError,
   runBulkOutlineIngest,
   runOutlineIngestPaths,
 } from "./outline-generation"
 
-describe("outline-generation context fallback", () => {
-  beforeEach(() => {
-    mocks.buildContextPackMock.mockReset()
-    mocks.ingestOutlineMock.mockReset()
-    mocks.syncSnapshotToMemoryMock.mockReset()
-    mocks.finalizeProjectMemoryRebuildMock.mockReset()
-    mocks.refreshProjectStateMock.mockReset()
-    mocks.hasUsableLlmMock.mockReset()
-    mocks.hasUsableLlmMock.mockReturnValue(true)
-    mocks.outlineStore.tasks = []
-    mocks.importProgressStore.tasks = []
-    mocks.importProgressStore.startTask.mockClear()
-    mocks.importProgressStore.updateTask.mockClear()
-    mocks.importProgressStore.finishTask.mockClear()
-    mocks.syncSnapshotToMemoryMock.mockResolvedValue({
-      writtenEntityPaths: [],
-      memoryPagePaths: [],
-      memorySyncedAt: "2026-01-01T00:00:00.000Z",
-    })
-    mocks.finalizeProjectMemoryRebuildMock.mockResolvedValue(undefined)
-    mocks.refreshProjectStateMock.mockResolvedValue(undefined)
-  })
-
-  it("still builds a generation prompt when context loading fails", async () => {
-    mocks.buildContextPackMock.mockRejectedValueOnce(new Error("context failed"))
-
-    const prompt = await buildOutlineGenerationPrompt("E:/Novel", "通用", "短篇", "测试")
-
-    expect(prompt).toContain("测试")
-    expect(prompt).toContain("请根据以上信息,设计一个完整、有吸引力的小说大纲")
-  })
-
-  it("returns an empty refinement context when context loading fails", async () => {
-    mocks.buildContextPackMock.mockRejectedValueOnce(new Error("context failed"))
-
-    const result = await buildOutlineRefinementContext("E:/Novel", "测试")
-
-    expect(result).toEqual({
-      context: "",
-      hasOutline: false,
-    })
-  })
-})
-
 describe("bulk outline ingest", () => {
   beforeEach(() => {
     mocks.buildContextPackMock.mockReset()

+ 4 - 635
src/lib/novel/outline-generation.ts

@@ -1,19 +1,9 @@
-import { createDirectory, fileExists, listDirectory, readFile, writeFile } from "@/commands/fs"
-import { streamChat } from "@/lib/llm-client"
-import { planOutlineRequestBudget } from "@/lib/context-budget"
-import { getOutputLanguage } from "@/lib/output-language"
-import { getFileName, normalizePath } from "@/lib/path-utils"
+import { listDirectory } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
 import { refreshProjectState } from "@/lib/project-refresh"
 import i18n from "@/i18n"
-import {
-  getEffectiveMaxOutputTokens,
-  thinkingMinMaxTokens,
-  type ChatMessage,
-} from "@/lib/llm-providers"
-import { PROMPTS } from "@/lib/novel/prompt-templates"
 import { useOutlineGenerationStore } from "@/stores/outline-generation-store"
 import { useImportProgressStore } from "@/stores/import-progress-store"
-import type { LlmConfig } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { mapWithConcurrency } from "@/lib/async-pool"
@@ -23,627 +13,8 @@ import {
   syncSnapshotToMemory,
   type OutlineIngestResult,
 } from "./chapter-ingest"
-import { buildContextPack, type ContextPack } from "./context-engine"
-import { resolveDefaultModel, resolveModelConfig, resolveNovelModel } from "@/lib/novel/model-resolver"
-import { buildPureOutlineMarkdown } from "./outline-markdown"
-
-export type OutlineSectionGenerationKey =
-  | "chapterOutlines"
-  | "characterBriefs"
-  | "organizationsOutline"
-  | "powerSystem"
-  | "foreshadowingPlan"
-  | "locationsOutline"
-
-export interface OutlineSectionGenerationConfig {
-  key: OutlineSectionGenerationKey
-  title: string
-  englishTitle: string
-  englishFileName: string
-  requestHint: string
-}
-
-export interface OutlineRefinementResult {
-  primaryPath: string | null
-  sections: Partial<Record<OutlineSectionGenerationKey, string>>
-  writtenPaths: string[]
-}
-
-export type OutlineRefinementWriteMode =
-  | "replaceDefault"
-  | "appendCurrent"
-  | "newFileAndAddToList"
-
-export interface OutlineRefinementWriteOptions {
-  mode?: OutlineRefinementWriteMode
-  targetPath?: string | null
-}
-
-export const OUTLINE_SECTION_GENERATION_CONFIGS: OutlineSectionGenerationConfig[] = [
-  {
-    key: "chapterOutlines",
-    title: "章节细纲",
-    englishTitle: "Chapter Outlines",
-    englishFileName: "chapter-outlines.md",
-    requestHint: "根据已有总纲、分卷大纲与章节推进需要,生成或完善章节细纲,明确每章目标、冲突、转折和结尾钩子。",
-  },
-  {
-    key: "characterBriefs",
-    title: "人物小传",
-    englishTitle: "Character Briefs",
-    englishFileName: "character-briefs.md",
-    requestHint: "根据已有大纲和项目记忆,整理主要人物的小传、动机、弧线、关系网络与当前状态。",
-  },
-  {
-    key: "organizationsOutline",
-    title: "组织势力设定",
-    englishTitle: "Faction Notes",
-    englishFileName: "organizations.md",
-    requestHint: "根据已有大纲和项目记忆,补完组织、势力、阵营目标、关系、冲突与剧情作用。",
-  },
-  {
-    key: "powerSystem",
-    title: "金手指与能力体系",
-    englishTitle: "Power System",
-    englishFileName: "power-system.md",
-    requestHint: "根据已有大纲和项目记忆,整理金手指、能力体系、规则、限制、代价与剧情作用。",
-  },
-  {
-    key: "foreshadowingPlan",
-    title: "伏笔计划",
-    englishTitle: "Foreshadowing Plan",
-    englishFileName: "foreshadowing-plan.md",
-    requestHint: "根据已有大纲和项目记忆,整理伏笔的埋设、推进、回收节奏与对应章节节点。",
-  },
-  {
-    key: "locationsOutline",
-    title: "地点设定",
-    englishTitle: "Location Notes",
-    englishFileName: "locations.md",
-    requestHint: "根据已有大纲和项目记忆,整理重要地点、地点规则、所属势力与剧情作用。",
-  },
-]
-
-function useEnglishOutlineNames(): boolean {
-  return getOutputLanguage() === "English"
-}
-
-function getOutlineSectionTitle(config: OutlineSectionGenerationConfig): string {
-  return useEnglishOutlineNames() ? config.englishTitle : config.title
-}
-
-function getOutlineSectionFileName(config: OutlineSectionGenerationConfig): string {
-  return useEnglishOutlineNames() ? config.englishFileName : `${config.title}.md`
-}
-
-function getStoryOutlineFileName(): string {
-  return useEnglishOutlineNames() ? "story-outline.md" : "总大纲.md"
-}
-
-function outlinePageMarkdown(title: string, content: string): string {
-  return buildPureOutlineMarkdown(title, content)
-}
-
-function appendContextSection(sections: string[], title: string, content: string | string[]) {
-  const text = Array.isArray(content) ? content.filter(Boolean).join("\n") : content.trim()
-  if (!text) return
-  sections.push(`## ${title}\n${text}`)
-}
-
-function formatOutlineRefinementContext(pack: ContextPack): string {
-  const sections: string[] = []
-  appendContextSection(sections, "已有大纲", pack.outline)
-  appendContextSection(sections, "最近剧情摘要", pack.recentSummaries)
-  appendContextSection(sections, "人物状态变化", pack.characterStates)
-  appendContextSection(sections, "角色认知", pack.cognitionStates)
-  appendContextSection(sections, "伏笔状态", pack.foreshadowingStates)
-  appendContextSection(sections, "时间线", pack.timeline)
-  appendContextSection(sections, "相关设定", pack.relatedSettings)
-  appendContextSection(sections, "正史规则", pack.canonRules)
-  appendContextSection(sections, "关联检索", pack.searchResults)
-  appendContextSection(sections, "图谱关联检索", pack.graphSearchResults)
-  return sections.join("\n\n").slice(0, 20000)
-}
-
-function formatOutlineGenerationContext(pack: ContextPack): string {
-  const sections: string[] = []
-  appendContextSection(sections, "已有故事记忆与项目资料", pack.soulDoc)
-  appendContextSection(sections, "已有大纲与故事骨架", pack.outline)
-  appendContextSection(sections, "最近剧情记忆", pack.recentSummaries)
-  appendContextSection(sections, "人物状态与关系", pack.characterStates)
-  appendContextSection(sections, "角色认知与信息差", pack.cognitionStates)
-  appendContextSection(sections, "伏笔与未回收线索", pack.foreshadowingStates)
-  appendContextSection(sections, "时间线与剧情节点", pack.timeline)
-  appendContextSection(sections, "设定与地点组织", pack.relatedSettings)
-  appendContextSection(sections, "正史规则", pack.canonRules)
-  appendContextSection(sections, "剧情记忆与卡片故事", pack.searchResults)
-  appendContextSection(sections, "图谱关联", pack.graphSearchResults)
-  return sections.join("\n\n").slice(0, 20000)
-}
-
-export async function buildOutlineGenerationPrompt(
-  projectPath: string,
-  genre: string,
-  scale: string,
-  premise: string,
-): Promise<string> {
-  const pack = await safeBuildOutlineContextPack(projectPath, `?????${premise || genre}`)
-  return PROMPTS.outlineGeneration(genre, scale, premise, formatOutlineGenerationContext(pack))
-}
-
-export async function hasOutlineForRefinement(projectPath: string): Promise<boolean> {
-  try {
-    const pp = normalizePath(projectPath)
-    const tree = await listDirectory(`${pp}/wiki/outlines`)
-    const flattenFiles = (nodes: typeof tree): typeof tree => {
-      const files: typeof tree = []
-      for (const node of nodes) {
-        if (node.is_dir && node.children) files.push(...flattenFiles(node.children))
-        else if (!node.is_dir && node.name.endsWith(".md")) files.push(node)
-      }
-      return files
-    }
-    return flattenFiles(tree).length > 0
-  } catch {
-    return false
-  }
-}
-
-export async function buildOutlineRefinementContext(
-  projectPath: string,
-  userRequest: string,
-): Promise<{ context: string; hasOutline: boolean }> {
-  const pack = await safeBuildOutlineContextPack(projectPath, userRequest)
-  return {
-    context: formatOutlineRefinementContext(pack),
-    hasOutline: Boolean(pack.outline.trim()),
-  }
-}
-
-function emptyOutlineContextPack(task: string): ContextPack {
-  return {
-    task,
-    chapterGoal: "",
-    outline: "",
-    recentSummaries: [],
-    previousChapterEnding: "",
-    characterStates: "",
-    soulDoc: "",
-    characterAuras: "",
-    storyFrameworkBinding: "",
-    cognitionStates: "",
-    foreshadowingStates: "",
-    timeline: "",
-    relatedSettings: "",
-    canonRules: "",
-    writingStyle: "",
-    searchResults: "",
-    graphSearchResults: "",
-    mustDo: "",
-    mustAvoid: "",
-    nextChapterAdvice: "",
-    revisionDirectives: "",
-  }
-}
-
-async function safeBuildOutlineContextPack(projectPath: string, task: string): Promise<ContextPack> {
-  try {
-    return await buildContextPack(projectPath, task)
-  } catch {
-    return emptyOutlineContextPack(task)
-  }
-}
-
-function buildSectionRefinementPrompt(
-  context: string,
-  config: OutlineSectionGenerationConfig,
-  userRequest: string,
-): string {
-  const sectionTitle = getOutlineSectionTitle(config)
-  return [
-    "请基于已有大纲和项目记忆,生成指定类型的小说设定文件。",
-    "",
-    "硬性约束:",
-    "1. 已有大纲、人物状态、角色认知、伏笔状态、时间线、正史规则和项目记忆都是最高优先级,不得推翻。",
-    "2. 本次用户要求只能用于补充、聚焦和完善,不得改写既定主线和核心设定。",
-    "3. 如果信息不足,只能做最小必要补完,且必须与现有设定兼容。",
-    "4. 只输出正文 Markdown,不要输出 JSON、代码块、解释、前言或额外说明。",
-    "",
-    "已有大纲与项目记忆:",
-    context || "当前暂无可读取的项目记忆,请仅基于已有大纲与本次要求进行细化。",
-    "",
-    "本次细化重点:",
-    userRequest.trim() || "未额外指定,请基于已有大纲与项目记忆完成细化。",
-    "",
-    `本次只生成:${sectionTitle}`,
-    config.requestHint,
-  ].join("\n")
-}
-
-function outlineGenerationOverrides(llmConfig: LlmConfig) {
-  const budget = planOutlineRequestBudget({
-    maxContextSize: llmConfig.maxContextSize,
-    stage: "generation",
-    maxOutputTokens: getEffectiveMaxOutputTokens(llmConfig),
-    thinkingFloorTokens: thinkingMinMaxTokens(llmConfig.reasoning ?? { mode: "auto" }),
-  })
-  return { max_tokens: budget.outputTokens }
-}
-
-async function streamOutlineSectionContent(
-  llmConfig: LlmConfig,
-  context: string,
-  config: OutlineSectionGenerationConfig,
-  userRequest: string,
-  signal?: AbortSignal,
-): Promise<string> {
-  let content = ""
-  let streamError: Error | null = null
-
-  await streamChat(llmConfig, [{ role: "user", content: buildSectionRefinementPrompt(context, config, userRequest) }], {
-    onToken: (token) => {
-      content += token
-    },
-    onDone: () => {},
-    onError: (err) => {
-      streamError = err
-    },
-  }, signal, outlineGenerationOverrides(llmConfig))
-
-  if (streamError) throw streamError
-  return content.trim()
-}
-
-async function getUniqueOutlinePath(outlinesDir: string, fileName: string): Promise<string> {
-  const firstPath = `${outlinesDir}/${fileName}`
-  if (!(await fileExists(firstPath))) return firstPath
-
-  const extensionIndex = fileName.lastIndexOf(".")
-  const stem = extensionIndex > 0 ? fileName.slice(0, extensionIndex) : fileName
-  const extension = extensionIndex > 0 ? fileName.slice(extensionIndex) : ""
-  for (let index = 2; index <= 99; index += 1) {
-    const candidate = `${outlinesDir}/${stem}-${index}${extension}`
-    if (!(await fileExists(candidate))) return candidate
-  }
-  return `${outlinesDir}/${stem}-${Date.now()}${extension}`
-}
-
-async function writeOutlineSectionFile(
-  projectPath: string,
-  outlinesDir: string,
-  config: OutlineSectionGenerationConfig,
-  sectionContent: string,
-  options: OutlineRefinementWriteOptions = {},
-): Promise<string | null> {
-  if (!sectionContent.trim()) return null
-  const sectionTitle = getOutlineSectionTitle(config)
-  const fileName = getOutlineSectionFileName(config)
-
-  if (options.mode === "appendCurrent" && options.targetPath) {
-    const targetPath = normalizePath(options.targetPath)
-    const existing = await readFile(targetPath).catch(() => "")
-    const appended = [
-      existing.trimEnd(),
-      "",
-      "---",
-      "",
-      `## ${sectionTitle}`,
-      "",
-      sectionContent.trim(),
-      "",
-    ].filter((part, index) => index > 0 || part).join("\n")
-    await writeFile(targetPath, appended)
-    return targetPath
-  }
-
-  const outlinePath = options.mode === "newFileAndAddToList"
-    ? await getUniqueOutlinePath(outlinesDir, fileName)
-    : `${outlinesDir}/${fileName}`
-  await writeFile(outlinePath, outlinePageMarkdown(sectionTitle, sectionContent))
-  if (options.mode === "newFileAndAddToList") {
-    await addOutlineFileToSourceList(projectPath, outlinePath)
-  }
-  return outlinePath
-}
-
-export async function generateOutlineRefinementSectionFile(
-  projectPath: string,
-  llmConfig: LlmConfig,
-  userRequest: string,
-  sectionKey: OutlineSectionGenerationKey,
-  writeOptions: OutlineRefinementWriteOptions = {},
-  signal?: AbortSignal,
-): Promise<string> {
-  const pp = normalizePath(projectPath)
-  const config = OUTLINE_SECTION_GENERATION_CONFIGS.find((item) => item.key === sectionKey)
-  if (!config) {
-    throw new Error("未知的大纲生成类型")
-  }
-
-  const { context, hasOutline } = await buildOutlineRefinementContext(pp, userRequest)
-  if (!hasOutline) {
-    throw new Error(i18n.t("novel.outlineGenerator.refineMissingOutline"))
-  }
-
-  const outlinesDir = `${pp}/wiki/outlines`
-  await createDirectory(outlinesDir)
-  const sectionContent = await streamOutlineSectionContent(llmConfig, context, config, userRequest, signal)
-  const outlinePath = await writeOutlineSectionFile(pp, outlinesDir, config, sectionContent, writeOptions)
-  if (!outlinePath) {
-    throw new Error(i18n.t("novel.outlineGenerator.refineEmpty"))
-  }
-  return outlinePath
-}
-
-export async function generateOutlineRefinementFiles(
-  projectPath: string,
-  llmConfig: LlmConfig,
-  userRequest: string,
-  writeOptions: OutlineRefinementWriteOptions = {},
-  signal?: AbortSignal,
-): Promise<OutlineRefinementResult> {
-  const pp = normalizePath(projectPath)
-  const { context, hasOutline } = await buildOutlineRefinementContext(pp, userRequest)
-  if (!hasOutline) {
-    throw new Error(i18n.t("novel.outlineGenerator.refineMissingOutline"))
-  }
-
-  const outlinesDir = `${pp}/wiki/outlines`
-  await createDirectory(outlinesDir)
-
-  const sections: Partial<Record<OutlineSectionGenerationKey, string>> = {}
-  const writtenPaths: string[] = []
-  let primaryPath: string | null = null
-
-  for (const config of OUTLINE_SECTION_GENERATION_CONFIGS) {
-    if (signal?.aborted) {
-      throw new Error("细化生成已取消")
-    }
-    const sectionContent = await streamOutlineSectionContent(llmConfig, context, config, userRequest, signal)
-    sections[config.key] = sectionContent
-    const outlinePath = await writeOutlineSectionFile(pp, outlinesDir, config, sectionContent, writeOptions)
-    if (!outlinePath) continue
-    writtenPaths.push(outlinePath)
-
-    if (config.key === "chapterOutlines") {
-      primaryPath = outlinePath
-    } else if (!primaryPath) {
-      primaryPath = outlinePath
-    }
-  }
-
-  if (writtenPaths.length === 0) {
-    throw new Error(i18n.t("novel.outlineGenerator.refineEmpty"))
-  }
-
-  return {
-    primaryPath,
-    sections,
-    writtenPaths,
-  }
-}
-
-export async function generateOutlineFile(
-  projectPath: string,
-  llmConfig: LlmConfig,
-  prompt: string,
-  signal?: AbortSignal,
-): Promise<{ outlinePath: string; content: string }> {
-  let content = ""
-  let streamError: Error | null = null
-
-  const messages: ChatMessage[] = [{ role: "user", content: prompt }]
-
-  await streamChat(llmConfig, messages, {
-    onToken: (token) => {
-      content += token
-    },
-    onDone: () => {},
-    onError: (err) => {
-      streamError = err
-    },
-  }, signal, outlineGenerationOverrides(llmConfig))
-
-  if (streamError) {
-    throw streamError
-  }
-
-  const pp = normalizePath(projectPath)
-  const outlinesDir = `${pp}/wiki/outlines`
-  await createDirectory(outlinesDir)
-  const outlineTitle = useEnglishOutlineNames() ? "Story Outline" : "总大纲"
-  const fullContent = outlinePageMarkdown(outlineTitle, content)
-  const outlinePath = `${outlinesDir}/${getStoryOutlineFileName()}`
-  await writeFile(outlinePath, fullContent)
-  return { outlinePath, content }
-}
-
-export async function runOutlineGenerationTask(taskId: string, llmConfig: LlmConfig): Promise<void> {
-  const task = useOutlineGenerationStore.getState().tasks.find((item) => item.id === taskId)
-  if (!task) return
-
-  const { providerConfigs } = useWikiStore.getState()
-  const effectiveLlmConfig = task.modelId
-    ? resolveModelConfig(task.modelId, llmConfig, providerConfigs)
-    : resolveDefaultModel(llmConfig)
-
-  if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
-    throw new Error("请先在设置中配置并选择一个可用的 AI 模型,或在大纲弹窗中选择模型后再试。")
-  }
-
-  const abortController = new AbortController()
-  const progressTaskId = useImportProgressStore.getState().startTask({
-    projectPath: task.projectPath,
-    kind: "outline_generation",
-    total: 100,
-    currentTitle: "生成大纲",
-    message: "正在生成大纲",
-    abortController,
-  })
-
-  try {
-    const { outlinePath } = await generateOutlineFile(task.projectPath, effectiveLlmConfig, task.prompt, abortController.signal)
-    await refreshProjectState(task.projectPath)
-    useOutlineGenerationStore.getState().updateTask(taskId, {
-      status: "generated",
-      outlinePath,
-      message: i18n.t("novel.outlineGenerator.generatedNotification"),
-      error: null,
-    })
-    useImportProgressStore.getState().finishTask(progressTaskId, "done", {
-      completed: 100,
-      total: 100,
-      currentTitle: "",
-      message: "大纲生成完成",
-    })
-  } catch (err) {
-    const message = err instanceof Error ? err.message : String(err)
-    useOutlineGenerationStore.getState().updateTask(taskId, {
-      status: "error",
-      error: message,
-      message,
-    })
-    useImportProgressStore.getState().finishTask(progressTaskId, "error", {
-      completed: 0,
-      total: 100,
-      currentTitle: "",
-      message: `大纲生成失败: ${message}`,
-    })
-  }
-}
-
-export async function runOutlineRefinementTask(taskId: string, llmConfig: LlmConfig): Promise<void> {
-  const task = useOutlineGenerationStore.getState().tasks.find((item) => item.id === taskId)
-  if (!task) return
-
-  const { providerConfigs } = useWikiStore.getState()
-  const effectiveLlmConfig = task.modelId
-    ? resolveModelConfig(task.modelId, llmConfig, providerConfigs)
-    : resolveDefaultModel(llmConfig)
-
-  if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
-    throw new Error("请先在设置中配置并选择一个可用的 AI 模型,或在大纲弹窗中选择模型后再试。")
-  }
-
-  const abortController = new AbortController()
-  const progressTaskId = useImportProgressStore.getState().startTask({
-    projectPath: task.projectPath,
-    kind: "outline_refinement",
-    total: 100,
-    currentTitle: task.displayTitle || "细化生成",
-    message: "正在细化生成大纲",
-    abortController,
-  })
-
-  try {
-    let outlinePath: string
-    if (task.selectedSectionKey) {
-      outlinePath = await generateOutlineRefinementSectionFile(
-        task.projectPath,
-        effectiveLlmConfig,
-        task.userRequest,
-        task.selectedSectionKey as OutlineSectionGenerationKey,
-        {
-          mode: (task.writeMode as OutlineRefinementWriteMode | null) ?? undefined,
-          targetPath: task.targetPath,
-        },
-        abortController.signal,
-      )
-    } else {
-      const result = await generateOutlineRefinementFiles(
-        task.projectPath,
-        effectiveLlmConfig,
-        task.userRequest,
-        {
-          mode: (task.writeMode as OutlineRefinementWriteMode | null) ?? undefined,
-          targetPath: task.targetPath,
-        },
-        abortController.signal,
-      )
-      if (!result.primaryPath) {
-        throw new Error(i18n.t("novel.outlineGenerator.refineEmpty"))
-      }
-      outlinePath = result.primaryPath
-    }
-
-    await refreshProjectState(task.projectPath)
-    useOutlineGenerationStore.getState().updateTask(taskId, {
-      status: "generated",
-      outlinePath,
-      message: task.selectedSectionKey && task.displayTitle
-        ? i18n.t("novel.outlineGenerator.sectionGenerated", { title: task.displayTitle })
-        : i18n.t("novel.outlineGenerator.refineGenerated"),
-      error: null,
-    })
-    useImportProgressStore.getState().finishTask(progressTaskId, "done", {
-      completed: 100,
-      total: 100,
-      currentTitle: "",
-      message: task.displayTitle ? `${task.displayTitle} 细化完成` : "细化生成完成",
-    })
-  } catch (err) {
-    const message = err instanceof Error ? err.message : String(err)
-    useOutlineGenerationStore.getState().updateTask(taskId, {
-      status: "error",
-      error: message,
-      message,
-    })
-    useImportProgressStore.getState().finishTask(progressTaskId, "error", {
-      completed: 0,
-      total: 100,
-      currentTitle: "",
-      message: `细化生成失败: ${message}`,
-    })
-  }
-}
-
-export async function openGeneratedOutline(taskId: string): Promise<void> {
-  const task = useOutlineGenerationStore.getState().tasks.find((item) => item.id === taskId)
-  if (!task?.outlinePath) return
-  const content = await readFile(task.outlinePath)
-  useWikiStore.getState().setActiveView("sources")
-  useWikiStore.getState().setSelectedFile(task.outlinePath)
-  useWikiStore.getState().setFileContent(content)
-  useOutlineGenerationStore.getState().updateTask(taskId, {
-    status: "generated",
-    message: i18n.t("novel.outlineGenerator.openedNotification"),
-  })
-}
-
-async function getUniqueSourceListPath(projectPath: string, fileName: string): Promise<string> {
-  const sourcesDir = `${normalizePath(projectPath)}/raw/sources`
-  const firstPath = `${sourcesDir}/${fileName}`
-  if (!(await fileExists(firstPath))) return firstPath
-
-  const extensionIndex = fileName.lastIndexOf(".")
-  const stem = extensionIndex > 0 ? fileName.slice(0, extensionIndex) : fileName
-  const extension = extensionIndex > 0 ? fileName.slice(extensionIndex) : ""
-  for (let index = 2; index <= 99; index += 1) {
-    const candidate = `${sourcesDir}/${stem}-${index}${extension}`
-    if (!(await fileExists(candidate))) return candidate
-  }
-  return `${sourcesDir}/${stem}-${Date.now()}${extension}`
-}
-
-export async function addOutlineFileToSourceList(projectPath: string, outlinePath: string): Promise<string> {
-  const pp = normalizePath(projectPath)
-  const normalizedOutlinePath = normalizePath(outlinePath)
-  const sourcesDir = `${pp}/raw/sources`
-  await createDirectory(sourcesDir)
-
-  const content = await readFile(normalizedOutlinePath)
-  const targetPath = await getUniqueSourceListPath(pp, getFileName(normalizedOutlinePath))
-  await writeFile(targetPath, content)
-
-  await refreshProjectState(projectPath)
-  return targetPath
-}
-
-export async function addOutlineTaskToSourceList(taskId: string): Promise<string | null> {
-  const task = useOutlineGenerationStore.getState().tasks.find((item) => item.id === taskId)
-  if (!task?.outlinePath) return null
-  return addOutlineFileToSourceList(task.projectPath, task.outlinePath)
-}
+import { resolveNovelModel } from "@/lib/novel/model-resolver"
+import { getOutlineFileName, outlineSnapshotExists } from "./outline-ingest-utils"
 
 export function createOutlineIngestTask(projectPath: string, outlinePath: string): string {
   return useOutlineGenerationStore.getState().createTask({
@@ -716,8 +87,6 @@ export interface RunOutlineIngestTaskOptions {
   manageProgress?: boolean
 }
 
-import { getOutlineFileName, outlineSnapshotExists } from "./outline-ingest-utils"
-
 function buildOutlineIngestFailureReason(err: unknown): string {
   if (err instanceof Error) return err.message
   return String(err)

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

@@ -86,6 +86,14 @@ describe("classifyDirectOutlineGenerationRequest", () => {
     expect(classifyDirectOutlineGenerationRequest("完善分卷大纲")?.module).toBe("卷纲")
     expect(classifyDirectOutlineGenerationRequest("补充力量体系")?.module).toBe("力量体系")
     expect(classifyDirectOutlineGenerationRequest("细化地点设定")?.module).toBe("地理设定")
+    expect(classifyDirectOutlineGenerationRequest("写一本仙侠,要前十章细纲")?.module).toBe("章节细纲")
+    expect(classifyDirectOutlineGenerationRequest("写个大纲")?.module).toBe("故事大纲")
+  })
+
+  it("不把描写、书写等含「写」的问答误判为生成请求", () => {
+    expect(classifyDirectOutlineGenerationRequest("描写一下这个人物的动机")).toBeNull()
+    expect(classifyDirectOutlineGenerationRequest("这段人物书写得怎么样")).toBeNull()
+    expect(classifyDirectOutlineGenerationRequest("填写人物关系表")).toBeNull()
   })
 })
 

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

@@ -28,9 +28,9 @@ export interface DirectOutlineGenerationRequest {
 
 const INTENT_OPEN_PATTERN = /<!--\s*intent_clarity\s*-->/i
 const INTENT_CLOSE_PATTERN = /<!--\s*\/intent_clarity\s*-->/i
-const OUTLINE_GENERATION_VERB_PATTERN = /生成|编写|完善|补充|细化|扩写|修改|重写|续写/
+const OUTLINE_GENERATION_VERB_PATTERN = /生成|编写|完善|补充|细化|扩写|修改|重写|续写|改写|写一本|写一部|写一篇|写个|写本/
 const OUTLINE_GENERATION_TARGETS: Array<{ pattern: RegExp; module: string }> = [
-  { pattern: /(?:第?\s*\d+\s*章[^\n]{0,12}大纲)|章纲|章节细纲|章节大纲/, module: "章节细纲" },
+  { pattern: /(?:第?\s*\d+\s*章[^\n]{0,12}大纲)|章纲|章节细纲|章节大纲|细纲/, module: "章节细纲" },
   { pattern: /卷纲|分卷大纲/, module: "卷纲" },
   { pattern: /人物|角色/, module: "人物小传" },
   { pattern: /组织势力|势力设定/, module: "组织势力设定" },

+ 91 - 10
src/lib/novel/outline-multi-agent-orchestrator.spec.ts

@@ -1,5 +1,6 @@
 import { describe, expect, it } from "vitest"
 import {
+  DEFAULT_OUTLINE_SUBAGENT_MERGE_MAX_CHARS,
   buildBoundedSubAgentMergePayload,
   planOutlineSubAgents,
   resumeOutlineMultiAgentWorkflow,
@@ -178,27 +179,107 @@ describe("AI大纲多 Agent 编排器", () => {
     expect(result.successfulAgents).toEqual(["parent", "child"])
   })
 
-  it("合并载荷按预算压缩完整子 Agent 输出", () => {
-    const results = Array.from({ length: 5 }, (_, index) => ({
+  it("合并载荷保留子 Agent 正文中段和写回项", () => {
+    const results = Array.from({ length: 2 }, (_, index) => ({
       agentId: `a${index}`,
       agentName: `Agent ${index}`,
       stage: "planning",
       usedSkills: [],
       confidence: 0.8,
       summary: `总结 ${index}`,
-      contentMarkdown: `内容 ${index}`.repeat(3000),
+      contentMarkdown: `开头 ${index}\n中间正文 ${index}\n结尾 ${index}`,
       constraints: ["保持一致"],
-      writebackItems: [],
+      writebackItems: [{
+        type: "character",
+        name: `角色${index}`,
+        content: `角色正文 ${index}`,
+        targetFolder: "人物小传",
+      }],
       risks: ["存在冲突"],
-      questions: [],
+      questions: ["是否保留这条线?"],
     }))
 
-    const payload = buildBoundedSubAgentMergePayload(results, 6000)
+    const payload = buildBoundedSubAgentMergePayload(results)
+
+    expect(payload).toContain("中间正文 0")
+    expect(payload).toContain("中间正文 1")
+    expect(payload).toContain("角色正文 0")
+    expect(payload).toContain("待确认问题:是否保留这条线?")
+    expect(payload).not.toContain("子 Agent 内容已压缩")
+  })
+
+  it("超出预算时整份丢弃靠后的 Agent,不砍中段", () => {
+    const results = [
+      {
+        agentId: "a0",
+        agentName: "Agent 0",
+        stage: "planning",
+        usedSkills: [],
+        confidence: 0.8,
+        summary: "总结 0",
+        contentMarkdown: "开头 0\n中间正文 0\n结尾 0",
+        constraints: [],
+        writebackItems: [],
+        risks: [],
+        questions: [],
+      },
+      {
+        agentId: "a1",
+        agentName: "Agent 1",
+        stage: "planning",
+        usedSkills: [],
+        confidence: 0.8,
+        summary: "总结 1",
+        contentMarkdown: "x".repeat(4000),
+        constraints: [],
+        writebackItems: [],
+        risks: [],
+        questions: [],
+      },
+    ]
+
+    const payload = buildBoundedSubAgentMergePayload(results, 200)
+
+    expect(payload).toContain("中间正文 0")
+    expect(payload).not.toContain("Agent 1")
+    expect(payload).not.toContain("子 Agent 内容已压缩")
+  })
+
+  it("默认上限下整份丢弃靠后的 Agent,不砍中段", () => {
+    const results = [
+      {
+        agentId: "a0",
+        agentName: "Agent 0",
+        stage: "planning",
+        usedSkills: [],
+        confidence: 0.8,
+        summary: "总结 0",
+        contentMarkdown: "开头 0\n中间正文 0\n结尾 0",
+        constraints: [],
+        writebackItems: [],
+        risks: [],
+        questions: [],
+      },
+      {
+        agentId: "a1",
+        agentName: "Agent 1",
+        stage: "planning",
+        usedSkills: [],
+        confidence: 0.8,
+        summary: "总结 1",
+        contentMarkdown: "x".repeat(DEFAULT_OUTLINE_SUBAGENT_MERGE_MAX_CHARS),
+        constraints: [],
+        writebackItems: [],
+        risks: [],
+        questions: [],
+      },
+    ]
+
+    const payload = buildBoundedSubAgentMergePayload(results)
 
-    expect(payload.length).toBeLessThanOrEqual(6000)
-    expect(payload).toContain("总结 0")
-    expect(payload).toContain("总结 4")
-    expect(payload).toContain("冲突与风险")
+    expect(payload).toContain("中间正文 0")
+    expect(payload).not.toContain("Agent 1")
+    expect(payload.length).toBeLessThan(DEFAULT_OUTLINE_SUBAGENT_MERGE_MAX_CHARS)
   })
 })
 

+ 38 - 20
src/lib/novel/outline-multi-agent-orchestrator.ts

@@ -293,7 +293,7 @@ async function executeWithRetry(
           `### ${dependencyTask?.dimension || dependencyTask?.name || id}`,
           `结论:${result.summary}`,
           result.constraints.length > 0 ? `约束:${result.constraints.join(";")}` : "",
-          result.contentMarkdown.slice(0, 1600),
+          result.contentMarkdown,
         ].filter(Boolean).join("\n")
       }),
     ] : []),
@@ -340,32 +340,50 @@ function formatError(error: unknown): string {
   return error instanceof Error ? error.message : String(error)
 }
 
-function truncateMergeContent(value: string, maxChars: number): string {
-  if (value.length <= maxChars) return value
-  const marker = "\n[子 Agent 内容已压缩]\n"
-  if (maxChars <= marker.length) return value.slice(0, maxChars)
-  const available = maxChars - marker.length
-  const head = Math.ceil(available * 0.65)
-  return `${value.slice(0, head)}${marker}${value.slice(-(available - head))}`
+function formatWritebackItems(items: OutlineSubAgentResult["writebackItems"]): string {
+  if (items.length === 0) return ""
+  return [
+    "## 写回项",
+    ...items.map((item) => [
+      `### ${item.name || item.type || "未命名写回"}`,
+      item.type ? `类型:${item.type}` : "",
+      item.targetFolder ? `目标:${item.targetFolder}` : "",
+      item.content,
+    ].filter(Boolean).join("\n")),
+  ].join("\n")
 }
 
-export function buildBoundedSubAgentMergePayload(results: OutlineSubAgentResult[], maxChars = 24_000): string {
-  const limit = Math.max(0, Math.floor(maxChars))
-  if (limit === 0 || results.length === 0) return ""
-  const fixedSections = results.map((result) => [
+function buildSubAgentMergeSection(result: OutlineSubAgentResult): string {
+  return [
     `## ${result.agentName}`,
     `摘要:${result.summary}`,
     `置信度:${result.confidence}`,
     result.constraints.length > 0 ? `约束:${result.constraints.join(";")}` : "",
     result.risks.length > 0 ? `冲突与风险:${result.risks.join(";")}` : "冲突与风险:无",
-  ].filter(Boolean).join("\n"))
-  const fixedLength = fixedSections.reduce((sum, section) => sum + section.length + 2, 0)
-  const contentShare = Math.max(0, Math.floor((limit - fixedLength) / results.length))
-  const payload = results.map((result, index) => [
-    fixedSections[index],
-    contentShare > 0 ? truncateMergeContent(result.contentMarkdown, contentShare) : "",
-  ].filter(Boolean).join("\n")).join("\n\n")
-  return payload.slice(0, limit)
+    result.questions.length > 0 ? `待确认问题:${result.questions.join(";")}` : "",
+    formatWritebackItems(result.writebackItems),
+    result.contentMarkdown,
+  ].filter(Boolean).join("\n")
+}
+
+export const DEFAULT_OUTLINE_SUBAGENT_MERGE_MAX_CHARS = 48_000
+
+export function buildBoundedSubAgentMergePayload(results: OutlineSubAgentResult[], maxChars = DEFAULT_OUTLINE_SUBAGENT_MERGE_MAX_CHARS): string {
+  if (results.length === 0) return ""
+  const sections = results.map((result) => buildSubAgentMergeSection(result))
+  const payload = sections.join("\n\n")
+  if (!Number.isFinite(maxChars) || maxChars <= 0) return payload
+  if (payload.length <= maxChars) return payload
+
+  const kept: string[] = []
+  let used = 0
+  for (const section of sections) {
+    const extra = kept.length > 0 ? 2 : 0
+    if (kept.length > 0 && used + extra + section.length > maxChars) break
+    kept.push(section)
+    used += extra + section.length
+  }
+  return kept.join("\n\n")
 }
 
 function inferSubAgentKind(skillName: string): OutlineSubAgentKind {

+ 0 - 23
src/lib/novel/outline-result-protocol.spec.ts

@@ -1,7 +1,6 @@
 import { describe, expect, it } from "vitest"
 import {
   coerceOutlineSubAgentResult,
-  parseOutlineFinalResult,
   parseOutlineSubAgentResult,
 } from "./outline-result-protocol"
 
@@ -29,28 +28,6 @@ describe("AI大纲结构化输出协议", () => {
     }
   })
 
-  it("解析 Markdown 代码块包裹的最终保存协议", () => {
-    const result = parseOutlineFinalResult(`\`\`\`json
-{
-  "outline_type": "chapter-outline",
-  "target_folder": "章纲",
-  "file_name": "章纲-第001章.md",
-  "status": "草稿",
-  "content_markdown": "# 章纲(第001章)",
-  "quality_check": { "valid": true, "errors": [], "warnings": [] },
-  "writeback_items": [],
-  "source_agents": ["topic-agent"]
-}
-\`\`\``)
-
-    expect(result.ok).toBe(true)
-    if (result.ok) {
-      expect(result.value.fileName).toBe("章纲-第001章.md")
-      expect(result.value.qualityCheck.valid).toBe(true)
-      expect(result.value.sourceAgents).toEqual(["topic-agent"])
-    }
-  })
-
   it("缺少必要字段时返回中文错误", () => {
     const result = parseOutlineSubAgentResult(JSON.stringify({
       agent_id: "topic-agent",

+ 0 - 54
src/lib/novel/outline-result-protocol.ts

@@ -26,21 +26,6 @@ export interface OutlineSubAgentFallbackContext {
   stage?: string
 }
 
-export interface OutlineFinalResult {
-  outlineType: string
-  targetFolder: string
-  fileName: string
-  status: string
-  contentMarkdown: string
-  qualityCheck: {
-    valid: boolean
-    errors: string[]
-    warnings: string[]
-  }
-  writebackItems: OutlineWritebackItem[]
-  sourceAgents: string[]
-}
-
 export type OutlineProtocolParseResult<T> =
   | { ok: true; value: T }
   | { ok: false; error: string }
@@ -175,42 +160,3 @@ export function coerceOutlineSubAgentResult(
     },
   }
 }
-
-export function parseOutlineFinalResult(text: string): OutlineProtocolParseResult<OutlineFinalResult> {
-  const parsed = parseJsonObject(text)
-  if (!parsed.ok) return parsed
-
-  const required = [
-    "outline_type",
-    "target_folder",
-    "file_name",
-    "status",
-    "content_markdown",
-    "quality_check",
-    "writeback_items",
-    "source_agents",
-  ]
-  const missing = missingFields(parsed.value, required)
-  if (missing.length > 0) {
-    return { ok: false, error: `最终大纲输出缺少必要字段:${missing.join("、")}` }
-  }
-
-  const quality = parsed.value.quality_check as Record<string, unknown>
-  return {
-    ok: true,
-    value: {
-      outlineType: String(parsed.value.outline_type),
-      targetFolder: String(parsed.value.target_folder),
-      fileName: String(parsed.value.file_name),
-      status: String(parsed.value.status),
-      contentMarkdown: String(parsed.value.content_markdown),
-      qualityCheck: {
-        valid: Boolean(quality?.valid),
-        errors: asStringArray(quality?.errors),
-        warnings: asStringArray(quality?.warnings),
-      },
-      writebackItems: asWritebackItems(parsed.value.writeback_items),
-      sourceAgents: asStringArray(parsed.value.source_agents),
-    },
-  }
-}

+ 31 - 0
src/lib/novel/outline-save-classifier.spec.ts

@@ -71,4 +71,35 @@ describe("outline-save-classifier", () => {
       targetFolder: "卷纲",
     })
   })
+
+  it("章纲标题优先于正文里引用的卷纲节拍", () => {
+    expect(classifyOutlineSaveTarget({
+      title: "章纲-第46章:第一次冷处理",
+      content: [
+        "# 章纲-第46章:第一次冷处理",
+        "",
+        "## 上层依据",
+        "- **卷纲节拍:** 终局期",
+        "- 依据:卷纲/第一卷.md",
+        "",
+        "## 本章目标",
+        "男主第一次被公司冷处理。",
+      ].join("\n"),
+    })).toMatchObject({
+      fileType: "chapter-outline",
+      targetFolder: "章纲",
+      fileName: "章纲-第046章-第一次冷处理.md",
+    })
+  })
+
+  it("用户原话是章纲时,正文引用卷纲不能改判成卷纲", () => {
+    expect(classifyOutlineSaveTarget({
+      title: "第一次冷处理",
+      content: "## 上层依据\n- **卷纲节拍:** 终局期",
+      sourceHint: "编写第 46 章章纲",
+    })).toMatchObject({
+      fileType: "chapter-outline",
+      targetFolder: "章纲",
+    })
+  })
 })

+ 56 - 3
src/lib/novel/outline-save-classifier.ts

@@ -6,6 +6,8 @@ export interface OutlineSaveClassificationInput {
   referencedSkills?: string[]
   title: string
   content: string
+  /** 用户原话、意图模块名等,用于在正文引用「卷纲」时仍能识别章纲 */
+  sourceHint?: string
 }
 
 export interface OutlineSaveClassification {
@@ -40,10 +42,56 @@ export function inferOutlineFileTypeFromSkills(skills: string[] = []): OutlineSa
   return null
 }
 
+const CHAPTER_TYPE_MARK = /章纲|细纲|章节细纲|章节大纲/
+const VOLUME_TYPE_MARK = /卷纲|分卷大纲/
+const CHAPTER_SUBJECT = /章纲|细纲|第\s*\d{1,4}\s*章/
+const VOLUME_SUBJECT = /卷纲|分卷大纲|第\s*(?:\d+|[一二三四五六七八九十百千万]+)\s*卷/
+
+function extractDocumentSubject(title: string, content: string): string {
+  const heading = title.replace(/^#+\s*/, "").trim()
+  const firstH1 = content.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? ""
+  return `${heading}\n${firstH1}`
+}
+
+function resolveChapterOrVolumeType(text: string): OutlineSaveRequestFileType | null {
+  const chapter = CHAPTER_SUBJECT.test(text)
+  const volume = VOLUME_SUBJECT.test(text)
+  if (chapter && !volume) return "chapter-outline"
+  if (volume && !chapter) return "volume-outline"
+  if (chapter && volume) {
+    if (CHAPTER_TYPE_MARK.test(text) && !VOLUME_TYPE_MARK.test(text)) return "chapter-outline"
+    if (VOLUME_TYPE_MARK.test(text) && !CHAPTER_TYPE_MARK.test(text)) return "volume-outline"
+    if (CHAPTER_TYPE_MARK.test(text)) return "chapter-outline"
+    return "volume-outline"
+  }
+  return null
+}
+
+function inferFileTypeFromHint(hint: string): OutlineSaveRequestFileType | null {
+  const text = hint.trim()
+  if (!text) return null
+  const chapterOrVolume = resolveChapterOrVolumeType(text)
+  if (chapterOrVolume) return chapterOrVolume
+  if (/人物小传|角色小传/.test(text)) return "character"
+  if (/伏笔计划|伏笔/.test(text) && /伏笔/.test(text)) return "foreshadowing"
+  if (/组织势力|势力设定/.test(text)) return "organization"
+  if (/力量体系|能力体系|金手指|世界观|背景设定|地理设定/.test(text)) return "setting"
+  if (/质量检查/.test(text)) return "quality-report"
+  if (/故事大纲|总纲/.test(text)) return "outline"
+  return null
+}
+
 function inferFileTypeFromContent(title: string, content: string): OutlineSaveRequestFileType {
   const text = `${title}\n${content}`
-  if (/卷纲|分卷|第\s*(?:\d+|[一二三四五六七八九十百千万]+)\s*卷/.test(text)) return "volume-outline"
-  if (/章纲|细纲|第\s*\d{1,4}\s*章/.test(text)) return "chapter-outline"
+  if (CHAPTER_TYPE_MARK.test(text)) return "chapter-outline"
+  if (
+    /(?:^|\n)#+\s*.*(?:卷纲|分卷大纲)/.test(text)
+    || /第\s*(?:\d+|[一二三四五六七八九十百千万]+)\s*卷\s*(?:卷纲|大纲)/.test(text)
+  ) {
+    return "volume-outline"
+  }
+  if (/第\s*\d{1,4}\s*章/.test(text)) return "chapter-outline"
+  if (/第\s*(?:\d+|[一二三四五六七八九十百千万]+)\s*卷/.test(text)) return "volume-outline"
   if (/人物小传|角色小传|男主|女主|男配|女配|反派|角色定位/.test(text)) return "character"
   if (/伏笔|线索|回收/.test(text)) return "foreshadowing"
   if (/组织|势力|阵营|门派|家族/.test(text)) return "organization"
@@ -55,7 +103,10 @@ function inferFileTypeFromContent(title: string, content: string): OutlineSaveRe
 function inferFileName(fileType: OutlineSaveRequestFileType, title: string, content: string): string {
   const chapter = `${title}\n${content}`.match(/第\s*(\d{1,4})\s*章\s*([^\n#]*)/)
   if (fileType === "chapter-outline" && chapter) {
-    return formatChapterOutlineFileName(Number(chapter[1]), chapter[2]?.trim() ?? "")
+    return formatChapterOutlineFileName(
+      Number(chapter[1]),
+      chapter[2]?.trim().replace(/^[::\s-]+/, "") ?? "",
+    )
   }
 
   const safe = sanitizeOutlineFileNamePart(title.replace(/^#+\s*/, "")) || "大纲"
@@ -70,6 +121,8 @@ export function classifyOutlineSaveTarget(input: OutlineSaveClassificationInput)
   const fileType =
     input.explicitFileType ??
     inferOutlineFileTypeFromSkills(input.referencedSkills) ??
+    resolveChapterOrVolumeType(extractDocumentSubject(input.title, input.content)) ??
+    inferFileTypeFromHint(input.sourceHint ?? "") ??
     inferFileTypeFromContent(input.title, input.content)
 
   return {

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

@@ -54,6 +54,29 @@ describe("outline-save-request", () => {
     })
   })
 
+  it("未闭合 json 围栏时仍能回收完整保存请求", () => {
+    const result = parseOutlineSaveRequests([
+      SAMPLE_CHAPTER_OUTLINE,
+      "",
+      "```json",
+      JSON.stringify({
+        outlineSaveRequest: {
+          targetFolder: "章纲",
+          fileName: "章纲-第001章.md",
+          fileType: "chapter-outline",
+          writeMode: "create",
+          referencedSkills: [],
+          sourceIntent: "生成第001章章纲",
+          content: SAMPLE_CHAPTER_OUTLINE,
+        },
+      }),
+    ].join("\n"))
+
+    expect(result.requests).toHaveLength(1)
+    expect(result.requests[0]?.fileName).toBe("章纲-第001章.md")
+    expect(result.requests[0]?.content).toContain("本章目标")
+  })
+
   it("拒绝绝对路径和上级目录路径", () => {
     const result = parseOutlineSaveRequests(JSON.stringify({
       outlineSaveRequests: [

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

@@ -115,6 +115,30 @@ function stripAbsoluteToRelativeFolder(value: string): string {
   return parts.length > 0 ? parts[parts.length - 1] : normalized
 }
 
+function extractBalancedJsonObject(text: string): 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 text.slice(start, index + 1).trim()
+    }
+  }
+  return null
+}
+
 function extractJsonCandidates(text: string): string[] {
   const candidates: string[] = []
   const fencePattern = /```(?:json)?\s*([\s\S]*?)```/gi
@@ -122,11 +146,31 @@ function extractJsonCandidates(text: string): string[] {
     candidates.push(match[1].trim())
   }
 
+  const lastFence = text.lastIndexOf("```")
+  if (lastFence >= 0) {
+    const afterOpen = text.slice(lastFence + 3)
+    if (!afterOpen.includes("```")) {
+      const unclosed = afterOpen.replace(/^(?:json)?\s*/i, "").trim()
+      if (/outlineSaveRequests?/i.test(unclosed)) {
+        candidates.push(extractBalancedJsonObject(unclosed) ?? unclosed)
+      }
+    }
+  }
+
   const trimmed = text.trim()
-  if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
-    candidates.push(trimmed)
+  if (trimmed.startsWith("{") && (trimmed.endsWith("}") || /outlineSaveRequests?/i.test(trimmed))) {
+    candidates.push(extractBalancedJsonObject(trimmed) ?? trimmed)
   }
-  return Array.from(new Set(candidates))
+
+  const lastBrace = text.lastIndexOf("{")
+  if (lastBrace >= 0) {
+    const tail = text.slice(lastBrace)
+    if (/outlineSaveRequests?/i.test(tail)) {
+      candidates.push(extractBalancedJsonObject(tail) ?? tail)
+    }
+  }
+
+  return Array.from(new Set(candidates.filter(Boolean)))
 }
 
 function isRecord(value: unknown): value is Record<string, unknown> {

+ 12 - 0
src/lib/novel/outline-wizard.spec.ts

@@ -63,6 +63,18 @@ describe("AI大纲生成向导请求", () => {
     expect(prompt).toContain("如果信息足够,请给出生成方案并询问用户是否确认开始生成。")
   })
 
+  it("快速模式只保留需求事实,不注入 Skill 名单和固定工作流", () => {
+    const prompt = buildOutlineWizardPrompt(baseRequest, { mode: "fast" })
+
+    expect(prompt).toContain("用户已提交小说生成需求")
+    expect(prompt).toContain("- 题材:都市")
+    expect(prompt).toContain("直接生成可保存的大纲正文")
+    expect(prompt).not.toContain("本次优先调用 Skill")
+    expect(prompt).not.toContain("固定工作流")
+    expect(prompt).not.toContain("请先分析该需求")
+    expect(prompt).not.toContain("询问用户是否确认开始生成")
+  })
+
   it("向导 Prompt 明确采用充分性闸门和分阶段生成工作流", () => {
     const prompt = buildOutlineWizardPrompt(baseRequest)
 

+ 18 - 4
src/lib/novel/outline-wizard.ts

@@ -300,7 +300,10 @@ export function getOutlineWizardSkillNames(request: OutlineWizardRequest): strin
   return Array.from(names)
 }
 
-export function buildOutlineWizardPrompt(request: OutlineWizardRequest): string {
+export function buildOutlineWizardPrompt(
+  request: OutlineWizardRequest,
+  options?: { mode?: "fast" | "standard" },
+): string {
   const task = optionLabel(OUTLINE_WIZARD_TASK_OPTIONS, request.task)
   const length = optionLabel(OUTLINE_WIZARD_LENGTH_OPTIONS, request.length)
   const channel = optionLabel(OUTLINE_WIZARD_CHANNEL_OPTIONS, request.channel)
@@ -313,9 +316,7 @@ export function buildOutlineWizardPrompt(request: OutlineWizardRequest): string
     request.materialSource,
   )
   const genre = getOutlineWizardGenreLabel(request)
-  const skillNames = getOutlineWizardSkillNames(request)
-
-  return [
+  const demand = [
     "用户已提交小说生成需求:",
     "",
     `- 任务:${task}`,
@@ -332,6 +333,19 @@ export function buildOutlineWizardPrompt(request: OutlineWizardRequest): string
     `- 作品规模:${request.scale.trim() || "AI 根据篇幅和题材推荐"}`,
     `- 叙事要求:${narrative}`,
     `- 已有资料:${materialSource}`,
+  ]
+
+  if (options?.mode === "fast") {
+    return [
+      ...demand,
+      "",
+      "请根据以上需求直接生成可保存的大纲正文,不要进入需求分析、意图分析或多 Agent 编排,不要等待用户确认后再写。",
+    ].join("\n")
+  }
+
+  const skillNames = getOutlineWizardSkillNames(request)
+  return [
+    ...demand,
     "",
     "## 本次优先调用 Skill",
     "请优先使用以下 SkillHub Skill 进行需求分析、题材判断和大纲生成,不要输出 Skill 说明:",

+ 1 - 24
src/lib/novel/prompt-templates.ts

@@ -120,30 +120,7 @@ export const PROMPTS = {
     "### 节奏特点",
     "{rhythmFeatures}",
     "",
-    "### 禁忌表达",
+      "### 禁忌表达",
     "{tabooExpressions}",
   ].join("\n"),
-
-  outlineGeneration: (genre: string, scale: string, premise: string, context: string) =>
-    [
-      "你是一位专业的小说大纲设计师。",
-      "",
-      `## 小说类型:${genre}`,
-      `## 故事规模:${scale}`,
-      `## 核心设定:${premise}`,
-      "",
-      "## 已有项目资料",
-      context,
-      "",
-      "## 任务要求",
-      "请根据以上信息,设计一个完整、有吸引力的小说大纲,包括:",
-      "1. 核心故事概念与主题",
-      "2. 主要人物设定(主角、重要配角、反派)",
-      "3. 故事世界观与背景设定",
-      "4. 整体故事结构(分卷/分篇规划)",
-      "5. 关键情节节点与转折点",
-      "6. 主要伏笔与悬念线",
-      "",
-      "请使用 Markdown 格式输出,保持结构清晰、层次分明。",
-    ].join("\n"),
 } as const

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

@@ -1,5 +1,10 @@
 import { describe, expect, it } from "vitest"
-import { prepareOutlineSaveDraft, prepareOutlineSaveSourceContent } from "./outline-save"
+import {
+  buildClassifiedOutlineSaveRequest,
+  isSaveableOutlineDeliverable,
+  prepareOutlineSaveDraft,
+  prepareOutlineSaveSourceContent,
+} from "./outline-save"
 
 describe("outline save draft", () => {
   it("ignores frontmatter when deriving an outline title", () => {
@@ -80,4 +85,46 @@ describe("outline save draft", () => {
     expect(source).not.toContain("outlineSaveRequest")
     expect(source).not.toContain("```")
   })
+
+  it("完整章纲即使引用卷纲节拍也判定为可保存章纲", () => {
+    const content = [
+      "# 章纲-第46章:第一次冷处理",
+      "",
+      "## 上层依据",
+      "- **卷纲节拍:** 终局期",
+      "",
+      "## 本章目标",
+      "男主第一次被公司冷处理,被迫从执行者转成旁观者。",
+      "",
+      "## 核心事件",
+      "1. 早会被点名缺席关键项目。",
+      "2. 直属领导改口称他先休息。",
+      "3. 同事开始绕开他同步进度。",
+      "",
+      "## 场景顺序",
+      "公司早会 → 茶水间 → 回家。",
+    ].join("\n")
+
+    expect(isSaveableOutlineDeliverable(content)).toBe(true)
+    expect(buildClassifiedOutlineSaveRequest({
+      content,
+      sourceIntent: "生成完成后自动保存",
+      sourceHint: "编写第 46 章章纲",
+    })).toMatchObject({
+      classification: {
+        fileType: "chapter-outline",
+        targetFolder: "章纲",
+      },
+      request: {
+        fileType: "chapter-outline",
+        targetFolder: "章纲",
+        sourceIntent: "生成完成后自动保存",
+      },
+    })
+  })
+
+  it("短问答不视为可保存大纲", () => {
+    expect(isSaveableOutlineDeliverable("第46章可以按卷纲节拍写冷处理。")).toBe(false)
+    expect(isSaveableOutlineDeliverable("好的,我明白了。")).toBe(false)
+  })
 })

+ 58 - 1
src/lib/outline-save.ts

@@ -1,13 +1,23 @@
 import { parseFrontmatter } from "./frontmatter"
 import { stripStructuredMarkers } from "./novel/outline-intent-clarity"
 import { cleanNextStepArtifacts } from "./novel/outline-next-step"
-import { extractBodyContent } from "./novel/outline-save-request"
+import {
+  classifyOutlineSaveTarget,
+  type OutlineSaveClassification,
+} from "./novel/outline-save-classifier"
+import { extractBodyContent, type OutlineSaveRequest } from "./novel/outline-save-request"
 
 export interface OutlineSaveDraft {
   title: string
   content: string
 }
 
+export interface ClassifiedOutlineSaveRequest {
+  draft: OutlineSaveDraft
+  classification: OutlineSaveClassification
+  request: OutlineSaveRequest
+}
+
 const DEFAULT_TITLE_PREFIX = "AI大纲"
 
 /**
@@ -28,6 +38,53 @@ export function prepareOutlineSaveDraft(content: string, existingTitles: string[
   return { title, content: body }
 }
 
+const SAVEABLE_TYPE_MARK = /章纲|卷纲|总纲|细纲|分卷大纲|人物小传|^#\s+角色-/m
+const CHAPTER_STRUCTURE_MARK = /本章目标[\s\S]{0,200}核心事件|核心事件[\s\S]{0,400}场景顺序/
+
+/** 判断回复是否像一份可落盘的大纲正文(而非问答/确认摘要)。 */
+export function isSaveableOutlineDeliverable(content: string): boolean {
+  const cleaned = prepareOutlineSaveSourceContent(content).trim()
+  if (!cleaned) return false
+  if (/^#\s+/.test(cleaned) && SAVEABLE_TYPE_MARK.test(cleaned.slice(0, 400)) && cleaned.length >= 80) {
+    return true
+  }
+  return cleaned.length >= 200
+    && CHAPTER_STRUCTURE_MARK.test(cleaned)
+    && /第\s*\d{1,4}\s*章/.test(cleaned)
+}
+
+export function buildClassifiedOutlineSaveRequest(input: {
+  content: string
+  sourceIntent: string
+  sourceHint?: string
+}): ClassifiedOutlineSaveRequest | null {
+  const cleaned = prepareOutlineSaveSourceContent(input.content)
+  if (!cleaned.trim()) return null
+  const draft = prepareOutlineSaveDraft(cleaned, [])
+  if (!draft.content.trim()) return null
+  const classification = classifyOutlineSaveTarget({
+    title: draft.title,
+    content: draft.content,
+    sourceHint: input.sourceHint,
+  })
+  const body = draft.content.replace(/^#\s+.+(?:\r?\n){1,2}/, "").trim()
+  const titleHeading = classification.fileName.replace(/\.md$/i, "")
+  const mdContent = body ? `# ${titleHeading}\n\n${body}` : draft.content.trim()
+  return {
+    draft,
+    classification,
+    request: {
+      targetFolder: classification.targetFolder,
+      fileName: classification.fileName,
+      fileType: classification.fileType,
+      writeMode: "create",
+      referencedSkills: [],
+      sourceIntent: input.sourceIntent,
+      content: mdContent,
+    },
+  }
+}
+
 function looksLikeHeading(line: string): boolean {
   const trimmed = line.trim()
   if (trimmed.length < 2 || trimmed.length > 50) return false

+ 3 - 45
src/stores/outline-generation-store.ts

@@ -1,26 +1,12 @@
 import { create } from "zustand"
 
-export type OutlineTaskStatus = "ingesting" | "done" | "error" | "generated"
-export type OutlineTaskKind = "ingest" | "generate" | "refine"
+export type OutlineTaskStatus = "ingesting" | "done" | "error"
+export type OutlineTaskKind = "ingest"
 
 export interface OutlineGenerationTask {
   id: string
   projectPath: string
   kind: OutlineTaskKind
-  genre: string
-  scale: string
-  premise: string
-  prompt: string
-  channel?: "male" | "female"
-  mainGenre?: string
-  subGenres?: string[]
-  customTags?: string[]
-  modelId?: string
-  userRequest: string
-  selectedSectionKey: string | null
-  displayTitle: string | null
-  writeMode: string | null
-  targetPath: string | null
   outlinePath: string | null
   status: OutlineTaskStatus
   message: string
@@ -32,20 +18,6 @@ export interface OutlineGenerationTask {
 interface CreateOutlineTaskInput {
   projectPath: string
   kind?: OutlineTaskKind
-  genre?: string
-  scale?: string
-  premise?: string
-  prompt?: string
-  channel?: "male" | "female"
-  mainGenre?: string
-  subGenres?: string[]
-  customTags?: string[]
-  modelId?: string
-  userRequest?: string
-  selectedSectionKey?: string | null
-  displayTitle?: string | null
-  writeMode?: string | null
-  targetPath?: string | null
   outlinePath?: string | null
   status?: OutlineTaskStatus
   message?: string
@@ -75,22 +47,8 @@ export const useOutlineGenerationStore = create<OutlineGenerationState>((set) =>
       tasks: [
         {
           id,
-          ...input,
+          projectPath: input.projectPath,
           kind: input.kind ?? "ingest",
-          genre: input.genre ?? "",
-          scale: input.scale ?? "",
-          premise: input.premise ?? "",
-          prompt: input.prompt ?? "",
-          channel: input.channel,
-          mainGenre: input.mainGenre,
-          subGenres: input.subGenres,
-          customTags: input.customTags,
-          modelId: input.modelId,
-          userRequest: input.userRequest ?? "",
-          selectedSectionKey: input.selectedSectionKey ?? null,
-          displayTitle: input.displayTitle ?? null,
-          writeMode: input.writeMode ?? null,
-          targetPath: input.targetPath ?? null,
           outlinePath: input.outlinePath ?? null,
           status: input.status ?? "ingesting",
           message: input.message ?? "",

+ 10 - 1
src/stores/wiki-store.ts

@@ -29,7 +29,12 @@ import {
   resolveStoredVisualStyle,
   type VisualStyle,
 } from "@/lib/visual-style-settings"
-import { DEFAULT_AI_WORKFLOW_MODE, type AiWorkflowMode } from "@/lib/agent/workflow-mode"
+import {
+  DEFAULT_AI_WORKFLOW_MODE,
+  DEFAULT_OUTLINE_WORKFLOW_MODE,
+  type AiWorkflowMode,
+  type OutlineWorkflowMode,
+} from "@/lib/agent/workflow-mode"
 import { DEFAULT_MCP_CONFIG, type McpConfig } from "@/lib/mcp/config"
 import {
   normalizeProviderConfigs,
@@ -600,6 +605,7 @@ interface WikiState {
   novelMode: boolean
   chatEditModeEnabled: boolean
   aiWorkflowMode: AiWorkflowMode
+  outlineWorkflowMode: OutlineWorkflowMode
   planExecuteEnabled: boolean
   novelConfig: NovelConfig
   /** 社区摘要生成错误信息(UI 层监听并弹窗提示) */
@@ -670,6 +676,7 @@ interface WikiState {
   setNovelMode: (novelMode: boolean) => void
   setChatEditModeEnabled: (enabled: boolean) => void
   setAiWorkflowMode: (mode: AiWorkflowMode) => void
+  setOutlineWorkflowMode: (mode: OutlineWorkflowMode) => void
   setPlanExecuteEnabled: (enabled: boolean) => void
   setNovelConfig: (config: Partial<NovelConfig>) => void
   setCommunitySummaryError: (error: string | null) => void
@@ -885,6 +892,7 @@ export const useWikiStore = create<WikiState>((set) => ({
   novelMode: true,
   chatEditModeEnabled: false,
   aiWorkflowMode: DEFAULT_AI_WORKFLOW_MODE,
+  outlineWorkflowMode: DEFAULT_OUTLINE_WORKFLOW_MODE,
   planExecuteEnabled: false,
   novelConfig: { ...DEFAULT_NOVEL_CONFIG },
   communitySummaryError: null,
@@ -928,6 +936,7 @@ export const useWikiStore = create<WikiState>((set) => ({
   setNovelMode: (novelMode) => set({ novelMode }),
   setChatEditModeEnabled: (chatEditModeEnabled) => set({ chatEditModeEnabled }),
   setAiWorkflowMode: (aiWorkflowMode) => set({ aiWorkflowMode }),
+  setOutlineWorkflowMode: (outlineWorkflowMode) => set({ outlineWorkflowMode }),
   setPlanExecuteEnabled: (planExecuteEnabled) => set({ planExecuteEnabled }),
   setNovelConfig: (config) => set((state) => ({
     novelConfig: { ...state.novelConfig, ...config },