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

merge: PR #46 章节删除优化、大纲结果丢失与计划模式修复(本地测试)

Mochocyang 1 месяц назад
Родитель
Сommit
e4183ece10

+ 1 - 1
src-tauri/Cargo.lock

@@ -5869,7 +5869,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
 
 [[package]]
 name = "qmai"
-version = "3.1.1"
+version = "3.1.3"
 dependencies = [
  "arrow-array",
  "arrow-schema",

+ 8 - 2
src/components/chat/chapter-plan-confirm-dialog.spec.tsx

@@ -51,8 +51,14 @@ describe("chapter-plan-confirm-dialog 纯函数", () => {
       expect(extractChapterPlan("本章目标是制造紧张感,然后直接输出正文。")).toBeNull()
     })
 
-    it("只有开始标记时返回 null", () => {
-      expect(extractChapterPlan(`${CHAPTER_PLAN_MARKER_START}计划内容`)).toBeNull()
+    it("只有开始标记时把剩余内容当作计划(截断容错)", () => {
+      const result = extractChapterPlan(`${CHAPTER_PLAN_MARKER_START}计划内容`)
+      expect(result).not.toBeNull()
+      expect(result!.plan).toBe("计划内容")
+    })
+
+    it("只有开始标记且其后无内容时返回 null", () => {
+      expect(extractChapterPlan(`前文${CHAPTER_PLAN_MARKER_START}   `)).toBeNull()
     })
 
     it("完整标记时正确提取计划", () => {

+ 7 - 1
src/components/chat/chapter-plan-confirm-dialog.tsx

@@ -39,7 +39,13 @@ export function extractChapterPlan(fullContent: string): { plan: string; body: s
   if (startIdx < 0) return extractUnmarkedChapterPlan(fullContent)
   const contentStart = startIdx + CHAPTER_PLAN_MARKER_START.length
   const endIdx = fullContent.indexOf(CHAPTER_PLAN_MARKER_END, contentStart)
-  if (endIdx < 0) return null
+  if (endIdx < 0) {
+    // 有开始标记但缺结束标记(输出被截断或模型漏写):把开始标记之后的
+    // 全部内容当作计划,而不是直接判定失败导致不弹确认窗。
+    const truncatedPlan = fullContent.slice(contentStart).trim()
+    if (!truncatedPlan) return null
+    return { plan: truncatedPlan, body: fullContent.slice(0, startIdx).trim() }
+  }
   const plan = fullContent.slice(contentStart, endIdx).trim()
   const beforePlan = fullContent.slice(0, startIdx).trim()
   const afterPlan = fullContent.slice(endIdx + CHAPTER_PLAN_MARKER_END.length).trim()

+ 39 - 5
src/components/chat/chat-panel.tsx

@@ -316,15 +316,22 @@ function buildChatAgentSystemPrompt(options: {
     lines.push("当前处于资料写入模式,用户可能希望把对话内容整理写入资料库。")
   }
   if (options.novelMode) {
-    lines.push("小说模式下,如果用户要求生成、续写或改写章节,只输出可直接放入章节库的正文。")
-    lines.push("章节生成、续写或改写任务的最终回复必须只包含章节正文,不要把工具读取过程、写作计划或执行过程展示给用户。")
-    lines.push("不要输出读取说明、执行总结、完成目标表格、章节结构、后续建议、引用来源或 Markdown 表格;章节标题和正文以外的内容都不要输出。")
+    const planPhase = Boolean(options.planExecuteEnabled) && options.aiWorkflowMode !== "fast"
+    if (planPhase) {
+      // 计划阶段与"只输出正文/必须调 run_chapter_workflow"互斥:同时注入
+      // 会让模型在两套矛盾指令之间随机选择,表现为跳过计划直接产出正文。
+      lines.push("当前处于章节计划阶段:本轮只输出章节创作计划并等待用户确认,禁止输出章节正文,禁止调用 run_chapter_workflow 等正文生成类工具。")
+    } else {
+      lines.push("小说模式下,如果用户要求生成、续写或改写章节,只输出可直接放入章节库的正文。")
+      lines.push("章节生成、续写或改写任务的最终回复必须只包含章节正文,不要把工具读取过程、写作计划或执行过程展示给用户。")
+      lines.push("不要输出读取说明、执行总结、完成目标表格、章节结构、后续建议、引用来源或 Markdown 表格;章节标题和正文以外的内容都不要输出。")
+    }
     if (options.includeOutlineFindProtocol) {
       lines.push(buildOutlineFindProtocol(options.targetChapterNumber))
     }
     if (options.aiWorkflowMode === "fast") {
       lines.push("快速模式下可以读取必要上下文;除非用户明确要求使用工作流或 Skill,否则不要主动调用 run_chapter_workflow。")
-    } else {
+    } else if (!planPhase) {
       lines.push("章节生成、续写、改写或润色必须调用 run_chapter_workflow 工具;未调用前禁止输出章节终稿正文。")
     }
   }
@@ -1797,11 +1804,20 @@ export function ChatPanel() {
           planExecuteActive,
           enabledToolNames: prePluginResult?.enabledToolNames,
         })
+        // 计划阶段硬管控:不依赖任务路由/pre-plugin 是否命中,直接从本轮可用
+        // 工具中移除正文生成与写入类工具(模型的 tools 广告和文本工具调用解析
+        // 都以 config.tools 为准),从根上阻止模型跳过计划直接产出正文。
+        const sessionTools = planExecuteActive
+          ? agentConfig.tools.filter(
+              (tool) => tool.name !== "run_chapter_workflow" && tool.category !== "write",
+            )
+          : agentConfig.tools
         const record = await withWritingWakeLock(keepAwake, () => runAiChatSession({
           userMessage: plainText,
           projectPath,
           agentConfig: {
             ...agentConfig,
+            tools: sessionTools,
             systemPrompt: systemPromptForConfig,
             projectPath,
             taskGoal: plainText,
@@ -1886,6 +1902,14 @@ export function ChatPanel() {
             console.warn("供应商缓存用量快照保存失败,继续保留本地缓存统计:", error)
           }
         }
+        // 计划模式:markDone/getCopyableAssistantContent 会重写消息内容
+        // (剥掉 HTML 注释等隐藏块),先捕获原始文本供后续计划提取使用,
+        // 避免 `<!-- chapter_plan -->` 标记在提取前就被剥掉。
+        const rawAssistantContentForPlan = planExecuteActive
+          ? useChatStore.getState().messages.find((m) => m.id === assistantMessage.id)?.content
+            || record.finalText
+            || ""
+          : ""
         finishAgentSession(() => {
           if (!hasAgentError) {
             if (contextTrace && effectiveTaskRoute) {
@@ -2032,7 +2056,9 @@ export function ChatPanel() {
           const lastAssistant = storeState.messages.find(
             (m) => m.id === assistantMessage.id && m.role === "assistant",
           )
-          const fullContent = lastAssistant?.content || record.finalText || ""
+          // 优先用重写前捕获的原始文本:markDone 会剥掉 HTML 注释,
+          // 只有原始文本还保留 `<!-- chapter_plan -->` 标记。
+          const fullContent = rawAssistantContentForPlan || lastAssistant?.content || record.finalText || ""
           // 诊断日志:帮助定位"计划弹窗有时不出现"问题
           const hasMarker = fullContent.includes("<!-- chapter_plan -->")
           console.info("[PlanExecute] 检查计划提取", {
@@ -2042,7 +2068,15 @@ export function ChatPanel() {
             messageContentLength: lastAssistant?.content?.length ?? 0,
             recordFinalTextLength: record.finalText?.length ?? 0,
           })
+          // 兜底:标记与关键词启发式都失败但确实有内容时,把整段回复
+          // 当作计划弹窗(用户可修改/跳过),替代静默不弹窗。仅对写作类
+          // 请求(或路由未识别的请求)生效,避免普通问答被误当计划。
+          const planFallbackEligible =
+            !effectiveTaskRoute || WRITING_INTENTS.has(effectiveTaskRoute.intent)
           const extracted = extractChapterPlan(fullContent)
+            ?? (planFallbackEligible && fullContent.trim()
+              ? { plan: fullContent.trim(), body: "" }
+              : null)
           if (extracted) {
             console.info("[PlanExecute] 计划提取成功,弹出确认对话框", {
               planLength: extracted.plan.length,

+ 15 - 0
src/components/layout/knowledge-tree.spec.tsx

@@ -16,4 +16,19 @@ describe("KnowledgeTree chapter memory extraction menu", () => {
     expect(source).toContain("useImportProgressStore.getState().startTask")
     expect(previewSource).not.toContain("一键提取所有章节")
   })
+
+  it("dispatches deleted chapter memory cleanup without awaiting it", () => {
+    expect(source).toContain("enqueueDeletedChapterSourceMemoryCleanup")
+    expect(source).toContain('void import("@/lib/novel/delete-source-memory")')
+    expect(source).not.toContain("await cleanupDeletedSourceMemory")
+    expect(source).toContain("await cleanupDeletedOutlineSourceMemory")
+  })
+
+  it("removes deleted chapter rows optimistically without showing chapter loading", () => {
+    expect(source).toContain("setPages((previous) => previous.filter((page) => page.path !== pagePath))")
+    expect(source).toContain('const showDeleteLoading = filterType === "outline" && isDeleting')
+    expect(source).toContain("mapWithConcurrency(files, PAGE_METADATA_CONCURRENCY")
+    expect(source).toContain('listDirectory(`${projectPath}/wiki/chapters`)')
+    expect(source).toContain('listDirectory(`${projectPath}/wiki/outlines`)')
+  })
 })

+ 67 - 26
src/components/layout/knowledge-tree.tsx

@@ -18,6 +18,7 @@ import { useOutlineGenerationStore } from "@/stores/outline-generation-store"
 import { startOutlineIngestTask } from "@/lib/novel/outline-generation"
 import { getOutlineFileName, outlineSnapshotExists } from "@/lib/novel/outline-ingest-utils"
 import { saveLastReadChapter } from "@/lib/project-store"
+import { mapWithConcurrency } from "@/lib/async-pool"
 import type { ReferenceToken } from "@/lib/reference/types"
 
 function formatImportProgressRunningLabel(task: ImportProgressTask, kindLabel: string): string {
@@ -80,6 +81,7 @@ interface CreateMenuState {
 }
 
 const EMPTY_PENDING_PAGES: WikiPageInfo[] = []
+const PAGE_METADATA_CONCURRENCY = 16
 
 function parseChineseNumber(input: string): number | null {
   const digitMap: Record<string, number> = {
@@ -215,9 +217,20 @@ function getRelativePath(path: string, rootPath: string): string {
     : normalizedPath.split("/").pop() ?? ""
 }
 
-async function cleanupDeletedSourceMemory(
+function enqueueDeletedChapterSourceMemoryCleanup(
   projectPath: string,
-  input: { kind: "chapter" | "outline"; pagePath: string; content?: string },
+  input: { kind: "chapter"; pagePath: string; content?: string },
+): void {
+  void import("@/lib/novel/delete-source-memory")
+    .then(({ deleteNovelSourceMemory }) => deleteNovelSourceMemory(projectPath, input))
+    .catch((error) => {
+      console.warn("[KnowledgeTree] 后台清理章节关联记忆失败:", error)
+    })
+}
+
+async function cleanupDeletedOutlineSourceMemory(
+  projectPath: string,
+  input: { kind: "outline"; pagePath: string },
 ): Promise<void> {
   const { deleteNovelSourceMemory } = await import("@/lib/novel/delete-source-memory")
   await deleteNovelSourceMemory(projectPath, input)
@@ -378,19 +391,23 @@ export function KnowledgeTree({
     if (!project) return
     const projectPath = normalizePath(project.path)
     try {
-      const wikiTree = await listDirectory(`${projectPath}/wiki`)
-      const nextPages: WikiPageInfo[] = []
-      for (const file of flattenMdFiles(wikiTree)) {
-        if (file.name === "index.md" || file.name === "log.md") continue
+      const directoryResults = await Promise.allSettled([
+        listDirectory(`${projectPath}/wiki/chapters`),
+        listDirectory(`${projectPath}/wiki/outlines`),
+      ])
+      const files = directoryResults.flatMap((result) =>
+        result.status === "fulfilled" ? flattenMdFiles(result.value) : [],
+      )
+      const loadedPages = await mapWithConcurrency(files, PAGE_METADATA_CONCURRENCY, async (file) => {
         try {
           const content = await readFile(file.path)
-          const info = parsePageInfo(file.path, file.name, content)
-          if (info) nextPages.push(info)
+          return parsePageInfo(file.path, file.name, content)
         } catch {
           // Ignore unreadable pages in the navigator.
+          return null
         }
-      }
-      setPages(nextPages)
+      })
+      setPages(loadedPages.filter((page): page is WikiPageInfo => page !== null))
     } catch (error) {
       console.error("[KnowledgeTree] loadPages failed:", error)
       setPages([])
@@ -791,6 +808,7 @@ export function KnowledgeTree({
 
   const handleDeleteClick = useCallback(async (pagePath: string) => {
     if (!project) return
+    if (deletingPath === pagePath) return
     if (armedPath !== pagePath) {
       setArmedPath(pagePath)
       return
@@ -807,28 +825,36 @@ export function KnowledgeTree({
         } catch { /* ignore */ }
       }
       await moveFileToTrash(projectPath, pagePath, filterType)
-      try {
-        await cleanupDeletedSourceMemory(projectPath, {
-          kind: filterType,
+      setPages((previous) => previous.filter((page) => page.path !== pagePath))
+      onRemovePendingPage?.(pagePath)
+      if (selectedFile === pagePath) setSelectedFile(null)
+      setDeletingPath(null)
+      if (filterType === "chapter") {
+        enqueueDeletedChapterSourceMemoryCleanup(projectPath, {
+          kind: "chapter",
           pagePath,
           content: sourceContent,
         })
-      } catch (e) {
-        // 记忆清理失败不影响文件删除本身
-        console.warn("[KnowledgeTree] 清理关联记忆失败:", e)
+      } else {
+        try {
+          await cleanupDeletedOutlineSourceMemory(projectPath, {
+            kind: "outline",
+            pagePath,
+          })
+        } catch (e) {
+          // 记忆清理失败不影响文件删除本身
+          console.warn("[KnowledgeTree] 清理关联记忆失败:", e)
+        }
       }
-      await loadPages()
-      onRemovePendingPage?.(pagePath)
       const tree = await listDirectory(projectPath)
       setFileTree(tree)
       bumpDataVersion()
-      if (selectedFile === pagePath) setSelectedFile(null)
     } catch (error) {
       console.error("[KnowledgeTree] delete failed:", error)
     } finally {
       setDeletingPath(null)
     }
-  }, [project, armedPath, filterType, loadPages, onRemovePendingPage, setFileTree, bumpDataVersion, selectedFile, setSelectedFile])
+  }, [project, deletingPath, armedPath, filterType, onRemovePendingPage, setFileTree, bumpDataVersion, selectedFile, setSelectedFile])
 
   const handleDeleteFolder = useCallback(async (folderPath: string) => {
     if (!project) return
@@ -853,11 +879,25 @@ export function KnowledgeTree({
       const projectPath = normalizePath(project.path)
       for (const filePath of mdFiles) {
         try {
+          let sourceContent: string | undefined
+          if (fileKind === "chapter") {
+            try {
+              sourceContent = await readFile(filePath)
+            } catch { /* ignore */ }
+          }
           await moveFileToTrash(projectPath, filePath, fileKind)
-          await cleanupDeletedSourceMemory(projectPath, {
-            kind: fileKind,
-            pagePath: filePath,
-          })
+          if (fileKind === "chapter") {
+            enqueueDeletedChapterSourceMemoryCleanup(projectPath, {
+              kind: "chapter",
+              pagePath: filePath,
+              content: sourceContent,
+            })
+          } else {
+            await cleanupDeletedOutlineSourceMemory(projectPath, {
+              kind: "outline",
+              pagePath: filePath,
+            })
+          }
         } catch (e) {
           // 单个文件删除失败不影响整体流程(如文件已不存在的幽灵条目)
           console.warn("[KnowledgeTree] 删除文件失败,继续处理下一个:", filePath, e)
@@ -1462,6 +1502,7 @@ export function KnowledgeTree({
       const isChapterChecked = filterType === "chapter" && selectedChapterPaths.has(normalizedPath)
       const isArmed = armedPath === normalizedPath
       const isDeleting = deletingPath === normalizedPath
+      const showDeleteLoading = filterType === "outline" && isDeleting
       const isDragSource = dragSource === normalizedPath
       const chapterIndex = chapterIndexMap.get(normalizedPath)
       const isInsertTarget = isDragging && dragInsertIndex !== null && chapterIndex !== undefined && chapterIndex === dragInsertIndex && !isDragSource
@@ -1563,8 +1604,8 @@ export function KnowledgeTree({
           ) : null}
           <DeleteButton
             armed={isArmed}
-            deleting={isDeleting}
-            className={`mr-1 transition-opacity ${isArmed || isDeleting ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
+            deleting={showDeleteLoading}
+            className={`mr-1 transition-opacity ${isArmed || showDeleteLoading ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
             onClick={() => void handleDeleteClick(normalizedPath)}
             name={page.title}
           />

+ 11 - 6
src/components/sources/outline-chat-panel.spec.tsx

@@ -270,7 +270,7 @@ describe("OutlineChatPanel controls", () => {
     expect(toastSpy).toHaveBeenCalledWith("发送失败,推荐操作已恢复,请稍后重试。", expect.objectContaining({ dedupeKey: expect.any(String) }))
   })
 
-  it("停止大纲生成时保留部分内容并清理运行状态", async () => {
+  it("停止大纲生成时清理运行状态提示且不把状态提示写入消息", async () => {
     const controller = new AbortController()
     outlineConversationRunRegistry.register("outline-active", controller)
     setOutlineConversations([conversation([{
@@ -279,7 +279,7 @@ describe("OutlineChatPanel controls", () => {
       content: "",
       isAgentRunning: true,
     }])], "outline-active", {
-      streamingContents: { "outline-active": "部分大纲" },
+      streamingContents: { "outline-active": "正在运行:世界观 Agent" },
       runStates: {
         "outline-active": { status: "running", updatedAt: 200, runId: "outline-run" },
       },
@@ -292,6 +292,8 @@ describe("OutlineChatPanel controls", () => {
     expect(statusIcon?.hasAttribute("data-slot")).toBe(false)
     expect(container.textContent).not.toContain("\u6b63\u5728\u751f\u6210...")
     expect(container.querySelector(".animate-pulse.rounded-md.border.bg-sky-50")).toBeNull()
+    // 运行状态提示以独立状态行展示
+    expect(container.textContent).toContain("正在运行:世界观 Agent")
 
     const stopButton = container.querySelector<HTMLButtonElement>('[aria-label="停止生成"]')
     expect(stopButton).not.toBeNull()
@@ -304,15 +306,15 @@ describe("OutlineChatPanel controls", () => {
     expect(controller.signal.aborted).toBe(true)
     expect(state.runStates["outline-active"]?.status).toBe("idle")
     expect(state.streamingContents["outline-active"]).toBeUndefined()
+    // 状态提示不会被当成内容写入消息;已生成内容由生成流程 catch 分支收尾
     expect(stoppedConversation?.messages).toEqual([expect.objectContaining({
       id: "assistant-running",
       role: "assistant",
-      content: "部分大纲",
-      isAgentRunning: false,
+      content: "",
     })])
   })
 
-  it("停止无部分内容的大纲生成时删除助手占位消息", async () => {
+  it("停止无部分内容的大纲生成时保留助手占位消息,由生成流程收尾", async () => {
     const controller = new AbortController()
     outlineConversationRunRegistry.register("outline-active", controller)
     setOutlineConversations([conversation([{
@@ -338,7 +340,10 @@ describe("OutlineChatPanel controls", () => {
     expect(controller.signal.aborted).toBe(true)
     expect(state.runStates["outline-active"]?.status).toBe("idle")
     expect(state.streamingContents["outline-active"]).toBeUndefined()
-    expect(stoppedConversation?.messages).toEqual([])
+    // 不再删除占位消息:AgentRunner 整轮结束才回调文本,停止瞬间可能已有
+    // 未送达的内容,改由 handleSend 的 catch 分支统一落盘或写停止占位。
+    expect(stoppedConversation?.messages).toHaveLength(1)
+    expect(stoppedConversation?.messages[0]?.id).toBe("assistant-running-empty")
   })
 
   it("根据当前大纲会话的已发送用户消息实时控制新建按钮", async () => {

+ 121 - 116
src/components/sources/outline-chat-panel.tsx

@@ -137,6 +137,7 @@ 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 { ToolRegistry } from "@/lib/agent/registry";
 import { buildAgentConfig, modelSupportsTools } from "@/lib/agent/config";
 import type { AgentMessage, AgentRunRecord } from "@/lib/agent/types";
@@ -564,19 +565,6 @@ function updateOutlineAssistantMessage(
   }));
 }
 
-function removeOutlineMessage(conversationId: string, messageId: string): void {
-  useOutlineChatStore.setState((state) => ({
-    conversations: state.conversations.map((conversation) =>
-      conversation.id === conversationId
-        ? {
-            ...conversation,
-            messages: conversation.messages.filter((message) => message.id !== messageId),
-          }
-        : conversation,
-    ),
-  }));
-}
-
 function describeOutlineSubAgentTask(agent: OutlineSubAgentPlan): string {
   switch (agent.kind) {
     case "outline":
@@ -837,7 +825,7 @@ function OutlineAssistantMessage({
   msg,
   index,
   isStreaming,
-  streamingContent,
+  runStatusText,
   activeMessagesLength,
   copied,
   projectPath,
@@ -857,7 +845,7 @@ function OutlineAssistantMessage({
   msg: import("@/stores/outline-chat-store").OutlineChatMessage;
   index: number;
   isStreaming: boolean;
-  streamingContent: string;
+  runStatusText: string;
   activeMessagesLength: number;
   copied: string | null;
   projectPath: string | null;
@@ -880,9 +868,8 @@ function OutlineAssistantMessage({
   >([]);
   const [editDismissed, setEditDismissed] = useState(false);
 
-  const displayContent =
-    msg.content ||
-    (isStreaming && index === activeMessagesLength - 1 ? streamingContent : "");
+  // 消息内容是唯一内容通道;运行状态提示单独渲染,绝不混入正文
+  const displayContent = msg.content;
   const { thinking, answer } = useMemo(
     () => separateThinking(displayContent),
     [displayContent],
@@ -938,9 +925,14 @@ function OutlineAssistantMessage({
         onConfirmSave={onConfirmToolSave}
         onReject={onRejectTool}
       />
+      {messageIsStreaming && !msg.content && runStatusText ? (
+        <div className="mb-1 whitespace-pre-wrap text-xs text-muted-foreground">
+          {runStatusText}
+        </div>
+      ) : null}
       <StreamingMarkdown
         content={renderedMarkdownContent}
-        isStreaming={isStreaming && index === activeMessagesLength - 1}
+        isStreaming={messageIsStreaming}
         renderCommitted={(text) => (
           <OutlineMarkdownContent content={text} projectPath={projectPath} />
         )}
@@ -1149,9 +1141,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     (s) => s.setActiveConversation,
   );
   const addMessage = useOutlineChatStore((s) => s.addMessage);
-  const replaceLastAssistant = useOutlineChatStore(
-    (s) => s.replaceLastAssistant,
-  );
   const deleteConversation = useOutlineChatStore((s) => s.deleteConversation);
   const setConversationModel = useOutlineChatStore(
     (s) => s.setConversationModel,
@@ -1823,13 +1812,17 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         isAgentRunning: true,
         intentPhase: options.intentPhase,
       });
-      setStreamingContent(capturedConvId, "");
+      clearStreamingContent(capturedConvId);
       userScrolledUpRef.current = false;
       let hiddenToolCalls: AgentRunRecord["toolCalls"] = [];
       let followUpGenerationPrompt: string | null = null;
       let contextHubResult: ContextHubResult | null = null;
       let providerUsage: LlmUsage | undefined;
       let accumulatedReasoningContent = "";
+      // 已生成的用户可见文本。streamingContents 只承载状态提示不存内容,
+      // 出错/中断时必须依靠这个变量判断有没有可保留的内容,
+      // 避免整段结果被静默丢弃。
+      let bestGeneratedText = "";
 
       try {
         const contextHub = getContextHub(normalizePath(project.path));
@@ -2033,8 +2026,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 runText += chunk;
                 if (optionsForRun.streamToUser) {
                   result += chunk;
+                  bestGeneratedText = result;
                   if (isCurrentRun()) {
-                    setStreamingContent(capturedConvId, result);
                     updateOutlineAssistantMessage(convId, assistantId, (message) => ({
                       ...message,
                       content: result,
@@ -2141,12 +2134,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             setStreamingContent(capturedConvId, "角色规划未识别到明确角色,按单 Agent 模式生成...");
             finalText = await runSingleAgentFallback();
           } else {
-            const headerContent = `# 人物小传\n\n共识别到 ${characterPlans.length} 个角色,正在并行生成...\n\n`;
             updateOutlineAssistantMessage(convId, assistantId, (message) => ({
               ...message,
               content: `# 人物小传生成中\n\n共识别到 ${characterPlans.length} 个角色,正在并行生成...`,
             }));
-            setStreamingContent(capturedConvId, headerContent);
 
             const completedByIndex: (CharacterAgentResult | null)[] = new Array(characterPlans.length).fill(null);
 
@@ -2189,7 +2180,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 if (!isCurrentRun()) return;
                 completedByIndex[result.plan.index] = result;
                 const newContent = rebuildAccumulated();
-                setStreamingContent(capturedConvId, newContent);
+                bestGeneratedText = newContent;
                 updateOutlineAssistantMessage(convId, assistantId, (message) => ({
                   ...message,
                   content: newContent,
@@ -2480,7 +2471,21 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           finalText = await runSingleAgentFallback();
         }
 
-        if (!isCurrentRun()) return { started: true, sent: false };
+        if (finalText.trim()) bestGeneratedText = finalText;
+        if (!isCurrentRun()) {
+          // run 已被停止或替换:跳过后续处理,但已生成的内容仍要写入消息,
+          // 不能因为状态闸门而静默丢弃整段结果。
+          if (finalText.trim()) {
+            updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+              ...message,
+              content: finalText,
+              reasoning_content: accumulatedReasoningContent,
+              isAgentRunning: false,
+            }));
+            void useOutlineChatStore.getState().saveToDisk();
+          }
+          return { started: true, sent: false };
+        }
         if (contextHubResult && providerUsage) {
           try {
             const contextHubSnapshot = await persistContextHubProviderUsage(
@@ -2533,13 +2538,15 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             },
           },
         );
-        if (!isCurrentRun()) return { started: true, sent: false };
-        // 先将流式内容同步为最终内容,确保打字机效果立即终止、切换无跳变
-        setStreamingContent(capturedConvId, finalContent);
+        if (finalContent.trim()) bestGeneratedText = finalContent;
+        // 内容已直接写入消息,这里只需清掉运行状态提示
+        if (isCurrentRun()) clearStreamingContent(capturedConvId);
         const visibleToolCalls = allToolCalls.length ? allToolCalls : [];
         const shouldShowToolProcess =
           historyPlan.showToolProcess ||
           visibleToolCalls.some((call) => call.status === "approval_required");
+        // 最终内容提交不受 run 状态闸门限制:即使运行状态已被切换/停止,
+        // 已生成的结果也必须写入消息,只有后续 UI 副作用才需要闸门。
         updateOutlineAssistantMessage(convId, assistantId, (message) => ({
           ...message,
           content: finalContent,
@@ -2554,6 +2561,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           isAgentRunning: false,
           nextStepRecommendation: nextStepExtraction.recommendation,
         }));
+        if (!isCurrentRun()) {
+          void useOutlineChatStore.getState().saveToDisk();
+          return { started: true, sent: false };
+        }
 
         // 解析意图清晰度结果
         const intentResult = parseIntentClarity(finalContent);
@@ -2646,7 +2657,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           }));
         }
         void useOutlineChatStore.getState().saveToDisk();
-        if (isCurrentRun()) clearStreamingContent(capturedConvId);
         setCapturedWorkflowStage("idle");
         finishConversationRun(
           capturedConvId,
@@ -2666,52 +2676,46 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       } catch (err) {
         const errorMsg = err instanceof Error ? err.message : String(err);
         const aborted = controller.signal.aborted || errorMsg.toLowerCase().includes("aborted");
-        if (aborted || !isCurrentRun()) return { started: true, sent: false };
-        const partial = useOutlineChatStore.getState().getStreamingContent(capturedConvId);
-        if (partial) {
-          updateOutlineAssistantMessage(convId, assistantId, (message) => ({
-            ...message,
-            content: partial,
-            reasoning_content: accumulatedReasoningContent,
-            agentToolCalls: historyPlan.showToolProcessOnError
-              ? settleRunningAgentToolCalls(
-                  message.agentToolCalls?.length ? message.agentToolCalls : hiddenToolCalls,
-                  "error",
-                  Date.now(),
-                   errorMsg,
-                )
-              : [],
-            isAgentRunning: false,
-          }));
-        } else {
-          if (errorMsg && !aborted) {
-            updateOutlineAssistantMessage(convId, assistantId, (message) => ({
-              ...message,
-              content: `生成失败:${errorMsg}`,
-              reasoning_content: accumulatedReasoningContent,
-              agentToolCalls: historyPlan.showToolProcessOnError
-                ? settleRunningAgentToolCalls(
-                    message.agentToolCalls?.length ? message.agentToolCalls : hiddenToolCalls,
-                    "error",
-                    Date.now(),
-                    errorMsg,
-                  )
-                : [],
-              isAgentRunning: false,
-            }));
-          } else if (isCurrentRun()) {
-            removeOutlineMessage(capturedConvId, assistantId);
-          }
-        }
-        if (isCurrentRun()) clearStreamingContent(capturedConvId);
+        // streamingContents 只承载状态提示,不再存内容;可保留内容唯一来源
+        // 是 bestGeneratedText。无论中断原因如何,已生成的内容都必须落进
+        // 消息,绝不静默删除整条回复。
+        const partial = bestGeneratedText.trim() ? bestGeneratedText : "";
+        const reasoningOnlyFailure =
+          err instanceof Error && isReasoningOnlyResponseError(err) && Boolean(accumulatedReasoningContent.trim());
+        updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+          ...message,
+          content: partial
+            ? aborted
+              ? `${partial}\n\n---\n\n⚠️ 生成已停止,以上为已生成的内容。`
+              : `${partial}\n\n---\n\n⚠️ 生成中断:${errorMsg || "未知错误"}`
+            : aborted
+              ? message.content || "已停止生成。"
+              : `生成失败:${errorMsg || "未知错误"}`,
+          reasoning_content: accumulatedReasoningContent,
+          // 模型只输出思考没输出正文时,强制展示思考过程,
+          // 让用户明白"看着生成完了却没有结果"的原因。
+          showThinkingProcess: reasoningOnlyFailure ? true : message.showThinkingProcess,
+          agentToolCalls: historyPlan.showToolProcessOnError
+            ? settleRunningAgentToolCalls(
+                message.agentToolCalls?.length ? message.agentToolCalls : hiddenToolCalls,
+                aborted ? "cancelled" : "error",
+                Date.now(),
+                aborted ? undefined : errorMsg,
+              )
+            : [],
+          isAgentRunning: false,
+        }));
         if (isCurrentRun()) {
+          clearStreamingContent(capturedConvId);
           setCapturedWorkflowStage("idle");
           failConversationRun(capturedConvId, errorMsg || "未知错误", runId);
-          toast.error(errorMsg || "未知错误", {
-            title: "大纲生成失败",
-            persistent: true,
-            dedupeKey: `outline-run:${capturedConvId}:${errorMsg}`,
-          });
+          if (!aborted) {
+            toast.error(errorMsg || "未知错误", {
+              title: "大纲生成失败",
+              persistent: true,
+              dedupeKey: `outline-run:${capturedConvId}:${errorMsg}`,
+            });
+          }
         }
         void useOutlineChatStore.getState().saveToDisk();
         return { started: true, sent: false };
@@ -2733,7 +2737,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       forceRefreshNext,
       addMessage,
       setConversationContextSummary,
-      replaceLastAssistant,
       handleAutoSaveOutlineRequests,
       outlineWritingSkills,
       setStreamingContent,
@@ -3019,7 +3022,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               onText: (chunk) => {
                 mergeText += chunk;
                 if (isCurrentRun()) {
-                  setStreamingContent(capturedConvId, mergeText);
                   updateOutlineAssistantMessage(capturedConvId, messageId, (message) => ({
                     ...message,
                     content: mergeText,
@@ -3162,28 +3164,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     const runningState = useOutlineChatStore.getState().runStates[activeConversationId];
     if (runningState?.status !== "running" || !runningState.runId) return;
     outlineConversationRunRegistry.abort(activeConversationId);
-    const partial = useOutlineChatStore.getState().getStreamingContent(activeConversationId);
-    const conversation = useOutlineChatStore.getState().conversations
-      .find((item) => item.id === activeConversationId);
-    const lastMessage = conversation?.messages[conversation.messages.length - 1];
-    if (partial) {
-      if (lastMessage?.role === "assistant") {
-        updateOutlineAssistantMessage(activeConversationId, lastMessage.id, (message) => ({
-          ...message,
-          content: partial,
-          isAgentRunning: false,
-        }));
-      }
-    } else {
-      if (lastMessage?.role === "assistant" && lastMessage.isAgentRunning) {
-        removeOutlineMessage(activeConversationId, lastMessage.id);
-      }
-    }
+    // 内容只存在于消息里(onText 直接写消息),streamingContents 仅承载
+    // 状态提示文本。这里只需中止运行并清理状态;已生成内容由 handleSend /
+    // handleRegenerate 的 catch 分支在 abort 传播后统一收尾落盘。
     clearStreamingContent(activeConversationId);
     stopConversationRun(activeConversationId, runningState.runId);
   }, [
     activeConversationId,
-    replaceLastAssistant,
     clearStreamingContent,
     stopConversationRun,
   ]);
@@ -3250,14 +3237,16 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         ),
       }));
 
-      setStreamingContent(capturedConvId, "");
+      clearStreamingContent(capturedConvId);
       userScrolledUpRef.current = false;
+      const assistantId = crypto.randomUUID();
+      let assistantAdded = false;
+      let accumulatedReasoningContent = "";
 
       try {
         const regenerationInput = buildOutlineRegenerationInput(targetMessages);
         const lastUserRequest = regenerationInput.request;
         const historyMessages = regenerationInput.history satisfies AgentMessage[];
-        const assistantId = crypto.randomUUID();
         let contextHubSnapshot: ContextHubSnapshotRef | undefined;
         let contextHubResult: ContextHubResult | null = null;
         try {
@@ -3294,6 +3283,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           isAgentRunning: true,
           contextHubSnapshot,
         });
+        assistantAdded = true;
 
         const skillConfig = await loadDeAiSkillConfig(project.path).catch(
           (): DeAiSkillConfig | null => null,
@@ -3355,7 +3345,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           userMemorySessionKey: capturedConvId,
         };
         let agentError: Error | null = null;
-        let accumulatedReasoningContent = "";
         const record = await new AgentRunner().run(
           agentConfig,
           registry,
@@ -3368,7 +3357,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             onText: (chunk) => {
               result += chunk;
               if (isCurrentRun()) {
-                setStreamingContent(capturedConvId, result);
                 updateOutlineAssistantMessage(
                   capturedConvId,
                   assistantId,
@@ -3462,8 +3450,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           }),
         });
         if (!isCurrentRun()) return;
-        // 先将流式内容同步为最终内容,确保打字机效果立即终止、切换无跳变
-        setStreamingContent(capturedConvId, finalContent);
         updateOutlineAssistantMessage(
           capturedConvId,
           assistantId,
@@ -3488,7 +3474,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         if (!isCurrentRun()) return;
         await handleAutoSaveOutlineRequests(capturedConvId, finalContent, isCurrentRun);
         if (!isCurrentRun()) return;
-        if (isCurrentRun()) clearStreamingContent(capturedConvId);
+        clearStreamingContent(capturedConvId);
         finishConversationRun(
           capturedConvId,
           useOutlineChatStore.getState().activeConversationId,
@@ -3498,18 +3484,39 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       } catch (err) {
         const errorMsg = err instanceof Error ? err.message : String(err);
         const aborted = controller.signal.aborted || errorMsg.toLowerCase().includes("aborted");
-        if (aborted || !isCurrentRun()) return;
-        const partial = useOutlineChatStore.getState().getStreamingContent(capturedConvId);
-        if (partial) {
-          replaceLastAssistant(capturedConvId, partial);
-        } else {
-          if (errorMsg && !aborted) {
-            replaceLastAssistant(capturedConvId, `生成失败:${errorMsg}`);
-          }
+        // 内容唯一存在于消息里(onText 直接写入)。这里只负责收尾:
+        // 保留已生成内容、补错误/停止占位、结束消息运行态,绝不删除内容。
+        if (assistantAdded) {
+          updateOutlineAssistantMessage(capturedConvId, assistantId, (message) => ({
+            ...message,
+            content: message.content.trim()
+              ? aborted
+                ? `${message.content}\n\n---\n\n⚠️ 生成已停止,以上为已生成的内容。`
+                : `${message.content}\n\n---\n\n⚠️ 生成中断:${errorMsg || "未知错误"}`
+              : aborted
+                ? "已停止生成。"
+                : `生成失败:${errorMsg || "未知错误"}`,
+            reasoning_content: accumulatedReasoningContent,
+            agentToolCalls: settleRunningAgentToolCalls(
+              message.agentToolCalls,
+              aborted ? "cancelled" : "error",
+              Date.now(),
+              aborted ? undefined : errorMsg,
+            ),
+            isAgentRunning: false,
+          }));
+        } else if (!aborted && isCurrentRun()) {
+          addMessage(capturedConvId, {
+            id: assistantId,
+            role: "assistant",
+            content: `生成失败:${errorMsg || "未知错误"}`,
+          });
         }
-        if (isCurrentRun()) clearStreamingContent(capturedConvId);
-        if (isCurrentRun()) {
-          failConversationRun(capturedConvId, errorMsg || "未知错误", runId);
+        void useOutlineChatStore.getState().saveToDisk();
+        if (!isCurrentRun()) return;
+        clearStreamingContent(capturedConvId);
+        failConversationRun(capturedConvId, errorMsg || "未知错误", runId);
+        if (!aborted) {
           toast.error(errorMsg || "未知错误", {
             title: "大纲生成失败",
             persistent: true,
@@ -3530,10 +3537,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       activeConv,
       activeConversationId,
       addMessage,
-      replaceLastAssistant,
       handleAutoSaveOutlineRequests,
       outlineWritingSkills,
-      setStreamingContent,
       clearStreamingContent,
       startConversationRun,
       finishConversationRun,
@@ -3939,7 +3944,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                   msg={msg}
                   index={i}
                   isStreaming={isStreaming}
-                  streamingContent={streamingContent}
+                  runStatusText={streamingContent}
                   activeMessagesLength={activeMessages.length}
                   copied={copied}
                   projectPath={project?.path ?? null}

+ 2 - 2
src/lib/agent/plan-execute-policy.ts

@@ -48,7 +48,7 @@ export function buildPlanExecutePolicyPrompt(mode: LegacyAiWorkflowMode): string
       "计划必须简短,最多 5 条,只写将要读取和执行的关键步骤。",
       "执行后审查结果是否满足用户请求、项目设定和输出边界。",
       executablePlanFormat,
-      "如果是章节生成、续写、改写或润色,必须调用 run_chapter_workflow;未调用前禁止输出章节终稿。",
+      "本轮是计划阶段:禁止输出章节正文,禁止调用 run_chapter_workflow。用户确认计划后才进入执行阶段,届时章节生成、续写、改写或润色必须调用 run_chapter_workflow。",
     ].join("\n")
   }
 
@@ -57,6 +57,6 @@ export function buildPlanExecutePolicyPrompt(mode: LegacyAiWorkflowMode): string
     "标准模式:先创建轻量计划,再快速执行。",
     "计划最多 3 条,不能替代正文,不能把计划混入最终章节正文。",
     executablePlanFormat,
-    "如果是章节生成、续写、改写或润色,必须调用 run_chapter_workflow;未调用前禁止输出章节终稿。",
+    "本轮是计划阶段:禁止输出章节正文,禁止调用 run_chapter_workflow。用户确认计划后才进入执行阶段,届时章节生成、续写、改写或润色必须调用 run_chapter_workflow。",
   ].join("\n")
 }

+ 2 - 0
src/lib/agent/runner.spec.ts

@@ -46,6 +46,8 @@ const mockLlmConfig: LlmConfig = {
 const mockStreamChat = vi.fn()
 vi.mock("../llm-client", () => ({
   streamChat: (...args: unknown[]) => mockStreamChat(...args),
+  isOutputTruncatedError: (error: unknown) =>
+    error instanceof Error && error.message.includes("输出被截断"),
 }))
 
 describe("AgentRunner", () => {

+ 13 - 1
src/lib/agent/runner.ts

@@ -1,4 +1,4 @@
-import { streamChat } from "../llm-client"
+import { isOutputTruncatedError, streamChat } from "../llm-client"
 import type { StreamCallbacks } from "../llm-client"
 import { isFunctionCallingEnabled, providerUsesTextToolCalls } from "./config"
 import { accumulateToolCalls, parseTextToolCalls } from "./tool-call-parser"
@@ -239,6 +239,18 @@ export class AgentRunner {
         if (attemptedToolsFallback) {
           return failToolsUnsupported()
         }
+        // Token-limit truncation: keep the partial round text so callers
+        // can show it and offer continuation, instead of dropping the
+        // whole round on the floor.
+        if (
+          isOutputTruncatedError(streamError) &&
+          toolCallDeltas.length === 0 &&
+          roundText.trim()
+        ) {
+          finalText = roundText
+          record.finalText = finalText
+          callbacks.onText(roundText)
+        }
         callbacks.onError(streamError)
         return record
       }

+ 18 - 0
src/lib/chat-copy-content.test.ts

@@ -190,3 +190,21 @@ test("keeps short assistant content when workflow body is unavailable", () => {
     "第 32 章正文已按章纲重写完成。",
   )
 })
+
+test("preserves chapter_plan markers while stripping other hidden comments", () => {
+  const content = [
+    '<!-- next_step {"module":"章纲"} -->',
+    "<!-- chapter_plan -->",
+    "### 1. 本章目标",
+    "- 推进主线,完成反转。",
+    "<!-- /chapter_plan -->",
+    "计划已生成,请确认。",
+  ].join("\n")
+
+  const copied = getCopyableAssistantContent(content)
+
+  expect(copied).toContain("<!-- chapter_plan -->")
+  expect(copied).toContain("<!-- /chapter_plan -->")
+  expect(copied).toContain("本章目标")
+  expect(copied).not.toContain("next_step")
+})

+ 4 - 1
src/lib/chat-copy-content.ts

@@ -21,8 +21,11 @@ export type CopyableToolCall = {
 }
 
 function stripHiddenAssistantBlocks(content: string): string {
+  // 剥掉隐藏的 HTML 注释,但保留 chapter_plan 标记:计划模式靠
+  // `<!-- chapter_plan -->` / `<!-- /chapter_plan -->` 从最终消息中提取
+  // 计划并弹确认窗,这里剥掉会导致确认弹窗永远走不到标记路径。
   let result = content
-    .replace(/<!--.*?-->/gs, "")
+    .replace(/<!--(?!\s*\/?\s*chapter_plan\s*-->).*?-->/gs, "")
 
   // 1. 移除完整的 <think>...</think> 或 <thinking>...</thinking> 块
   result = result.replace(/<think(?:ing)?>\s*[\s\S]*?<\/think(?:ing)?>\s*/gi, "")

+ 41 - 1
src/lib/llm-client.ts

@@ -1,6 +1,11 @@
 import type { LlmConfig } from "@/stores/wiki-store"
 import { isAzureOpenAiEndpoint } from "@/lib/azure-openai"
-import { getEffectiveMaxContextSize, getProviderConfig, type RequestOverrides } from "./llm-providers"
+import {
+  getEffectiveMaxContextSize,
+  getProviderConfig,
+  isTruncationFinishReason,
+  type RequestOverrides,
+} from "./llm-providers"
 import { getHttpFetch, isFetchNetworkError } from "./tauri-fetch"
 import { countReasoningCharsInLine, extractReasoningTextFromLine } from "./reasoning-detector"
 import {
@@ -56,6 +61,24 @@ async function streamViaCodexCli(
 const NETWORK_RETRY_DELAYS_MS = [30_000, 60_000, 90_000, 120_000]
 export const DEFAULT_LLM_REQUEST_TIMEOUT_MS = 30 * 60 * 1000
 
+/**
+ * Stable marker for token-limit truncation errors. Callers that want to
+ * tolerate truncation (keep the partial text and offer "继续") match on
+ * this prefix — see outline-chat-panel's isLengthTruncated check.
+ */
+export const OUTPUT_TRUNCATED_ERROR_MARKER = "输出被截断"
+
+export function buildOutputTruncatedError(finishReason: string): Error {
+  return new Error(
+    `${OUTPUT_TRUNCATED_ERROR_MARKER}:模型已达到最大输出 token 上限(finish_reason=${finishReason})。` +
+    `已生成的内容已保留,可输入"继续"让模型补全剩余部分,或提高最大输出 token 后重试。`,
+  )
+}
+
+export function isOutputTruncatedError(error: unknown): boolean {
+  return error instanceof Error && error.message.includes(OUTPUT_TRUNCATED_ERROR_MARKER)
+}
+
 export function shouldRetryWithBrowserFetch(errorDetail: string): boolean {
   return /client not allowed/i.test(errorDetail) && /tauri-plugin-http/i.test(errorDetail)
 }
@@ -399,10 +422,15 @@ export async function streamChat(
     let reasoningCharsObserved = 0
     let reasoningTokensForwarded = 0
     let toolCallDeltaCount = 0
+    let finishReason: string | null = null
     const recordToken = (text: string) => {
       contentCharsEmitted += text.length
       onToken(text)
     }
+    const recordFinishReason = (line: string) => {
+      const reason = providerConfig.parseFinishReason(line)
+      if (reason) finishReason = reason
+    }
     const recordReasoning = (line: string) => {
       const reasoningParts = extractReasoningTextFromLine(line)
       for (const part of reasoningParts) {
@@ -419,6 +447,7 @@ export async function streamChat(
           if (lineBuffer.trim()) {
             const trimmed = lineBuffer.trim()
             recordUsage(trimmed)
+            recordFinishReason(trimmed)
             // Always harvest reasoning first: some gateways emit
             // reasoning_content and tool_calls on the same SSE line.
             reasoningCharsObserved += countReasoningCharsInLine(trimmed)
@@ -442,6 +471,7 @@ export async function streamChat(
           const trimmed = line.trim()
           if (!trimmed) continue
           recordUsage(trimmed)
+          recordFinishReason(trimmed)
           // Always harvest reasoning first: some gateways emit
           // reasoning_content and tool_calls on the same SSE line.
           reasoningCharsObserved += countReasoningCharsInLine(trimmed)
@@ -490,6 +520,16 @@ export async function streamChat(
         return
       }
 
+      // The provider explicitly told us the output was cut at the token
+      // limit. Surface it as a distinct, tolerable error so callers can
+      // keep the partial text and offer continuation, instead of parsing
+      // a silently half-finished response.
+      const finalFinishReason: string | null = finishReason
+      if (finalFinishReason && isTruncationFinishReason(finalFinishReason)) {
+        onError(buildOutputTruncatedError(finalFinishReason))
+        return
+      }
+
       onDone()
     } catch (err) {
       if (err instanceof Error && (err.name === "AbortError" || (signal?.aborted))) {

+ 148 - 0
src/lib/llm-finish-reason.test.ts

@@ -0,0 +1,148 @@
+import { expect, test, vi } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+import {
+  isTruncationFinishReason,
+  parseAnthropicFinishReason,
+  parseGoogleFinishReason,
+  parseOpenAiFinishReason,
+  parseResponsesFinishReason,
+} from "./llm-providers"
+
+vi.mock("./tauri-fetch", () => ({
+  getHttpFetch: async () => mockFetch,
+  isFetchNetworkError: () => false,
+}))
+
+let mockFetch: (url: string, init?: RequestInit) => Promise<Response> = async () => {
+  throw new Error("mockFetch not configured")
+}
+
+function sseResponse(lines: string[]): Response {
+  return new Response(lines.map((line) => `${line}\n`).join(""), {
+    status: 200,
+    headers: { "Content-Type": "text/event-stream" },
+  })
+}
+
+function buildConfig(): LlmConfig {
+  return {
+    provider: "custom",
+    apiKey: "test-key",
+    model: "test-model",
+    ollamaUrl: "",
+    customEndpoint: "https://example.com/v1",
+    maxContextSize: 100_000,
+  } as LlmConfig
+}
+
+test("parseOpenAiFinishReason reads choices[0].finish_reason", () => {
+  expect(
+    parseOpenAiFinishReason('data: {"choices":[{"delta":{},"finish_reason":"length"}]}'),
+  ).toBe("length")
+  expect(
+    parseOpenAiFinishReason('data: {"choices":[{"delta":{"content":"x"},"finish_reason":null}]}'),
+  ).toBe(null)
+  expect(parseOpenAiFinishReason("data: [DONE]")).toBe(null)
+  expect(parseOpenAiFinishReason("event: ping")).toBe(null)
+})
+
+test("parseAnthropicFinishReason reads message_delta stop_reason", () => {
+  expect(
+    parseAnthropicFinishReason('data: {"type":"message_delta","delta":{"stop_reason":"max_tokens"}}'),
+  ).toBe("max_tokens")
+  expect(
+    parseAnthropicFinishReason('data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"x"}}'),
+  ).toBe(null)
+})
+
+test("parseGoogleFinishReason reads candidates[0].finishReason", () => {
+  expect(
+    parseGoogleFinishReason('data: {"candidates":[{"content":{"parts":[{"text":"x"}]},"finishReason":"MAX_TOKENS"}]}'),
+  ).toBe("MAX_TOKENS")
+  expect(
+    parseGoogleFinishReason('data: {"candidates":[{"content":{"parts":[{"text":"x"}]}}]}'),
+  ).toBe(null)
+})
+
+test("parseResponsesFinishReason reads incomplete_details.reason", () => {
+  expect(
+    parseResponsesFinishReason('data: {"type":"response.incomplete","response":{"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}}'),
+  ).toBe("max_output_tokens")
+  expect(
+    parseResponsesFinishReason('data: {"type":"response.output_text.delta","delta":"x"}'),
+  ).toBe(null)
+})
+
+test("isTruncationFinishReason only matches token-limit reasons", () => {
+  expect(isTruncationFinishReason("length")).toBe(true)
+  expect(isTruncationFinishReason("max_tokens")).toBe(true)
+  expect(isTruncationFinishReason("MAX_TOKENS")).toBe(true)
+  expect(isTruncationFinishReason("max_output_tokens")).toBe(true)
+  expect(isTruncationFinishReason("stop")).toBe(false)
+  expect(isTruncationFinishReason("end_turn")).toBe(false)
+  expect(isTruncationFinishReason(null)).toBe(false)
+  expect(isTruncationFinishReason(undefined)).toBe(false)
+})
+
+test("streamChat surfaces finish_reason=length as a tolerable truncation error", async () => {
+  const { streamChat, isOutputTruncatedError } = await import("./llm-client")
+  mockFetch = async () => sseResponse([
+    'data: {"choices":[{"delta":{"content":"第一段"},"finish_reason":null}]}',
+    "",
+    'data: {"choices":[{"delta":{"content":"第二段"},"finish_reason":"length"}]}',
+    "",
+    "data: [DONE]",
+  ])
+
+  const tokens: string[] = []
+  let doneCalled = false
+  let error: Error | null = null
+  await streamChat(
+    buildConfig(),
+    [{ role: "user", content: "写一章" }],
+    {
+      onToken: (token) => tokens.push(token),
+      onDone: () => { doneCalled = true },
+      onError: (err) => { error = err },
+    },
+    undefined,
+    { skipUserMemory: true },
+  )
+
+  expect(tokens.join("")).toBe("第一段第二段")
+  expect(doneCalled).toBe(false)
+  expect(error).not.toBeNull()
+  expect(error!.message).toContain("输出被截断")
+  expect(error!.message).toContain("最大输出 token")
+  expect(isOutputTruncatedError(error)).toBe(true)
+})
+
+test("streamChat with finish_reason=stop ends normally", async () => {
+  const { streamChat } = await import("./llm-client")
+  mockFetch = async () => sseResponse([
+    'data: {"choices":[{"delta":{"content":"完整回答"},"finish_reason":null}]}',
+    "",
+    'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}',
+    "",
+    "data: [DONE]",
+  ])
+
+  const tokens: string[] = []
+  let doneCalled = false
+  let error: Error | null = null
+  await streamChat(
+    buildConfig(),
+    [{ role: "user", content: "问个问题" }],
+    {
+      onToken: (token) => tokens.push(token),
+      onDone: () => { doneCalled = true },
+      onError: (err) => { error = err },
+    },
+    undefined,
+    { skipUserMemory: true },
+  )
+
+  expect(tokens.join("")).toBe("完整回答")
+  expect(doneCalled).toBe(true)
+  expect(error).toBeNull()
+})

+ 89 - 0
src/lib/llm-providers.ts

@@ -96,6 +96,25 @@ interface ProviderConfig {
   buildBody: (messages: ChatMessage[], overrides?: RequestOverrides) => unknown
   parseStream: (line: string) => string | null
   parseUsage: (line: string) => LlmUsage | null
+  /**
+   * Extract the provider's finish/stop reason from an SSE line, so the
+   * stream-end path can distinguish a natural stop from a token-limit
+   * truncation ("length" / "max_tokens" / "MAX_TOKENS" / ...).
+   */
+  parseFinishReason: (line: string) => string | null
+}
+
+/**
+ * Finish/stop reasons that mean the provider cut the output because it
+ * hit a token limit, per wire: OpenAI "length", Anthropic "max_tokens",
+ * Gemini "MAX_TOKENS", Responses "max_output_tokens".
+ */
+export function isTruncationFinishReason(reason: string | null | undefined): boolean {
+  if (!reason) return false
+  const normalized = reason.toLowerCase()
+  return normalized === "length"
+    || normalized === "max_tokens"
+    || normalized === "max_output_tokens"
 }
 
 const JSON_CONTENT_TYPE = "application/json"
@@ -246,6 +265,66 @@ function parseOpenAiUsage(line: string): LlmUsage | null {
   }
 }
 
+export function parseOpenAiFinishReason(line: string): string | null {
+  if (!line.startsWith("data: ")) return null
+  const data = line.slice(6).trim()
+  if (data === "[DONE]") return null
+  try {
+    const parsed = JSON.parse(data) as {
+      choices?: Array<{ finish_reason?: string | null }>
+    }
+    return parsed.choices?.[0]?.finish_reason ?? null
+  } catch {
+    return null
+  }
+}
+
+export function parseAnthropicFinishReason(line: string): string | null {
+  if (!line.startsWith("data: ")) return null
+  const data = line.slice(6).trim()
+  try {
+    const parsed = JSON.parse(data) as {
+      delta?: { stop_reason?: string | null }
+      message?: { stop_reason?: string | null }
+    }
+    // message_delta events carry delta.stop_reason ("max_tokens" on
+    // truncation); some proxies put it on message.stop_reason instead.
+    return parsed.delta?.stop_reason ?? parsed.message?.stop_reason ?? null
+  } catch {
+    return null
+  }
+}
+
+export function parseGoogleFinishReason(line: string): string | null {
+  if (!line.startsWith("data: ")) return null
+  const data = line.slice(6).trim()
+  try {
+    const parsed = JSON.parse(data) as {
+      candidates?: Array<{ finishReason?: string | null }>
+    }
+    return parsed.candidates?.[0]?.finishReason ?? null
+  } catch {
+    return null
+  }
+}
+
+export function parseResponsesFinishReason(line: string): string | null {
+  if (!line.startsWith("data: ")) return null
+  const data = line.slice(6).trim()
+  if (data === "[DONE]") return null
+  try {
+    const parsed = JSON.parse(data) as {
+      response?: {
+        status?: string
+        incomplete_details?: { reason?: string | null }
+      }
+    }
+    return parsed.response?.incomplete_details?.reason ?? null
+  } catch {
+    return null
+  }
+}
+
 function parseResponsesLine(line: string): string | null {
   if (!line.startsWith("data: ")) return null
   const data = line.slice(6).trim()
@@ -938,6 +1017,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
         }),
         parseStream: parseOpenAiLine,
         parseUsage: parseOpenAiUsage,
+        parseFinishReason: parseOpenAiFinishReason,
       }
 
     case "anthropic": {
@@ -951,6 +1031,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
         }),
         parseStream: parseAnthropicLine,
         parseUsage: parseAnthropicUsage,
+        parseFinishReason: parseAnthropicFinishReason,
       }
     }
 
@@ -972,6 +1053,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
         }),
         parseStream: parseGoogleLine,
         parseUsage: parseGoogleUsage,
+        parseFinishReason: parseGoogleFinishReason,
       }
     }
 
@@ -990,6 +1072,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
           buildOpenAiCompatibleBody(config, messages, overrides),
         parseStream: parseOpenAiLine,
         parseUsage: parseOpenAiUsage,
+        parseFinishReason: parseOpenAiFinishReason,
       }
     }
 
@@ -1016,6 +1099,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
         }),
         parseStream: parseOpenAiLine,
         parseUsage: parseOpenAiUsage,
+        parseFinishReason: parseOpenAiFinishReason,
       }
     }
 
@@ -1035,6 +1119,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
         }),
         parseStream: parseAnthropicLine,
         parseUsage: parseAnthropicUsage,
+        parseFinishReason: parseAnthropicFinishReason,
       }
     }
 
@@ -1068,6 +1153,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
         }),
         parseStream: parseOpenAiLine,
         parseUsage: parseOpenAiUsage,
+        parseFinishReason: parseOpenAiFinishReason,
       }
     }
 
@@ -1088,6 +1174,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
           }),
           parseStream: parseAnthropicLine,
           parseUsage: parseAnthropicUsage,
+          parseFinishReason: parseAnthropicFinishReason,
         }
       }
       if (mode === "responses") {
@@ -1101,6 +1188,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
           buildBody: (messages, overrides) => buildResponsesBody(config, messages, overrides),
           parseStream: parseResponsesLine,
           parseUsage: parseResponsesUsage,
+          parseFinishReason: parseResponsesFinishReason,
         }
       }
       // Defense-in-depth: settings-side EndpointField normalizes URLs on
@@ -1136,6 +1224,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
         },
         parseStream: parseOpenAiLine,
         parseUsage: parseOpenAiUsage,
+        parseFinishReason: parseOpenAiFinishReason,
       }
     }
 

+ 27 - 5
src/lib/novel/chapter-ingest.ts

@@ -1395,7 +1395,7 @@ async function syncForeshadowingChanges(projectPath: string, snapshot: ChapterSn
   await saveForeshadowingTracker(projectPath, existingForeshadows)
 }
 
-async function rebuildDerivedMemoryFromSnapshots(projectPath: string, latestSnapshot?: ChapterSnapshot): Promise<void> {
+export async function rebuildDerivedMemoryFromSnapshots(projectPath: string, latestSnapshot?: ChapterSnapshot): Promise<void> {
   const snapshots = await loadValidMemorySnapshots(projectPath, latestSnapshot)
 
   const cognitionState = snapshots.reduce(
@@ -1582,14 +1582,36 @@ export async function listSnapshots(projectPath: string): Promise<number[]> {
   }
 }
 
-export async function deleteChapterSnapshots(projectPath: string, chapterNumber: number): Promise<void> {
+export async function deleteChapterSnapshotArtifacts(projectPath: string, chapterNumber: number): Promise<boolean> {
   const pp = normalizePath(projectPath)
   const jsonPath = snapshotJsonPath(pp, chapterNumber)
   const mdPath = snapshotMarkdownPath(pp, chapterNumber)
   const historyDir = snapshotHistoryDir(pp, chapterNumber)
-  try { if (await fileExists(jsonPath)) await deleteFile(jsonPath) } catch { /* ignore */ }
-  try { if (await fileExists(mdPath)) await deleteFile(mdPath) } catch { /* ignore */ }
-  try { if (await fileExists(historyDir)) await deleteFile(historyDir) } catch { /* ignore */ }
+  let deleted = false
+  try {
+    if (await fileExists(jsonPath)) {
+      await deleteFile(jsonPath)
+      deleted = true
+    }
+  } catch { /* ignore */ }
+  try {
+    if (await fileExists(mdPath)) {
+      await deleteFile(mdPath)
+      deleted = true
+    }
+  } catch { /* ignore */ }
+  try {
+    if (await fileExists(historyDir)) {
+      await deleteFile(historyDir)
+      deleted = true
+    }
+  } catch { /* ignore */ }
+  return deleted
+}
+
+export async function deleteChapterSnapshots(projectPath: string, chapterNumber: number): Promise<void> {
+  const pp = normalizePath(projectPath)
+  await deleteChapterSnapshotArtifacts(pp, chapterNumber)
   await rebuildDerivedMemoryFromSnapshots(pp)
   clearGraphCache()
   useWikiStore.getState().bumpDataVersion()

+ 223 - 57
src/lib/novel/delete-source-memory.spec.ts

@@ -2,111 +2,277 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
 
 const fsMocks = vi.hoisted(() => ({
   deleteFile: vi.fn(),
+  fileExists: vi.fn(),
   listDirectory: vi.fn(),
   readFile: vi.fn(),
   writeFileAtomic: vi.fn(),
 }))
 
+const ingestMocks = vi.hoisted(() => ({
+  deleteChapterSnapshotArtifacts: vi.fn(),
+  deleteChapterSnapshots: vi.fn(),
+  rebuildDerivedMemoryFromSnapshots: vi.fn(),
+}))
+
+const graphMocks = vi.hoisted(() => ({
+  clearGraphCache: vi.fn(),
+}))
+
+const storeMocks = vi.hoisted(() => ({
+  bumpDataVersion: vi.fn(),
+}))
+
 vi.mock("@/commands/fs", () => ({
   deleteFile: fsMocks.deleteFile,
+  fileExists: fsMocks.fileExists,
   listDirectory: fsMocks.listDirectory,
   readFile: fsMocks.readFile,
   writeFileAtomic: fsMocks.writeFileAtomic,
 }))
 
+vi.mock("@/lib/novel/chapter-ingest", () => ingestMocks)
+vi.mock("@/lib/graph-relevance", () => graphMocks)
+vi.mock("@/stores/wiki-store", () => ({
+  useWikiStore: {
+    getState: () => storeMocks,
+  },
+}))
+
 import {
   deleteNovelSourceMemory,
+  flushDeletedChapterMemoryCleanup,
   getOutlineSnapshotNumberFromPath,
+  shouldCleanupDeletedChapterMemory,
 } from "./delete-source-memory"
-import { deleteChapterSnapshots } from "@/lib/novel/chapter-ingest"
 
-vi.mock("@/lib/novel/chapter-ingest", () => ({
-  deleteChapterSnapshots: vi.fn(),
-}))
+function chapterContent(status: string | null, chapterNumber = 12): string {
+  return [
+    "---",
+    "type: chapter",
+    `chapter_number: ${chapterNumber}`,
+    ...(status === null ? [] : [`chapter_status: ${status}`]),
+    "---",
+    `# 第${chapterNumber}章`,
+  ].join("\n")
+}
+
+function entityContent(sources: string[]): string {
+  return [
+    "---",
+    "type: entity",
+    `sources: [${sources.map((source) => `"${source}"`).join(", ")}]`,
+    'source_type: "chapter"',
+    "---",
+    "# 主角",
+  ].join("\n")
+}
 
 describe("deleteNovelSourceMemory", () => {
-  beforeEach(() => {
+  beforeEach(async () => {
+    await flushDeletedChapterMemoryCleanup()
     vi.clearAllMocks()
+    fsMocks.listDirectory.mockResolvedValue([])
+    fsMocks.fileExists.mockResolvedValue(false)
+    fsMocks.readFile.mockResolvedValue("")
+    ingestMocks.deleteChapterSnapshotArtifacts.mockResolvedValue(true)
+    ingestMocks.rebuildDerivedMemoryFromSnapshots.mockResolvedValue(undefined)
+    ingestMocks.deleteChapterSnapshots.mockResolvedValue(undefined)
   })
 
-  it("deletes chapter snapshots by chapter_number before the page disappears", async () => {
+  it.each(["outline", "draft", "revised", "archived"])(
+    "does not clean memory for non-final chapter status %s",
+    async (status) => {
+      await deleteNovelSourceMemory("/project", {
+        kind: "chapter",
+        pagePath: "/project/wiki/chapters/chapter-012.md",
+        content: chapterContent(status),
+      })
+      await flushDeletedChapterMemoryCleanup()
+
+      expect(ingestMocks.deleteChapterSnapshotArtifacts).not.toHaveBeenCalled()
+      expect(ingestMocks.rebuildDerivedMemoryFromSnapshots).not.toHaveBeenCalled()
+      expect(fsMocks.listDirectory).not.toHaveBeenCalled()
+    },
+  )
+
+  it.each([null, "unknown"])(
+    "treats missing or invalid status %s as non-final",
+    async (status) => {
+      const content = chapterContent(status)
+      expect(shouldCleanupDeletedChapterMemory(content)).toBe(false)
+    },
+  )
+
+  it("queues final chapter cleanup and returns before the background batch runs", async () => {
     await deleteNovelSourceMemory("/project", {
       kind: "chapter",
       pagePath: "/project/wiki/chapters/chapter-012.md",
-      content: "---\nchapter_number: 12\n---\n# 第十二章\n",
+      content: chapterContent("final"),
     })
 
-    expect(deleteChapterSnapshots).toHaveBeenCalledWith("/project", 12)
+    expect(ingestMocks.deleteChapterSnapshotArtifacts).not.toHaveBeenCalled()
+
+    await flushDeletedChapterMemoryCleanup()
+    expect(ingestMocks.deleteChapterSnapshotArtifacts).toHaveBeenCalledWith("/project", 12)
+    expect(ingestMocks.rebuildDerivedMemoryFromSnapshots).toHaveBeenCalledTimes(1)
   })
 
-  it("deletes outline snapshots using the same filename hash as outline ingest", async () => {
-    const outlinePath = "/project/wiki/outlines/人物小传/主角.md"
-    const expected = getOutlineSnapshotNumberFromPath(outlinePath)
+  it("coalesces consecutive final deletions into one rebuild and one entity scan", async () => {
+    fsMocks.listDirectory.mockImplementation(async (path: string) => {
+      if (path.endsWith("/wiki/entities")) {
+        return [{ name: "主角.md", path: "/project/wiki/entities/主角.md", is_dir: false }]
+      }
+      return []
+    })
+    fsMocks.readFile.mockResolvedValue(entityContent(["012.snapshot.json", "013.snapshot.json", "014.snapshot.json"]))
+
+    await Promise.all([
+      deleteNovelSourceMemory("/project", {
+        kind: "chapter",
+        pagePath: "/project/wiki/chapters/chapter-012.md",
+        content: chapterContent("final", 12),
+      }),
+      deleteNovelSourceMemory("/project", {
+        kind: "chapter",
+        pagePath: "/project/wiki/chapters/chapter-013.md",
+        content: chapterContent("final", 13),
+      }),
+    ])
+    await flushDeletedChapterMemoryCleanup()
+
+    expect(ingestMocks.deleteChapterSnapshotArtifacts).toHaveBeenCalledTimes(2)
+    expect(ingestMocks.rebuildDerivedMemoryFromSnapshots).toHaveBeenCalledTimes(1)
+    expect(fsMocks.listDirectory).toHaveBeenCalledWith("/project/wiki/entities")
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledWith(
+      "/project/wiki/entities/主角.md",
+      expect.stringContaining("014.snapshot.json"),
+    )
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledWith(
+      "/project/wiki/entities/主角.md",
+      expect.not.stringContaining("012.snapshot.json"),
+    )
+  })
+
+  it("deletes an entity whose only source belongs to the deleted chapter", async () => {
+    fsMocks.listDirectory.mockImplementation(async (path: string) => {
+      if (path.endsWith("/wiki/entities")) {
+        return [{ name: "主角.md", path: "/project/wiki/entities/主角.md", is_dir: false }]
+      }
+      return []
+    })
+    fsMocks.readFile.mockResolvedValue(entityContent(["012.snapshot.json"]))
 
     await deleteNovelSourceMemory("/project", {
-      kind: "outline",
-      pagePath: outlinePath,
+      kind: "chapter",
+      pagePath: "/project/wiki/chapters/chapter-012.md",
+      content: chapterContent("final"),
     })
+    await flushDeletedChapterMemoryCleanup()
 
-    expect(expected).toBeLessThan(0)
-    expect(deleteChapterSnapshots).toHaveBeenCalledWith("/project", expected)
+    expect(fsMocks.deleteFile).toHaveBeenCalledWith("/project/wiki/entities/主角.md")
   })
 
-  it("deletes entity pages that only came from the deleted chapter source", async () => {
-    fsMocks.listDirectory.mockResolvedValueOnce([
-      { name: "主角.md", path: "/project/wiki/entities/主角.md", is_dir: false },
-    ])
-    fsMocks.readFile.mockResolvedValueOnce([
-      "---",
-      "type: entity",
-      'sources: ["012.snapshot.json"]',
-      'source_type: "chapter"',
-      "source_sequence: 12",
-      "---",
-      "# 主角",
-    ].join("\n"))
+  it("scans entity files concurrently with a maximum of 16 readers", async () => {
+    const files = Array.from({ length: 40 }, (_, index) => ({
+      name: `实体-${index}.md`,
+      path: `/project/wiki/entities/实体-${index}.md`,
+      is_dir: false,
+    }))
+    fsMocks.listDirectory.mockImplementation(async (path: string) => path.endsWith("/wiki/entities") ? files : [])
+
+    let activeReads = 0
+    let maxActiveReads = 0
+    fsMocks.readFile.mockImplementation(async () => {
+      activeReads += 1
+      maxActiveReads = Math.max(maxActiveReads, activeReads)
+      await new Promise((resolve) => setTimeout(resolve, 2))
+      activeReads -= 1
+      return entityContent(["999.snapshot.json"])
+    })
 
     await deleteNovelSourceMemory("/project", {
       kind: "chapter",
       pagePath: "/project/wiki/chapters/chapter-012.md",
-      content: "---\nchapter_number: 12\n---\n# 第十二章\n",
+      content: chapterContent("final"),
     })
+    await flushDeletedChapterMemoryCleanup()
 
-    expect(fsMocks.deleteFile).toHaveBeenCalledWith("/project/wiki/entities/主角.md")
+    expect(maxActiveReads).toBeGreaterThan(1)
+    expect(maxActiveReads).toBeLessThanOrEqual(16)
   })
 
-  it("preserves entity pages that still reference other sources", async () => {
-    fsMocks.listDirectory.mockResolvedValueOnce([
-      { name: "主角.md", path: "/project/wiki/entities/主角.md", is_dir: false },
-    ])
-    fsMocks.readFile.mockResolvedValueOnce([
-      "---",
-      "type: entity",
-      'sources: ["012.snapshot.json", "013.snapshot.json"]',
-      'source_type: "chapter"',
-      "source_sequence: 13",
-      "---",
-      "# 主角",
-      "",
-      "## 章节信息",
-      "",
-      "- **相关章节**: 12",
-      "",
-      "## 章节信息",
-      "",
-      "- **相关章节**: 13",
-    ].join("\n"))
+  it("continues cleaning other entities when one entity read fails", async () => {
+    fsMocks.listDirectory.mockImplementation(async (path: string) => {
+      if (path.endsWith("/wiki/entities")) {
+        return [
+          { name: "损坏.md", path: "/project/wiki/entities/损坏.md", is_dir: false },
+          { name: "正常.md", path: "/project/wiki/entities/正常.md", is_dir: false },
+        ]
+      }
+      return []
+    })
+    fsMocks.readFile.mockImplementation(async (path: string) => {
+      if (path.endsWith("/损坏.md")) throw new Error("read failed")
+      return entityContent(["012.snapshot.json"])
+    })
+    const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined)
 
     await deleteNovelSourceMemory("/project", {
       kind: "chapter",
       pagePath: "/project/wiki/chapters/chapter-012.md",
-      content: "---\nchapter_number: 12\n---\n# 第十二章\n",
+      content: chapterContent("final"),
     })
+    await flushDeletedChapterMemoryCleanup()
 
-    expect(fsMocks.deleteFile).not.toHaveBeenCalledWith("/project/wiki/entities/主角.md")
-    expect(fsMocks.writeFileAtomic).toHaveBeenCalledWith(
-      "/project/wiki/entities/主角.md",
-      expect.not.stringContaining("012.snapshot.json"),
-    )
+    expect(fsMocks.deleteFile).toHaveBeenCalledWith("/project/wiki/entities/正常.md")
+    expect(consoleError).toHaveBeenCalled()
+    consoleError.mockRestore()
+  })
+
+  it("skips cleanup if the chapter number exists again before the worker runs", async () => {
+    fsMocks.listDirectory.mockImplementation(async (path: string) => {
+      if (path.endsWith("/wiki/chapters")) {
+        return [{ name: "restored.md", path: "/project/wiki/chapters/restored.md", is_dir: false }]
+      }
+      return []
+    })
+    fsMocks.readFile.mockResolvedValue(chapterContent("final"))
+
+    await deleteNovelSourceMemory("/project", {
+      kind: "chapter",
+      pagePath: "/project/wiki/chapters/chapter-012.md",
+      content: chapterContent("final"),
+    })
+    await flushDeletedChapterMemoryCleanup()
+
+    expect(ingestMocks.deleteChapterSnapshotArtifacts).not.toHaveBeenCalled()
+    expect(ingestMocks.rebuildDerivedMemoryFromSnapshots).not.toHaveBeenCalled()
+  })
+
+  it("skips cleanup if the original chapter path was restored", async () => {
+    fsMocks.fileExists.mockResolvedValue(true)
+
+    await deleteNovelSourceMemory("/project", {
+      kind: "chapter",
+      pagePath: "/project/wiki/chapters/chapter-012.md",
+      content: chapterContent("final"),
+    })
+    await flushDeletedChapterMemoryCleanup()
+
+    expect(ingestMocks.deleteChapterSnapshotArtifacts).not.toHaveBeenCalled()
+    expect(fsMocks.listDirectory).not.toHaveBeenCalled()
+  })
+
+  it("keeps outline deletion synchronous and uses its ingest hash", async () => {
+    const outlinePath = "/project/wiki/outlines/人物小传/主角.md"
+    const expected = getOutlineSnapshotNumberFromPath(outlinePath)
+
+    await deleteNovelSourceMemory("/project", {
+      kind: "outline",
+      pagePath: outlinePath,
+    })
+
+    expect(expected).toBeLessThan(0)
+    expect(ingestMocks.deleteChapterSnapshots).toHaveBeenCalledWith("/project", expected)
   })
 })

+ 187 - 7
src/lib/novel/delete-source-memory.ts

@@ -1,6 +1,11 @@
 import { normalizePath } from "@/lib/path-utils"
-import { deleteFile, listDirectory, readFile, writeFileAtomic } from "@/commands/fs"
+import { deleteFile, fileExists, listDirectory, readFile, writeFileAtomic } from "@/commands/fs"
 import { parseSources, writeSources } from "@/lib/sources-merge"
+import { parseFrontmatter } from "@/lib/frontmatter"
+import { clearGraphCache } from "@/lib/graph-relevance"
+import { mapWithConcurrency } from "@/lib/async-pool"
+import { useWikiStore } from "@/stores/wiki-store"
+import { isChapterPage, isFinalChapter, parseChapterNumber } from "./chapter-meta"
 import type { FileNode } from "@/types/wiki"
 
 export type NovelSourceKind = "chapter" | "outline"
@@ -11,6 +16,17 @@ export interface DeleteNovelSourceMemoryInput {
   content?: string
 }
 
+interface PendingChapterCleanup {
+  chapterNumber: number
+  pagePath: string
+}
+
+const ENTITY_CLEANUP_CONCURRENCY = 16
+const BACKGROUND_CLEANUP_DEBOUNCE_MS = 50
+const pendingChapterCleanups = new Map<string, Map<number, PendingChapterCleanup>>()
+let cleanupTimer: ReturnType<typeof setTimeout> | null = null
+let cleanupWorker: Promise<void> | null = null
+
 export function getOutlineSnapshotNumberFromPath(outlinePath: string): number {
   const normalizedPath = normalizePath(outlinePath)
   const fileName = normalizedPath.split("/").pop() ?? "outline"
@@ -54,6 +70,20 @@ function flattenEntityFiles(nodes: readonly FileNode[]): FileNode[] {
   return files
 }
 
+function flattenMarkdownFiles(nodes: readonly FileNode[]): FileNode[] {
+  const files: FileNode[] = []
+  for (const node of nodes) {
+    if (node.is_dir && node.children) {
+      files.push(...flattenMarkdownFiles(node.children))
+      continue
+    }
+    if (!node.is_dir && node.name.toLowerCase().endsWith(".md")) {
+      files.push(node)
+    }
+  }
+  return files
+}
+
 function snapshotSourceFileNameCandidates(snapshotNumber: number): string[] {
   if (snapshotNumber < 0) {
     const absolute = Math.abs(snapshotNumber)
@@ -68,9 +98,9 @@ function snapshotSourceFileNameCandidates(snapshotNumber: number): string[] {
   ]
 }
 
-async function cleanupDeletedSourceEntities(projectPath: string, snapshotNumber: number): Promise<void> {
+async function cleanupDeletedSourceEntities(projectPath: string, snapshotNumbers: readonly number[]): Promise<void> {
   const pp = normalizePath(projectPath)
-  const deletedSources = new Set(snapshotSourceFileNameCandidates(snapshotNumber))
+  const deletedSources = new Set(snapshotNumbers.flatMap(snapshotSourceFileNameCandidates))
   let entityFiles: FileNode[] = []
 
   try {
@@ -79,22 +109,165 @@ async function cleanupDeletedSourceEntities(projectPath: string, snapshotNumber:
     return
   }
 
-  for (const file of entityFiles) {
+  await mapWithConcurrency(entityFiles, ENTITY_CLEANUP_CONCURRENCY, async (file) => {
     try {
       const content = await readFile(file.path)
       const sources = parseSources(content)
       const remainingSources = sources.filter((source) => !deletedSources.has(source))
-      if (remainingSources.length === sources.length) continue
+      if (remainingSources.length === sources.length) return
 
       if (remainingSources.length === 0) {
         await deleteFile(file.path)
-        continue
+        return
       }
 
       await writeFileAtomic(file.path, writeSources(content, remainingSources))
     } catch (error) {
       console.error("[delete-source-memory] failed to clean entity source:", file.path, error)
     }
+  })
+}
+
+async function listCurrentChapterNumbers(projectPath: string): Promise<Set<number> | null> {
+  let chapterFiles: FileNode[] = []
+  try {
+    chapterFiles = flattenMarkdownFiles(await listDirectory(`${normalizePath(projectPath)}/wiki/chapters`))
+  } catch {
+    return null
+  }
+
+  const chapterNumbers = await mapWithConcurrency(
+    chapterFiles,
+    ENTITY_CLEANUP_CONCURRENCY,
+    async (file): Promise<number | null> => {
+      try {
+        const parsed = parseFrontmatter(await readFile(file.path))
+        const frontmatter = parsed.frontmatter as Record<string, unknown> | null
+        if (!frontmatter || !isChapterPage(frontmatter)) return null
+        return parseChapterNumber(frontmatter.chapter_number)
+      } catch {
+        return null
+      }
+    },
+  )
+
+  return new Set(chapterNumbers.filter((chapterNumber): chapterNumber is number => chapterNumber !== null))
+}
+
+export function shouldCleanupDeletedChapterMemory(content?: string): boolean {
+  if (!content) return false
+  try {
+    const parsed = parseFrontmatter(content)
+    const frontmatter = parsed.frontmatter as Record<string, unknown> | null
+    return Boolean(frontmatter && isChapterPage(frontmatter) && isFinalChapter(frontmatter))
+  } catch {
+    return false
+  }
+}
+
+async function runDeletedChapterCleanupBatch(
+  projectPath: string,
+  entries: readonly PendingChapterCleanup[],
+): Promise<void> {
+  const restoredPaths = await mapWithConcurrency(
+    entries,
+    ENTITY_CLEANUP_CONCURRENCY,
+    async (entry) => {
+      try {
+        return await fileExists(entry.pagePath)
+      } catch {
+        return false
+      }
+    },
+  )
+  const missingEntries = entries.filter((_, index) => !restoredPaths[index])
+  if (missingEntries.length === 0) return
+
+  const currentChapterNumbers = await listCurrentChapterNumbers(projectPath)
+  if (!currentChapterNumbers) {
+    console.error("[delete-source-memory] failed to verify current chapters; cleanup skipped:", projectPath)
+    return
+  }
+  const activeEntries = missingEntries.filter((entry) => !currentChapterNumbers.has(entry.chapterNumber))
+  if (activeEntries.length === 0) return
+
+  const snapshotNumbers = [...new Set(activeEntries.map((entry) => entry.chapterNumber))]
+  const {
+    deleteChapterSnapshotArtifacts,
+    rebuildDerivedMemoryFromSnapshots,
+  } = await import("@/lib/novel/chapter-ingest")
+
+  await mapWithConcurrency(
+    snapshotNumbers,
+    ENTITY_CLEANUP_CONCURRENCY,
+    async (chapterNumber) => deleteChapterSnapshotArtifacts(projectPath, chapterNumber),
+  )
+
+  try {
+    await rebuildDerivedMemoryFromSnapshots(projectPath)
+  } catch (error) {
+    console.error("[delete-source-memory] failed to rebuild derived memory:", projectPath, error)
+  }
+
+  await cleanupDeletedSourceEntities(projectPath, snapshotNumbers)
+  clearGraphCache()
+  useWikiStore.getState().bumpDataVersion()
+}
+
+async function processPendingChapterCleanups(): Promise<void> {
+  while (pendingChapterCleanups.size > 0) {
+    const next = pendingChapterCleanups.entries().next().value as
+      | [string, Map<number, PendingChapterCleanup>]
+      | undefined
+    if (!next) return
+
+    const [projectPath, pending] = next
+    pendingChapterCleanups.delete(projectPath)
+    try {
+      await runDeletedChapterCleanupBatch(projectPath, [...pending.values()])
+    } catch (error) {
+      console.error("[delete-source-memory] background cleanup failed:", projectPath, error)
+    }
+  }
+}
+
+function startCleanupWorker(): void {
+  if (cleanupWorker) return
+  cleanupWorker = processPendingChapterCleanups().finally(() => {
+    cleanupWorker = null
+    if (pendingChapterCleanups.size > 0) scheduleCleanupWorker()
+  })
+}
+
+function scheduleCleanupWorker(): void {
+  if (cleanupTimer || cleanupWorker) return
+  cleanupTimer = setTimeout(() => {
+    cleanupTimer = null
+    startCleanupWorker()
+  }, BACKGROUND_CLEANUP_DEBOUNCE_MS)
+}
+
+export function enqueueDeletedChapterMemoryCleanup(
+  projectPath: string,
+  chapterNumber: number,
+  pagePath: string,
+): void {
+  const pp = normalizePath(projectPath)
+  const pending = pendingChapterCleanups.get(pp) ?? new Map<number, PendingChapterCleanup>()
+  pending.set(chapterNumber, { chapterNumber, pagePath: normalizePath(pagePath) })
+  pendingChapterCleanups.set(pp, pending)
+  scheduleCleanupWorker()
+}
+
+export async function flushDeletedChapterMemoryCleanup(): Promise<void> {
+  if (cleanupTimer) {
+    clearTimeout(cleanupTimer)
+    cleanupTimer = null
+    startCleanupWorker()
+  }
+  if (cleanupWorker) await cleanupWorker
+  if (pendingChapterCleanups.size > 0 || cleanupTimer || cleanupWorker) {
+    await flushDeletedChapterMemoryCleanup()
   }
 }
 
@@ -104,7 +277,14 @@ export async function deleteNovelSourceMemory(
 ): Promise<void> {
   const snapshotNumber = getChapterSnapshotNumberFromDeletedSource(input)
   if (snapshotNumber === null) return
+
+  if (input.kind === "chapter") {
+    if (!shouldCleanupDeletedChapterMemory(input.content)) return
+    enqueueDeletedChapterMemoryCleanup(projectPath, snapshotNumber, input.pagePath)
+    return
+  }
+
   const { deleteChapterSnapshots } = await import("@/lib/novel/chapter-ingest")
   await deleteChapterSnapshots(projectPath, snapshotNumber)
-  await cleanupDeletedSourceEntities(projectPath, snapshotNumber)
+  await cleanupDeletedSourceEntities(projectPath, [snapshotNumber])
 }

+ 6 - 7
src/stores/outline-chat-store.spec.ts

@@ -57,16 +57,15 @@ describe("outline-chat-store", () => {
     )
   })
 
-  it("按会话隔离流式内容,并支持追加、读取和单独清理", () => {
+  it("按会话隔离运行状态提示,并支持读取和单独清理", () => {
     useOutlineChatStore.setState({ conversations: [conversation("a"), conversation("b")] })
     const store = useOutlineChatStore.getState()
-    store.setStreamingContent("a", "A")
-    store.appendStreamingContent("a", "内容")
-    store.setStreamingContent("b", "B内容")
-    expect(useOutlineChatStore.getState().streamingContents).toEqual({ a: "A内容", b: "B内容" })
-    expect(useOutlineChatStore.getState().getStreamingContent("a")).toBe("A内容")
+    store.setStreamingContent("a", "A状态")
+    store.setStreamingContent("b", "B状态")
+    expect(useOutlineChatStore.getState().streamingContents).toEqual({ a: "A状态", b: "B状态" })
+    expect(useOutlineChatStore.getState().getStreamingContent("a")).toBe("A状态")
     useOutlineChatStore.getState().clearStreamingContent("a")
-    expect(useOutlineChatStore.getState().streamingContents).toEqual({ b: "B内容" })
+    expect(useOutlineChatStore.getState().streamingContents).toEqual({ b: "B状态" })
   })
 
   it("后台完成显示未读,首次打开后只清除完成未读状态", () => {

+ 1 - 7
src/stores/outline-chat-store.ts

@@ -112,6 +112,7 @@ export interface OutlineChatConversation {
 interface OutlineChatState {
   conversations: OutlineChatConversation[]
   activeConversationId: string | null
+  /** 仅承载生成过程中的运行状态提示文本;正文内容始终直接写入消息,不经过这里。 */
   streamingContents: Record<string, string>
   runStates: ConversationRunStates
   loaded: boolean
@@ -126,7 +127,6 @@ interface OutlineChatState {
   setConversationModel: (id: string, modelId: string) => void
   setConversationContextSummary: (id: string, contextSummary: SessionContextSummary) => void
   setStreamingContent: (conversationId: string, content: string) => void
-  appendStreamingContent: (conversationId: string, content: string) => void
   clearStreamingContent: (conversationId: string) => void
   getStreamingContent: (conversationId: string) => string
   startConversationRun: (id: string, runId: string) => boolean
@@ -319,12 +319,6 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => {
   setStreamingContent: (conversationId, content) => set((state) => ({
     streamingContents: { ...state.streamingContents, [conversationId]: content },
   })),
-  appendStreamingContent: (conversationId, content) => set((state) => ({
-    streamingContents: {
-      ...state.streamingContents,
-      [conversationId]: (state.streamingContents[conversationId] ?? "") + content,
-    },
-  })),
   clearStreamingContent: (conversationId) => set((state) => {
     const { [conversationId]: _, ...streamingContents } = state.streamingContents
     return { streamingContents }