فهرست منبع

fix(ai): 过滤大纲与去 AI 味思考输出

扩展 Gemini 无标签思考段识别,并在大纲完整响应、渲染与保存边界统一过滤。纯思考响应关闭 reasoning 重试一次,避免英文分析内容进入正文或大纲。
darknessomi 2 هفته پیش
والد
کامیت
b05f93d2b4

+ 156 - 1
src/components/sources/outline-chat-panel.spec.tsx

@@ -20,7 +20,11 @@ import { outlineConversationRunRegistry } from "@/lib/conversation-run-registry"
 import { AgentRunner } from "@/lib/agent/runner"
 import { toast } from "@/lib/toast"
 import { useWikiStore } from "@/stores/wiki-store"
-import { buildOutlineAgentSystemPrompt, OutlineChatPanel } from "./outline-chat-panel"
+import {
+  buildOutlineAgentSystemPrompt,
+  filterOutlineGeneratedContent,
+  OutlineChatPanel,
+} from "./outline-chat-panel"
 import {
   useOutlineChatStore,
   type OutlineChatConversation,
@@ -32,6 +36,18 @@ import type { ContextHubSnapshotRef } from "@/lib/context-hub/types"
 const source = readFileSync(resolve(__dirname, "outline-chat-panel.tsx"), "utf8")
 const outlineSectionConfigsSource = readFileSync(resolve(__dirname, "../../lib/novel/outline-section-configs.ts"), "utf8")
 
+const GEMINI_OUTLINE_THOUGHT_DUMP = [
+  "I'm currently focused on defining the project scope and following the \"去 AI 味\" skill instructions.",
+  "",
+  "**Examining the Narrative Details**",
+  "",
+  "I'm now diving deep into analyzing the source text and identifying critical plot points.",
+  "",
+  "**Analyzing the Conflict's Dynamics**",
+  "",
+  "I've been mapping out the escalating conflict and the characters' motivations.",
+].join("\n")
+
 const mountedRoots: Array<{ container: HTMLDivElement; root: Root }> = []
 
 function agentMessageContentText(content: AgentMessage["content"]): string {
@@ -122,6 +138,30 @@ afterEach(async () => {
   vi.restoreAllMocks()
 })
 
+describe("AI 大纲完整结果过滤", () => {
+  it("把 Gemini 普通文本思考摘要识别为无正文", () => {
+    expect(filterOutlineGeneratedContent(GEMINI_OUTLINE_THOUGHT_DUMP)).toEqual({
+      content: "",
+      reasoningOnly: true,
+    })
+  })
+
+  it("只移除前置思考摘要并保留后续大纲正文", () => {
+    const output = filterOutlineGeneratedContent([
+      GEMINI_OUTLINE_THOUGHT_DUMP,
+      "",
+      "# 第27章 地下乱战",
+      "",
+      "## 本章目标",
+      "沈渊必须在增援抵达前夺下中枢。",
+    ].join("\n"))
+
+    expect(output.reasoningOnly).toBe(false)
+    expect(output.content).toContain("# 第27章 地下乱战")
+    expect(output.content).not.toContain("Examining the Narrative Details")
+  })
+})
+
 describe("OutlineChatPanel controls", () => {
 
   it("上下文圆环使用 AI 大纲选中模型的窗口而不是全局模型窗口", async () => {
@@ -690,6 +730,69 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("最后再生成大纲建议")
   })
 
+  it("主发送完整结果仅含 Gemini 思考摘要时关闭 reasoning 重试一次", async () => {
+    useWikiStore.setState({
+      outlineWorkflowMode: "fast",
+      llmConfig: {
+        ...useWikiStore.getState().llmConfig,
+        reasoning: { mode: "high" },
+      },
+    })
+    const finalOutline = "# 第27章 地下乱战\n\n## 本章目标\n沈渊必须在增援抵达前夺下中枢。"
+    const runSpy = vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (config, _registry, _messages, callbacks) => {
+      const text = runSpy.mock.calls.length === 1 ? GEMINI_OUTLINE_THOUGHT_DUMP : finalOutline
+      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="引用输入框"]')
+
+    await act(async () => {
+      const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set
+      setValue?.call(input, "说明第27章的剧情安排")
+      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 (runSpy.mock.calls.length === 2 && useOutlineChatStore.getState().runStates["outline-active"]?.status !== "running") break
+        await new Promise((resolve) => setTimeout(resolve, 5))
+      }
+    })
+
+    expect(runSpy).toHaveBeenCalledTimes(2)
+    expect(runSpy.mock.calls[1]?.[0].requestOverrides?.reasoning).toEqual({ mode: "off" })
+    const assistant = useOutlineChatStore.getState().conversations[0].messages.findLast((message) => message.role === "assistant")
+    expect(assistant?.content).toContain("沈渊必须在增援抵达前夺下中枢")
+    expect(assistant?.content).not.toContain("Examining the Narrative Details")
+  })
+
+  it("停止主发送时不会把已流出的 Gemini 思考摘要保存在消息中", async () => {
+    useWikiStore.setState({ outlineWorkflowMode: "fast" })
+    const runSpy = vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (_config, _registry, _messages, callbacks) => {
+      callbacks.onText(GEMINI_OUTLINE_THOUGHT_DUMP)
+      throw new Error("aborted")
+    })
+    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 (runSpy.mock.calls.length === 1 && useOutlineChatStore.getState().runStates["outline-active"]?.status !== "running") break
+        await new Promise((resolve) => setTimeout(resolve, 5))
+      }
+    })
+
+    const assistant = useOutlineChatStore.getState().conversations[0].messages.findLast((message) => message.role === "assistant")
+    expect(assistant?.content).toBe("已停止生成。")
+    expect(assistant?.content).not.toContain("Examining the Narrative Details")
+  })
+
   it("直接章纲完善请求按意图分析和正文生成两阶段执行,并保留原请求与引用", async () => {
     const reference = {
       id: "chapter-outline-236",
@@ -1085,6 +1188,21 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("手动保存 AI 大纲结果")
   })
 
+  it("历史消息中的 Gemini 思考摘要不会再次展示或进入手动保存", async () => {
+    setOutlineConversations([conversation([{
+      id: "thought-only-outline",
+      role: "assistant",
+      content: GEMINI_OUTLINE_THOUGHT_DUMP,
+    }])], "outline-active")
+    const container = await renderOutlineChatPanel()
+    const saveButton = Array.from(container.querySelectorAll<HTMLButtonElement>("button"))
+      .find((button) => button.textContent?.includes("保存为大纲"))
+
+    expect(saveButton).toBeUndefined()
+    expect(container.textContent).not.toContain("Examining the Narrative Details")
+    expect(document.body.textContent).not.toContain("请确认要保存的大纲文件")
+  })
+
   it("parses structured AI outline save requests and requires user confirmation before writing", () => {
     expect(source).toContain("parseOutlineSaveRequests")
     expect(source).toContain("formatOutlineSaveParseFeedback")
@@ -1539,6 +1657,43 @@ describe("OutlineChatPanel controls", () => {
     expect(answer).not.toContain("```markdown")
   })
 
+  it("重新生成完整结果仅含 Gemini 思考摘要时关闭 reasoning 重试一次", async () => {
+    useWikiStore.setState({
+      llmConfig: {
+        ...useWikiStore.getState().llmConfig,
+        reasoning: { mode: "high" },
+      },
+    })
+    const regenerated = "# 第27章 地下乱战\n\n## 核心事件\n沈渊截断敌方增援。"
+    const runSpy = vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (config, _registry, _messages, callbacks) => {
+      const text = runSpy.mock.calls.length === 1 ? GEMINI_OUTLINE_THOUGHT_DUMP : regenerated
+      callbacks.onText(text)
+      callbacks.onDone()
+      return { toolCalls: [], roundsUsed: 1, finalText: text }
+    })
+    setOutlineConversations([conversation([
+      { id: "u-retry", role: "user", content: "生成第27章章纲" },
+      { id: "a-retry", role: "assistant", content: "# 旧章纲", intentPhase: "generation" },
+    ])], "outline-active")
+    const container = await renderOutlineChatPanel()
+    const button = Array.from(container.querySelectorAll<HTMLButtonElement>("button"))
+      .find((item) => item.textContent?.includes("重新生成"))
+
+    await act(async () => {
+      button?.click()
+      for (let attempt = 0; attempt < 100; attempt += 1) {
+        if (runSpy.mock.calls.length === 2 && useOutlineChatStore.getState().runStates["outline-active"]?.status !== "running") break
+        await new Promise((resolve) => setTimeout(resolve, 5))
+      }
+    })
+
+    expect(runSpy).toHaveBeenCalledTimes(2)
+    expect(runSpy.mock.calls[1]?.[0].requestOverrides?.reasoning).toEqual({ mode: "off" })
+    const answer = useOutlineChatStore.getState().conversations[0].messages.at(-1)?.content ?? ""
+    expect(answer).toContain("沈渊截断敌方增援")
+    expect(answer).not.toContain("Analyzing the Conflict's Dynamics")
+  })
+
   it("生成阶段重新生成若再次返回意图标记则报错并阻止循环", async () => {
     const protocolText = `<!-- intent_clarity -->\n{"clarity":"clear","module":"章节细纲","analysis":"重复分析","detectedScope":"第236章","missingItems":[],"options":[],"question":""}\n<!-- /intent_clarity -->`
     const runSpy = vi.spyOn(AgentRunner.prototype, "run").mockImplementation(async (_config, _registry, _messages, callbacks) => {

+ 323 - 149
src/components/sources/outline-chat-panel.tsx

@@ -170,10 +170,18 @@ import {
 import type { ReferenceToken } from "@/lib/reference/types";
 import { useChatStore } from "@/stores/chat-store";
 import { AgentRunner } from "@/lib/agent/runner";
-import { isReasoningOnlyResponseError } from "@/lib/reasoning-retry";
+import {
+  isReasoningDisabled,
+  isReasoningOnlyResponseError,
+  withReasoningDisabled,
+} from "@/lib/reasoning-retry";
+import {
+  isThoughtDumpText,
+  stripThoughtDumpFromText,
+} from "@/lib/thought-dump";
 import { ToolRegistry } from "@/lib/agent/registry";
 import { buildAgentConfig, modelSupportsTools } from "@/lib/agent/config";
-import type { AgentMessage, AgentRunRecord } from "@/lib/agent/types";
+import type { AgentConfig, AgentMessage, AgentRunRecord } from "@/lib/agent/types";
 import {
   applyAgentToolEvent,
   settleRunningAgentToolCalls,
@@ -265,6 +273,67 @@ import {
 
 type OutlineSendResult = { started: boolean; sent: boolean };
 
+const OUTLINE_REASONING_ONLY_ERROR_MESSAGE =
+  "模型只输出了思考内容,没有输出正文。已关闭 reasoning 重试一次,仍未返回可用的大纲内容。";
+
+export function filterOutlineGeneratedContent(content: string): {
+  content: string;
+  reasoningOnly: boolean;
+} {
+  const trimmed = content.trim();
+  if (!trimmed) return { content: "", reasoningOnly: false };
+
+  const stripped = stripThoughtDumpFromText(trimmed).trim();
+  const reasoningOnly = !stripped || (
+    stripped === trimmed && isThoughtDumpText(trimmed)
+  );
+  return {
+    content: reasoningOnly ? "" : stripped,
+    reasoningOnly,
+  };
+}
+
+type OutlineFilteredAttempt = {
+  text: string;
+  error?: Error;
+};
+
+async function runOutlineAttemptWithReasoningRetry<T extends OutlineFilteredAttempt>(
+  config: Pick<AgentConfig, "llmConfig" | "requestOverrides">,
+  runAttempt: (requestOverrides: AgentConfig["requestOverrides"]) => Promise<T>,
+  onRetry?: (thoughtText: string) => void,
+): Promise<T> {
+  const firstAttempt = await runAttempt(config.requestOverrides);
+  const firstOutput = filterOutlineGeneratedContent(firstAttempt.text);
+  const firstReasoningOnlyError = Boolean(
+    firstAttempt.error && isReasoningOnlyResponseError(firstAttempt.error),
+  );
+
+  // AgentRunner 已经会对供应商明确上报的 reasoning-only 错误重试;
+  // 这里只为“被当作普通文本返回”的思考摘要补一次上层重试。
+  if (firstReasoningOnlyError) {
+    throw new Error(OUTLINE_REASONING_ONLY_ERROR_MESSAGE);
+  }
+  if (!firstOutput.reasoningOnly) {
+    return { ...firstAttempt, text: firstOutput.content };
+  }
+  if (isReasoningDisabled(config.llmConfig, config.requestOverrides)) {
+    throw new Error(OUTLINE_REASONING_ONLY_ERROR_MESSAGE);
+  }
+
+  onRetry?.(firstAttempt.text);
+  const retryAttempt = await runAttempt(withReasoningDisabled(config.requestOverrides));
+  const retryOutput = filterOutlineGeneratedContent(retryAttempt.text);
+  if (
+    retryOutput.reasoningOnly
+    || !retryOutput.content
+    || (retryAttempt.error && isReasoningOnlyResponseError(retryAttempt.error))
+  ) {
+    throw new Error(OUTLINE_REASONING_ONLY_ERROR_MESSAGE);
+  }
+  return { ...retryAttempt, text: retryOutput.content };
+}
+
 const OUTLINE_CHAT_DISABLED_TOOLS = ["write_chapter", "write_memory", "write_outline_node"];
 const OUTLINE_CHAT_WIZARD_DISABLED_TOOLS = [...OUTLINE_CHAT_DISABLED_TOOLS];
 
@@ -954,8 +1023,15 @@ function OutlineAssistantMessage({
   >([]);
   const [editDismissed, setEditDismissed] = useState(false);
 
-  // 消息内容是唯一内容通道;运行状态提示单独渲染,绝不混入正文
-  const displayContent = msg.content;
+  // 消息内容是唯一内容通道;加载历史消息时也要防御旧版本已经落盘的
+  // Gemini 普通文本思考摘要,避免再次展示或进入手动保存。
+  const filteredDisplayContent = useMemo(
+    () => filterOutlineGeneratedContent(msg.content),
+    [msg.content],
+  );
+  const displayContent = filteredDisplayContent.reasoningOnly
+    ? ""
+    : filteredDisplayContent.content || msg.content;
   const { thinking, answer } = useMemo(
     () => separateThinking(displayContent),
     [displayContent],
@@ -1811,15 +1887,18 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const handleAutoSaveOutlineRequests = useCallback(
     async (conversationId: string, assistantContent: string, canApply: () => boolean) => {
       if (!project || !canApply()) return;
-      const parsed = parseOutlineSaveRequests(assistantContent);
+      const filteredOutput = filterOutlineGeneratedContent(assistantContent);
+      if (filteredOutput.reasoningOnly || !filteredOutput.content) return;
+      const safeAssistantContent = filteredOutput.content;
+      const parsed = parseOutlineSaveRequests(safeAssistantContent);
       if (parsed.requests.length === 0) {
         if (parsed.errors.length > 0) {
           showOutlineAutoSaveError(formatOutlineSaveParseFeedback(parsed.errors));
           return;
         }
-        if (!isSaveableOutlineDeliverable(assistantContent)) return;
+        if (!isSaveableOutlineDeliverable(safeAssistantContent)) return;
         const built = buildClassifiedOutlineSaveRequest({
-          content: assistantContent,
+          content: safeAssistantContent,
           sourceIntent: "生成完成后自动保存",
           sourceHint: collectOutlineSaveSourceHint(conversationId),
         });
@@ -2321,80 +2400,108 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               ? { ...message, content: appendSystemRules(message.content, selectedSkillsPrompt) }
               : message)
             : messages;
-          let runText = "";
-          let runReasoningContent = "";
-          const agentErrorBox: { current: Error | null } = { current: null };
           if (optionsForRun.statusText) {
             if (isCurrentRun()) setStreamingContent(capturedConvId, optionsForRun.statusText);
           }
-          const record = await new AgentRunner().run(
+          return runOutlineAttemptWithReasoningRetry(
             agentConfig,
-            registry,
-            runMessages,
-            {
-              onText: (chunk) => {
-                runText += chunk;
-                if (optionsForRun.streamToUser) {
-                  result += chunk;
-                  bestGeneratedText = result;
-                  if (isCurrentRun()) {
+            async (requestOverrides) => {
+              let runText = "";
+              let runReasoningContent = "";
+              const agentErrorBox: { current: Error | null } = { current: null };
+              const record = await new AgentRunner().run(
+                { ...agentConfig, requestOverrides },
+                registry,
+                runMessages,
+                {
+                  onText: (chunk) => {
+                    runText += chunk;
+                    if (optionsForRun.streamToUser) {
+                      result = runText;
+                      bestGeneratedText = result;
+                      if (isCurrentRun()) {
+                        updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+                          ...message,
+                          content: result,
+                        }));
+                      }
+                    }
+                  },
+                  onReasoningToken: (chunk) => {
+                    runReasoningContent += chunk;
+                    accumulatedReasoningContent += chunk;
+                  },
+                  onToolCall: () => {},
+                  onToolResult: () => {},
+                  onToolError: () => {},
+                  onToolEvent: (event) => {
+                    if (!isCurrentRun()) return;
+                    if (!historyPlan.showToolProcess) {
+                      hiddenToolCalls = applyAgentToolEvent(hiddenToolCalls, event);
+                      return;
+                    }
                     updateOutlineAssistantMessage(convId, assistantId, (message) => ({
                       ...message,
-                      content: result,
+                      agentToolCalls: applyAgentToolEvent(
+                        message.agentToolCalls,
+                        event,
+                      ),
                     }));
-                  }
-                }
-              },
-              onReasoningToken: (chunk) => {
-                runReasoningContent += chunk;
-                accumulatedReasoningContent += chunk;
-              },
-              onToolCall: () => {},
-              onToolResult: () => {},
-              onToolError: () => {},
-              onToolEvent: (event) => {
-                if (!isCurrentRun()) return;
-                if (!historyPlan.showToolProcess) {
-                  hiddenToolCalls = applyAgentToolEvent(hiddenToolCalls, event);
-                  return;
-                }
+                  },
+                  onDone: () => {},
+                  onRequestTrace: requestTraceCollector.record,
+                  onError: (error) => {
+                    agentErrorBox.current = error;
+                  },
+                },
+                controller.signal,
+              );
+              providerUsage = addLlmUsage(providerUsage, record.usage);
+              lastProviderUsage = record.lastRequestUsage ?? record.usage ?? lastProviderUsage;
+              if (record.providerRequestCountAvailable === false) {
+                providerRequestCountAvailable = false;
+              } else {
+                llmRequestCount += Math.max(1, record.roundsUsed || 1);
+              }
+              if (memoryDecision === undefined && record.userMemoryDecision !== undefined) {
+                memoryDecision = record.userMemoryDecision;
+              }
+              allToolCalls.push(...record.toolCalls);
+              const agentError = agentErrorBox.current;
+              const errMsg = agentError?.message ?? "";
+              const isLengthTruncated = errMsg.includes("输出被截断") || errMsg.includes("最大输出 token");
+              if (
+                agentError
+                && !isLengthTruncated
+                && !isReasoningOnlyResponseError(agentError)
+              ) {
+                throw agentError;
+              }
+              return {
+                text: runText || record.finalText,
+                record,
+                error: agentError ?? undefined,
+                reasoning_content: runReasoningContent,
+              };
+            },
+            (thoughtText) => {
+              if (controller.signal.aborted || !isCurrentRun()) throw new Error("aborted");
+              if (thoughtText.trim()) {
+                accumulatedReasoningContent = [accumulatedReasoningContent, thoughtText]
+                  .filter((item) => item.trim())
+                  .join("\n\n");
+              }
+              if (optionsForRun.streamToUser) {
+                result = "";
+                bestGeneratedText = "";
                 updateOutlineAssistantMessage(convId, assistantId, (message) => ({
                   ...message,
-                  agentToolCalls: applyAgentToolEvent(
-                    message.agentToolCalls,
-                    event,
-                  ),
+                  content: "",
                 }));
-              },
-              onDone: () => {},
-              onRequestTrace: requestTraceCollector.record,
-              onError: (error) => {
-                agentErrorBox.current = error;
-              },
+              }
+              setStreamingContent(capturedConvId, "模型仅返回思考过程,正在关闭 reasoning 重试...");
             },
-            controller.signal,
           );
-          providerUsage = addLlmUsage(providerUsage, record.usage);
-          lastProviderUsage = record.lastRequestUsage ?? record.usage ?? lastProviderUsage;
-          if (record.providerRequestCountAvailable === false) {
-            providerRequestCountAvailable = false;
-          } else {
-            llmRequestCount += Math.max(1, record.roundsUsed || 1);
-          }
-          if (memoryDecision === undefined && record.userMemoryDecision !== undefined) {
-            memoryDecision = record.userMemoryDecision;
-          }
-          allToolCalls.push(...record.toolCalls);
-          const agentError = agentErrorBox.current;
-          const errMsg = agentError?.message ?? "";
-          const isLengthTruncated = errMsg.includes("输出被截断") || errMsg.includes("最大输出 token");
-          if (agentError && !isLengthTruncated) throw agentError;
-          return {
-            text: runText || record.finalText,
-            record,
-            error: agentError ?? undefined,
-            reasoning_content: runReasoningContent,
-          };
         };
 
         const runSingleAgentFallback = async () => {
@@ -2800,6 +2907,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           finalText = await runSingleAgentFallback();
         }
 
+        const filteredFinalText = filterOutlineGeneratedContent(finalText);
+        if (filteredFinalText.reasoningOnly) {
+          throw new Error(OUTLINE_REASONING_ONLY_ERROR_MESSAGE);
+        }
+        finalText = filteredFinalText.content;
         if (finalText.trim()) bestGeneratedText = finalText;
         if (!isCurrentRun()) {
           // run 已被停止或替换:跳过后续处理,但已生成的内容仍要写入消息,
@@ -2867,7 +2979,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             ...[...missingSkillNames].map((name) => `Skill 缺失(未强制启用): ${name}`),
           ]),
         );
-        const rawFinalContent = finalText || result || "AI大纲未返回内容。";
+        const filteredRawFinalContent = filterOutlineGeneratedContent(finalText || result);
+        if (filteredRawFinalContent.reasoningOnly) {
+          throw new Error(OUTLINE_REASONING_ONLY_ERROR_MESSAGE);
+        }
+        const rawFinalContent = filteredRawFinalContent.content || "AI大纲未返回内容。";
         const rawIntentProtocol = parseIntentClarityProtocol(rawFinalContent);
         const nextStepExtraction = extractNextStep(rawFinalContent, {
           allowFallback: options.intentPhase === "generation",
@@ -3060,7 +3176,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         // streamingContents 只承载状态提示,不再存内容;可保留内容唯一来源
         // 是 bestGeneratedText。无论中断原因如何,已生成的内容都必须落进
         // 消息,绝不静默删除整条回复。
-        const partial = bestGeneratedText.trim() ? bestGeneratedText : "";
+        const filteredPartial = filterOutlineGeneratedContent(bestGeneratedText);
+        const partial = filteredPartial.content;
         const reasoningOnlyFailure =
           err instanceof Error && isReasoningOnlyResponseError(err) && Boolean(accumulatedReasoningContent.trim());
         updateOutlineAssistantMessage(convId, assistantId, (message) => ({
@@ -3070,7 +3187,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               ? `${partial}\n\n---\n\n⚠️ 生成已停止,以上为已生成的内容。`
               : `${partial}\n\n---\n\n⚠️ 生成中断:${errorMsg || "未知错误"}`
             : aborted
-              ? message.content || "已停止生成。"
+              ? filterOutlineGeneratedContent(message.content).content || "已停止生成。"
               : `生成失败:${errorMsg || "未知错误"}`,
           reasoning_content: accumulatedReasoningContent,
           // 模型只输出思考没输出正文时,强制展示思考过程,
@@ -3456,7 +3573,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             registry: r,
           };
         };
-
         // 更新状态为续传运行中
         updateOutlineMultiAgentRun(capturedConvId, messageId, (run) => run ? ({
           ...run,
@@ -3952,71 +4068,110 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           userMemoryProjectKey: normalizePath(project.path),
           userMemorySessionKey: capturedConvId,
         };
-        let agentError: Error | null = null;
-        const record = await new AgentRunner().run(
+        const regenerationMessages: AgentMessage[] = [
+          { role: "system", content: systemContent },
+          ...historyMessages,
+          { role: "user", content: lastUserRequest },
+        ];
+        const regenerationRecords: AgentRunRecord[] = [];
+        const regenerationRun = await runOutlineAttemptWithReasoningRetry(
           agentConfig,
-          registry,
-          [
-            { role: "system", content: systemContent },
-            ...historyMessages,
-            { role: "user", content: lastUserRequest },
-          ],
-          {
-            onText: (chunk) => {
-              result += chunk;
-              if (isCurrentRun()) {
-                updateOutlineAssistantMessage(
-                  capturedConvId,
-                  assistantId,
-                  (message) => ({
-                    ...message,
-                    content: result,
-                  }),
-                );
-              }
-            },
-            onReasoningToken: (chunk) => {
-              accumulatedReasoningContent += chunk;
-            },
-            onToolCall: () => {},
-            onToolResult: () => {},
-            onToolError: () => {},
-            onToolEvent: (event) => {
-              if (!isCurrentRun()) return;
-              updateOutlineAssistantMessage(
-                capturedConvId,
-                assistantId,
-                (message) => ({
-                  ...message,
-                  agentToolCalls: applyAgentToolEvent(
-                    message.agentToolCalls,
-                    event,
-                  ),
-                }),
-              );
-            },
-            onDone: () => {
-              if (!isCurrentRun()) return;
-              updateOutlineAssistantMessage(
-                capturedConvId,
-                assistantId,
-                (message) => ({
-                  ...message,
-                  reasoning_content: accumulatedReasoningContent,
-                  agentToolCalls: settleRunningAgentToolCalls(
-                    message.agentToolCalls,
-                  ),
-                  isAgentRunning: false,
-                }),
-              );
-            },
-            onError: (error) => {
-              agentError = error;
-            },
+          async (requestOverrides) => {
+            let runText = "";
+            const agentErrorBox: { current: Error | null } = { current: null };
+            const attemptRecord = await new AgentRunner().run(
+              { ...agentConfig, requestOverrides },
+              registry,
+              regenerationMessages,
+              {
+                onText: (chunk) => {
+                  runText += chunk;
+                  result = runText;
+                  if (isCurrentRun()) {
+                    updateOutlineAssistantMessage(
+                      capturedConvId,
+                      assistantId,
+                      (message) => ({
+                        ...message,
+                        content: result,
+                      }),
+                    );
+                  }
+                },
+                onReasoningToken: (chunk) => {
+                  accumulatedReasoningContent += chunk;
+                },
+                onToolCall: () => {},
+                onToolResult: () => {},
+                onToolError: () => {},
+                onToolEvent: (event) => {
+                  if (!isCurrentRun()) return;
+                  updateOutlineAssistantMessage(
+                    capturedConvId,
+                    assistantId,
+                    (message) => ({
+                      ...message,
+                      agentToolCalls: applyAgentToolEvent(
+                        message.agentToolCalls,
+                        event,
+                      ),
+                    }),
+                  );
+                },
+                onDone: () => {},
+                onError: (error) => {
+                  agentErrorBox.current = error;
+                },
+              },
+              controller.signal,
+            );
+            regenerationRecords.push(attemptRecord);
+            const agentError = agentErrorBox.current;
+            if (agentError && !isReasoningOnlyResponseError(agentError)) throw agentError;
+            return {
+              text: runText || attemptRecord.finalText,
+              record: attemptRecord,
+              error: agentError ?? undefined,
+            };
+          },
+          (thoughtText) => {
+            if (controller.signal.aborted || !isCurrentRun()) throw new Error("aborted");
+            if (thoughtText.trim()) {
+              accumulatedReasoningContent = [accumulatedReasoningContent, thoughtText]
+                .filter((item) => item.trim())
+                .join("\n\n");
+            }
+            result = "";
+            updateOutlineAssistantMessage(capturedConvId, assistantId, (message) => ({
+              ...message,
+              content: "",
+              isAgentRunning: true,
+            }));
+            setStreamingContent(capturedConvId, "模型仅返回思考过程,正在关闭 reasoning 重试...");
           },
-          controller.signal,
         );
-        if (agentError) throw agentError;
+        if (regenerationRun.error) throw regenerationRun.error;
+        const record: AgentRunRecord = {
+          ...regenerationRun.record,
+          finalText: regenerationRun.text,
+          usage: regenerationRecords.reduce<LlmUsage | undefined>(
+            (usage, item) => addLlmUsage(usage, item.usage),
+            undefined,
+          ),
+          roundsUsed: regenerationRecords.reduce((total, item) => total + Math.max(1, item.roundsUsed || 1), 0),
+          toolCalls: regenerationRecords.flatMap((item) => item.toolCalls),
+          requestTraces: regenerationRecords.flatMap((item) => item.requestTraces ?? []),
+          omittedRequestTraceCount: regenerationRecords.reduce(
+            (total, item) => total + (item.omittedRequestTraceCount ?? 0),
+            0,
+          ),
+          providerRequestCountAvailable: regenerationRecords.every(
+            (item) => item.providerRequestCountAvailable !== false,
+          ),
+          userMemoryDecision: regenerationRecords.find(
+            (item) => item.userMemoryDecision !== undefined,
+          )?.userMemoryDecision,
+        };
         if (!isCurrentRun()) return;
         if (contextHubResult && (record.usage || record.requestTraces?.length)) {
           try {
@@ -4065,7 +4220,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           ...outlineToolCallsToSources(record.toolCalls),
           ...regenerationSkills.missingNames.map((name) => `Skill 缺失(未强制启用): ${name}`),
         ];
-        const rawRegenerationContent = result || record.finalText || "AI大纲未返回内容。";
+        const filteredRegenerationContent = filterOutlineGeneratedContent(
+          regenerationRun.text || result || record.finalText,
+        );
+        if (filteredRegenerationContent.reasoningOnly) {
+          throw new Error(OUTLINE_REASONING_ONLY_ERROR_MESSAGE);
+        }
+        const rawRegenerationContent = filteredRegenerationContent.content || "AI大纲未返回内容。";
         const rawRegenerationIntentProtocol = parseIntentClarityProtocol(rawRegenerationContent);
         const nextStepExtraction = extractNextStep(
           rawRegenerationContent,
@@ -4137,10 +4298,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         if (assistantAdded) {
           updateOutlineAssistantMessage(capturedConvId, assistantId, (message) => ({
             ...message,
-            content: message.content.trim()
+            content: filterOutlineGeneratedContent(message.content).content.trim()
               ? aborted
-                ? `${message.content}\n\n---\n\n⚠️ 生成已停止,以上为已生成的内容。`
-                : `${message.content}\n\n---\n\n⚠️ 生成中断:${errorMsg || "未知错误"}`
+                ? `${filterOutlineGeneratedContent(message.content).content}\n\n---\n\n⚠️ 生成已停止,以上为已生成的内容。`
+                : `${filterOutlineGeneratedContent(message.content).content}\n\n---\n\n⚠️ 生成中断:${errorMsg || "未知错误"}`
               : aborted
                 ? "已停止生成。"
                 : `生成失败:${errorMsg || "未知错误"}`,
@@ -4219,8 +4380,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       const capturedConvId = activeConversationId;
       setSaveStatus("");
       try {
+        const filteredOutput = filterOutlineGeneratedContent(content);
+        if (filteredOutput.reasoningOnly || !filteredOutput.content) {
+          toast.error("内容仅包含模型思考过程,无法保存为大纲");
+          return;
+        }
         const built = buildClassifiedOutlineSaveRequest({
-          content,
+          content: filteredOutput.content,
           sourceIntent: "手动保存 AI 大纲结果",
           sourceHint: collectOutlineSaveSourceHint(capturedConvId),
         });
@@ -4235,15 +4401,23 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
 
         if (built.classification.fileType === "character") {
           if (characterResults && characterResults.length > 0) {
-            const characterDrafts: CharacterSaveDraft[] = characterResults.map((r) => ({
-              id: `${r.plan.roleType}:${r.plan.characterName}`,
-              characterName: r.plan.characterName,
-              roleType: r.plan.roleType,
-              fileName: r.fileName,
-              content: r.content,
-              selected: true,
-              confidence: "high",
-            }));
+            const characterDrafts: CharacterSaveDraft[] = characterResults.flatMap((r) => {
+              const filteredCharacter = filterOutlineGeneratedContent(r.content);
+              if (!filteredCharacter.content || filteredCharacter.reasoningOnly) return [];
+              return [{
+                id: `${r.plan.roleType}:${r.plan.characterName}`,
+                characterName: r.plan.characterName,
+                roleType: r.plan.roleType,
+                fileName: r.fileName,
+                content: filteredCharacter.content,
+                selected: true,
+                confidence: "high" as const,
+              }];
+            });
+            if (characterDrafts.length === 0) {
+              toast.error("人物小传仅包含模型思考过程,无法保存");
+              return;
+            }
             presentOrQueueSaveBatch({
               title: "请确认要保存的人物角色",
               mode: "character",

+ 37 - 0
src/lib/llm-providers.spec.ts

@@ -1,5 +1,6 @@
 import { describe, expect, it } from "vitest"
 import { getCustomCompatibleHeaders, getProviderConfig, parseGoogleLine, withCustomOriginHeader } from "./llm-providers"
+import { filterDeAiOutput } from "./novel/de-ai-output"
 import type { LlmConfig, ReasoningMode } from "@/stores/wiki-store"
 
 function customConfig(overrides: Partial<LlmConfig> = {}): LlmConfig {
@@ -667,6 +668,12 @@ describe("Gemini thought summaries", () => {
     })
   }
 
+  function googleLine(...parts: string[]): string {
+    return `data: ${JSON.stringify({
+      candidates: [{ content: { parts: parts.map((text) => ({ text })) } }],
+    })}`
+  }
+
   it("does not return thought:true parts as visible content", () => {
     const line = 'data: {"candidates":[{"content":{"parts":[{"text":"先拆章纲","thought":true},{"text":"雨还在下。"}]}}]}'
     expect(parseGoogleLine(line)).toBe("雨还在下。")
@@ -678,6 +685,36 @@ describe("Gemini thought summaries", () => {
     expect(parseGoogleLine(line)).toBe("雨还在下。")
   })
 
+  it("filters a thought summary split across Gemini SSE events and parts at the completed-result boundary", () => {
+    const body = "地下暗轨深处,空气沉得像一汪死水。叶刃没有回头。"
+    const lines = [
+      googleLine(
+        "I'm currently focused on defining the project scope, prioritizing ",
+        "the objective: refining the novel snippet to align with the \"去 AI 味\" skill's instructions.",
+      ),
+      googleLine(
+        "\n\n**Examining the Narrative Details**\n\n",
+        "I'm now diving deep into analyzing the source text and preserving its key conflicts.",
+      ),
+      googleLine(
+        "\n\n**Analyzing the Conflict's Dynamics**\n\n",
+        "I've been mapping out the escalating conflict within the narrative's framework.",
+      ),
+      googleLine(`\n\n${body}`),
+    ]
+
+    // parseGoogleLine is stateless, so a single event or part cannot reliably
+    // identify a summary whose evidence is spread across the completed payload.
+    // Do not constrain how much the provider can discard eagerly; the business
+    // boundary must still guarantee that only the revised body survives.
+    const completed = lines
+      .map((line) => parseGoogleLine(line))
+      .filter((text): text is string => text !== null)
+      .join("")
+
+    expect(filterDeAiOutput(completed)).toBe(body)
+  })
+
   it("hides thought summaries on Gemini 3.x even in auto reasoning mode", () => {
     const body = getProviderConfig(googleConfig()).buildBody(
       [{ role: "user", content: "写第14章" }],

+ 28 - 0
src/lib/novel/de-ai-output.spec.ts

@@ -11,6 +11,24 @@ const GEMINI_DE_AI_THOUGHTS = [
   "I'm now zeroing in on the output constraints and improving the dialogue.",
 ].join("\n")
 
+const SCREENSHOT_STYLE_GEMINI_DE_AI_THOUGHTS = [
+  "I'm currently focused on defining the project scope, prioritizing the objective: refining the novel snippet to align with the \"去 AI 味\" skill's instructions. The crucial requirement is to modify the text, producing solely the revised narrative without any extraneous explanations.",
+  "",
+  "**Examining the Narrative Details**",
+  "",
+  "I'm now diving deep into analyzing the source text, identifying critical plot points and stylistic nuances. I'm preserving the dark fantasy setting while pinpointing the \"爽文打脸\" elements I need to tone down.",
+  "",
+  "**Analyzing the Conflict's Dynamics**",
+  "",
+  "I've been mapping out the escalating conflict within the narrative's framework and checking the characters' power dynamics.",
+].join("\n")
+
+const REVISED_CHINESE_BODY = [
+  "地下暗轨深处,空气沉得像一汪死水,混杂着铁锈、陈年机油与腐败菌丝的气味。",
+  "",
+  "叶刃停在防爆门前,指节轻敲门框。",
+].join("\n")
+
 describe("filterDeAiOutput", () => {
   it("过滤 Gemini 去 AI 味结果前置的思考摘要并保留正文", () => {
     const output = `${GEMINI_DE_AI_THOUGHTS}\n\n雨声压住了巷口的脚步。叶刃没有回头。`
@@ -22,6 +40,16 @@ describe("filterDeAiOutput", () => {
     expect(filterDeAiOutput(GEMINI_DE_AI_THOUGHTS)).toBe("")
   })
 
+  it("过滤截图同型且引用少量中文的 Gemini 思考摘要", () => {
+    expect(filterDeAiOutput(SCREENSHOT_STYLE_GEMINI_DE_AI_THOUGHTS)).toBe("")
+  })
+
+  it("过滤截图同型思考摘要并保留后续中文正文", () => {
+    const output = `${SCREENSHOT_STYLE_GEMINI_DE_AI_THOUGHTS}\n\n${REVISED_CHINESE_BODY}`
+
+    expect(filterDeAiOutput(output)).toBe(REVISED_CHINESE_BODY)
+  })
+
   it("不改动正常的去 AI 味正文", () => {
     const output = "雨声压住了巷口的脚步。\n\n叶刃把伞往下压了压。"
 

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

@@ -25,6 +25,14 @@ const SAMPLE_CHAPTER_OUTLINE = [
   "门外传来脚步声",
 ].join("\n")
 
+const GEMINI_OUTLINE_THOUGHT_DUMP = [
+  "I'm currently focused on defining the project scope and the requested chapter outline.",
+  "",
+  "**Examining the Narrative Details**",
+  "",
+  "I'm now analyzing the source text and identifying the required plot points.",
+].join("\n")
+
 describe("outline-save-request", () => {
   it("解析 AI 大纲回复中的单个保存请求", () => {
     const result = parseOutlineSaveRequests([
@@ -54,6 +62,41 @@ describe("outline-save-request", () => {
     })
   })
 
+  it("清理保存请求 content 中前置的 Gemini 思考摘要", () => {
+    const result = parseOutlineSaveRequests(JSON.stringify({
+      outlineSaveRequest: {
+        targetFolder: "章纲",
+        fileName: "章纲-第001章.md",
+        fileType: "chapter-outline",
+        writeMode: "create",
+        referencedSkills: [],
+        sourceIntent: "生成第001章章纲",
+        content: `${GEMINI_OUTLINE_THOUGHT_DUMP}\n\n${SAMPLE_CHAPTER_OUTLINE}`,
+      },
+    }))
+
+    expect(result.errors).toEqual([])
+    expect(result.requests).toHaveLength(1)
+    expect(result.requests[0]?.content).toBe(SAMPLE_CHAPTER_OUTLINE)
+  })
+
+  it("拒绝 content 仅包含 Gemini 思考摘要的保存请求", () => {
+    const result = parseOutlineSaveRequests(JSON.stringify({
+      outlineSaveRequest: {
+        targetFolder: "大纲",
+        fileName: "总纲.md",
+        fileType: "outline",
+        writeMode: "create",
+        referencedSkills: [],
+        sourceIntent: "生成总纲",
+        content: GEMINI_OUTLINE_THOUGHT_DUMP,
+      },
+    }))
+
+    expect(result.requests).toHaveLength(0)
+    expect(result.errors).toContain("第 1 个保存请求的 content 仅包含模型思考过程。")
+  })
+
   it("未闭合 json 围栏时仍能回收完整保存请求", () => {
     const result = parseOutlineSaveRequests([
       SAMPLE_CHAPTER_OUTLINE,

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

@@ -3,6 +3,7 @@ import type { CharacterSaveDraft } from "./character-save-extractor"
 import { cleanNextStepArtifacts } from "./outline-next-step"
 import { isLikelyChapterOutline } from "./outline-quality-check"
 import { stripOutlineFrontmatter } from "./outline-markdown"
+import { stripThoughtDumpFromText } from "@/lib/thought-dump"
 
 export type OutlineSaveRequestFileType =
   | "outline"
@@ -205,7 +206,8 @@ function normalizeRequest(raw: unknown, index: number): {
   const fileName = String(raw.fileName ?? "").trim()
   const fileType = normalizeFileTypeAlias(String(raw.fileType ?? "")) as OutlineSaveRequestFileType
   const writeMode = normalizeWriteModeAlias(String(raw.writeMode ?? "")) as OutlineSaveRequestWriteMode
-  const content = String(raw.content ?? "").trim()
+  const rawContent = String(raw.content ?? "").trim()
+  const content = stripThoughtDumpFromText(rawContent).trim()
 
   for (const [field, value] of Object.entries({
     targetFolder,
@@ -229,6 +231,9 @@ function normalizeRequest(raw: unknown, index: number): {
   if (writeMode && !ALLOWED_WRITE_MODES.has(writeMode)) {
     errors.push(`不支持的写入模式:${writeMode}。`)
   }
+  if (rawContent && !content) {
+    errors.push(`第 ${index + 1} 个保存请求的 content 仅包含模型思考过程。`)
+  }
 
   if (errors.length > 0) return { request: null, errors }
 

+ 93 - 0
src/lib/thought-dump.spec.ts

@@ -26,11 +26,46 @@ const GEMINI_THOUGHT_DUMP = [
   "The opening scene starts in the rain outside Black Water Alley.",
 ].join("\n")
 
+const SCREENSHOT_STYLE_THOUGHT_DUMP = [
+  "I'm currently focused on defining the project scope, prioritizing the objective: refining the novel snippet to align with the \"去 AI 味\" skill's instructions. The crucial requirement is to modify the text, producing solely the revised narrative without any extraneous explanations.",
+  "",
+  "**Examining the Narrative Details**",
+  "",
+  "I'm now diving deep into analyzing the source text, identifying critical plot points and stylistic nuances. I'm preserving the dark fantasy setting while pinpointing the \"爽文打脸\" elements I need to tone down.",
+  "",
+  "**Analyzing the Conflict's Dynamics**",
+  "",
+  "I've been mapping out the escalating conflict within the narrative's framework and checking the characters' power dynamics.",
+  "",
+  "**Detailing the Confrontation**",
+  "",
+  "I'm now detailing the confrontation and ensuring that the revised output contains no extraneous explanation.",
+].join("\n")
+
 describe("isThoughtDumpText", () => {
   it("recognizes Gemini thought-summary dumps", () => {
     expect(isThoughtDumpText(GEMINI_THOUGHT_DUMP)).toBe(true)
   })
 
+  it("recognizes an unlabelled first-person Gemini dump that quotes a few Chinese terms", () => {
+    expect(isThoughtDumpText(SCREENSHOT_STYLE_THOUGHT_DUMP)).toBe(true)
+  })
+
+  it("keeps recognizing legacy unlabelled planning prefixes", () => {
+    for (const dump of [
+      "I need to analyze the source text and return only the revised chapter.",
+      "I'll review the narrative constraints before producing the final chapter.",
+      "Let me examine the source text and rewrite the dialogue.",
+    ]) {
+      expect(isThoughtDumpText(dump)).toBe(true)
+      expect(stripThoughtDumpFromText(dump)).toBe("")
+    }
+  })
+
+  it("does not discard an unfinished thought fragment before its SSE continuation arrives", () => {
+    expect(isThoughtDumpText("I'm currently focused on defining the project scope, prioritizing ")).toBe(false)
+  })
+
   it("does not treat Chinese chapter text as a dump", () => {
     expect(isThoughtDumpText("雨还在下。黑水巷7号的铁门没有关严。")).toBe(false)
   })
@@ -38,6 +73,32 @@ describe("isThoughtDumpText", () => {
   it("does not treat ordinary English prose without dump headers as a dump", () => {
     expect(isThoughtDumpText("It was a dark and stormy night.\n\nThe detective walked into the alley.")).toBe(false)
   })
+
+  it("does not treat ordinary first-person English prose as a dump", () => {
+    const prose = [
+      "I'm currently focused on the road ahead, where the storm has swallowed every landmark.",
+      "",
+      "I've been mapping out each turn since dawn, but the river keeps erasing my tracks.",
+    ].join("\n")
+
+    expect(isThoughtDumpText(prose)).toBe(false)
+    expect(stripThoughtDumpFromText(prose)).toBe(prose)
+  })
+
+  it("does not treat ordinary English story headings as thought headers", () => {
+    const prose = [
+      "**Chapter One**",
+      "",
+      "It was a dark and stormy night.",
+      "",
+      "**A Narrow Escape**",
+      "",
+      "The detective crossed the alley before the gate closed.",
+    ].join("\n")
+
+    expect(isThoughtDumpText(prose)).toBe(false)
+    expect(stripThoughtDumpFromText(prose)).toBe(prose)
+  })
 })
 
 describe("stripThoughtDumpFromText", () => {
@@ -61,6 +122,27 @@ describe("stripThoughtDumpFromText", () => {
     expect(stripThoughtDumpFromText(GEMINI_THOUGHT_DUMP)).toBe("")
   })
 
+  it("returns empty for a screenshot-style payload containing only thoughts", () => {
+    expect(stripThoughtDumpFromText(SCREENSHOT_STYLE_THOUGHT_DUMP)).toBe("")
+  })
+
+  it("keeps Chinese body after a screenshot-style thought dump", () => {
+    const body = [
+      "地下暗轨深处,空气沉得像一汪死水。",
+      "",
+      "叶刃停在防爆门前,指节轻敲门框。",
+    ].join("\n")
+
+    expect(stripThoughtDumpFromText(`${SCREENSHOT_STYLE_THOUGHT_DUMP}\n\n${body}`)).toBe(body)
+  })
+
+  it("keeps whole-dump detection and stripping consistent", () => {
+    for (const dump of [GEMINI_THOUGHT_DUMP, SCREENSHOT_STYLE_THOUGHT_DUMP]) {
+      expect(isThoughtDumpText(dump)).toBe(true)
+      expect(stripThoughtDumpFromText(dump)).toBe("")
+    }
+  })
+
   it("strips a dump glued to Chinese without blank-line section breaks", () => {
     const glued = [
       "**Defining the Request**",
@@ -73,6 +155,17 @@ describe("stripThoughtDumpFromText", () => {
     expect(stripThoughtDumpFromText(glued)).toBe("雨还在下。叶刃把伞骨收紧。")
   })
 
+  it("strips an unlabelled first-person dump glued to Chinese by line breaks", () => {
+    const glued = [
+      "I'm currently focused on analyzing the source text and refining the chapter output.",
+      "**Examining the Narrative Details**",
+      "I'm now reviewing the narrative constraints and character conflict.",
+      "雨还在下。叶刃把伞骨收紧。",
+    ].join("\n")
+
+    expect(stripThoughtDumpFromText(glued)).toBe("雨还在下。叶刃把伞骨收紧。")
+  })
+
   it("keeps a Chinese chapter that uses bold emphasis", () => {
     const chapter = "**夜雨**\n\n他走进巷子,没有回头。"
     expect(stripThoughtDumpFromText(chapter)).toBe(chapter)

+ 82 - 37
src/lib/thought-dump.ts

@@ -8,59 +8,105 @@
  *   **Pinpointing Chapter Details**
  *   ...
  *
- * Standard/strict chapter workflows concatenate every `onToken` into the
- * chapter body, so those English planning notes leak into the editor.
- * Strip them; do not treat Title-Case markdown headers as story text.
+ * They can also start with an unlabelled first-person planning paragraph and
+ * quote a small amount of Chinese source text. Keep the detector deliberately
+ * narrow: an English paragraph is only allowed to start a dump when it contains
+ * an explicit assistant-workflow signal. Plain English narrative is not enough.
  */
 
-const CJK_RE = /[\u4e00-\u9fff]/
+const CJK_RE = /[\u4e00-\u9fff]/g
 const DUMP_HEADER_RE = /^\*\*([^*]+)\*\*\s*$/
-const DUMP_PROSE_RE =
-  /^(The user (wants|is asking|requested|needs|has asked)|I need to|I'll |I will |Let's |Let me |The request\b|The goal\b|The task\b)/i
+const DUMP_HEADER_TOPIC_RE =
+  /\b(?:Analy[sz](?:ing|is)|Assess(?:ing|ment)|Clarifying|Considering|Crafting|Defining|Detailing|Developing|Ensuring|Evaluating|Evaluation|Examining|Exploring|Focusing|Formulating|Identifying|Initiating|Mapping|Pinpointing|Planning|Reasoning|Refining|Reviewing|Structuring|Understanding)\b/i
+const REQUEST_DUMP_PROSE_RE =
+  /^(?:The user (?:wants|is asking|requested|needs|has asked)\b|The (?:request|goal|task)\b)/i
+const LEGACY_FIRST_PERSON_DUMP_PROSE_RE =
+  /^(?:I need to\b|I(?:['’]ll| will)\b|Let(?:['’]s| me)\b)/i
+const FIRST_PERSON_DUMP_PROSE_RE =
+  /^I(?:['’]m| am)(?: currently| now)? (?:dissecting|focused on (?:defining|analy[sz]ing|examining|identifying|refining|reviewing|structuring)|focusing on|diving deep into (?:analy[sz]ing|examining|reviewing)|zeroing in on|analy[sz]ing|examining|evaluating|assessing|refining|clarifying|defining|identifying|mapping out|detailing|reviewing|considering|exploring|structuring|crafting|formulating|developing)|^I(?:['’]ve| have) been (?:mapping out|analy[sz]ing|examining|evaluating|assessing|refining|reviewing|considering|exploring|identifying|detailing)/i
+const DUMP_CONTEXT_RE =
+  /\b(?:task|request|goal|project scope|source text|text|novel|chapter|story|narrative|plot|scene|conflict|character|rewrite|response|output|instruction|constraint|detail|content|dialogue|framework|objective|requirement)s?\b/i
+
+function cjkCount(text: string): number {
+  return text.match(CJK_RE)?.length ?? 0
+}
+
+/**
+ * A few quoted CJK terms inside a long English planning paragraph are not a
+ * body boundary. A CJK-heavy line or paragraph still is.
+ */
+function hasSubstantiveCjk(text: string): boolean {
+  const cjk = cjkCount(text)
+  if (cjk === 0) return false
+  const latin = text.match(/[A-Za-z]/g)?.length ?? 0
+  if (cjk <= 6 && latin >= 40) return false
+  return latin < 12 || cjk >= 8 || cjk / Math.max(cjk + latin, 1) > 0.12
+}
 
 function isThoughtDumpHeader(line: string): boolean {
   const match = line.trim().match(DUMP_HEADER_RE)
   if (!match) return false
   const inner = match[1].trim()
-  if (!inner || CJK_RE.test(inner)) return false
+  if (!inner || cjkCount(inner) > 0) return false
   if (!/^[A-Za-z]/.test(inner)) return false
   if (inner.length < 3 || inner.length > 80) return false
-  if (!/^[A-Za-z0-9 ,:'\-()/]+$/.test(inner)) return false
+  if (!/^[A-Za-z0-9 ,:'\-()/]+$/.test(inner)) return false
   const words = inner.split(/\s+/).filter(Boolean)
   if (words.length === 0) return false
-  if (words.length === 1) return /^[A-Z][a-z]+/.test(words[0] ?? "")
   const capitalized = words.filter((word) => /^[A-Z]/.test(word)).length
-  return capitalized >= Math.ceil(words.length * 0.5)
-}
-
-function isEnglishDumpProse(text: string): boolean {
-  const trimmed = text.trim()
-  if (!trimmed || CJK_RE.test(trimmed)) return false
-  return DUMP_PROSE_RE.test(trimmed)
+  const titleCase = words.length === 1
+    ? /^[A-Z][a-z]+/.test(words[0] ?? "")
+    : capitalized >= Math.ceil(words.length * 0.5)
+  return titleCase && DUMP_HEADER_TOPIC_RE.test(inner)
 }
 
 function isMostlyEnglishProse(text: string): boolean {
   const trimmed = text.trim()
-  if (!trimmed || CJK_RE.test(trimmed)) return false
+  if (!trimmed || hasSubstantiveCjk(trimmed)) return false
   const letters = trimmed.match(/[A-Za-z]/g)?.length ?? 0
   const nonSpace = trimmed.replace(/\s/g, "").length
-  return letters >= 12 && letters / Math.max(nonSpace, 1) >= 0.7
+  return letters >= 12 && letters / Math.max(nonSpace, 1) >= 0.65
+}
+
+function isEnglishDumpProse(text: string): boolean {
+  const trimmed = text.trim()
+  if (!isMostlyEnglishProse(trimmed)) return false
+  if (REQUEST_DUMP_PROSE_RE.test(trimmed)) return true
+  return (
+    FIRST_PERSON_DUMP_PROSE_RE.test(trimmed)
+    || LEGACY_FIRST_PERSON_DUMP_PROSE_RE.test(trimmed)
+  ) && DUMP_CONTEXT_RE.test(trimmed)
 }
 
 function looksLikeThoughtDumpBlock(block: string): boolean {
   const trimmed = block.trim()
-  if (!trimmed || CJK_RE.test(trimmed)) return false
-  const firstLine = trimmed.split("\n")[0] ?? ""
+  if (!trimmed || hasSubstantiveCjk(trimmed)) return false
+  const firstLine = trimmed.split("\n").find((line) => line.trim()) ?? ""
   if (isThoughtDumpHeader(firstLine)) return true
   return isEnglishDumpProse(trimmed)
 }
 
+function isDumpContinuationBlock(block: string): boolean {
+  return looksLikeThoughtDumpBlock(block) || isMostlyEnglishProse(block)
+}
+
+function splitParagraphs(text: string): string[] {
+  return text.replace(/\r\n?/g, "\n").split(/\n{2,}/)
+}
+
 export function isThoughtDumpText(text: string): boolean {
   const trimmed = text.trim()
-  if (!trimmed || CJK_RE.test(trimmed)) return false
-  if (looksLikeThoughtDumpBlock(trimmed)) return true
-  const headerCount = trimmed.split("\n").filter((line) => isThoughtDumpHeader(line)).length
-  return headerCount >= 2
+  if (!trimmed) return false
+
+  const parts = splitParagraphs(trimmed)
+  if (!looksLikeThoughtDumpBlock(parts[0] ?? "")) return false
+  const hasThoughtHeader = trimmed.split("\n").some((line) => isThoughtDumpHeader(line))
+  if (parts.length === 1 && !hasThoughtHeader && !/[.!?][\"')\]]?$/.test(trimmed)) {
+    // Do not drop an unfinished SSE fragment. Its continuation may contain the
+    // only boundary between the thought summary and the requested content.
+    return false
+  }
+  return parts.slice(1).every((part) => isDumpContinuationBlock(part))
 }
 
 function isLeadingDumpParagraph(block: string, alreadyInDump: boolean): boolean {
@@ -69,22 +115,23 @@ function isLeadingDumpParagraph(block: string, alreadyInDump: boolean): boolean
 }
 
 function stripLeadingThoughtDumpDense(text: string): string {
-  if (!/^\s*\*\*[A-Za-z]/.test(text) && !DUMP_PROSE_RE.test(text.trim())) {
-    return text.trim()
+  const trimmed = text.trim()
+  const firstLine = trimmed.split("\n").find((line) => line.trim()) ?? ""
+  if (!isThoughtDumpHeader(firstLine) && !isEnglishDumpProse(firstLine)) {
+    return trimmed
   }
 
   const lines = text.split("\n")
-  const firstCjk = lines.findIndex((line) => CJK_RE.test(line))
-  if (firstCjk < 0) {
-    return isThoughtDumpText(text) || isMostlyEnglishProse(text) ? "" : text.trim()
+  const firstBodyLine = lines.findIndex((line) => hasSubstantiveCjk(line))
+  if (firstBodyLine < 0) {
+    return isThoughtDumpText(text) ? "" : trimmed
   }
 
-  let keepFrom = firstCjk
+  let keepFrom = firstBodyLine
   while (keepFrom > 0 && !lines[keepFrom - 1]!.trim()) keepFrom -= 1
   const prefix = lines.slice(0, keepFrom).join("\n")
-  if (!prefix.trim()) return text.trim()
-  if (!isThoughtDumpText(prefix) && !looksLikeThoughtDumpBlock(prefix)) {
-    return text.trim()
+  if (!prefix.trim() || !isThoughtDumpText(prefix)) {
+    return trimmed
   }
   return lines.slice(keepFrom).join("\n").trim()
 }
@@ -92,7 +139,7 @@ function stripLeadingThoughtDumpDense(text: string): string {
 export function stripThoughtDumpFromText(text: string): string {
   if (!text) return text
   const normalized = text.replace(/\r\n?/g, "\n")
-  const parts = normalized.split(/\n{2,}/)
+  const parts = splitParagraphs(normalized)
 
   let start = 0
   let inDump = false
@@ -112,9 +159,7 @@ export function stripThoughtDumpFromText(text: string): string {
     break
   }
 
-  if (start >= end) {
-    return stripLeadingThoughtDumpDense(normalized)
-  }
+  if (start >= end) return ""
   if (start === 0 && end === parts.length) {
     return stripLeadingThoughtDumpDense(normalized)
   }