Bläddra i källkod

fix(outline): 修复大纲生成4个Bug + 风险加固

Bug 1: EventStream 流式结束后重置 showScrollButton,消除 ArrowDown 残留遮挡
Bug 2: 多 Agent 失败后新增「继续未完成的任务」按钮,仅重试失败 Agent
Bug 3: stripStructuredMarkers 增加流式中间态清理,不完整标记开标签截断
Bug 4: 系统生成 prompt 标记 systemGenerated,跳过关键词检测避免重复读取资料

风险加固:
- store 持久化加载时验证 resumeablePlan 数据完整性
- 向导多 Agent 调用补充 systemGenerated 标记
- 新增 9 个边界测试用例

版本: 2.2.34
Mochocyang 2 månader sedan
förälder
incheckning
fa5c9abd6b

+ 71 - 0
dagangbugxiu-分支说明.md

@@ -0,0 +1,71 @@
+# dagangbugxiu 分支说明
+
+## 分支用途
+
+AI 大纲模块 4 个 Bug 修复。
+
+## 使用要求
+
+- 本分支仅修复大纲生成相关的 4 个 Bug,不涉及其他功能
+- 修复完成后需运行类型检查和单元测试确认不回退
+- 不自动合并到 main,等待用户确认
+
+## Bug 列表
+
+1. Bug 1: 思考内容区域下拉箭头遮挡时间显示(EventStream)
+2. Bug 2: 多 Agent 失败后无法续传(编排器 + UI + Store)
+3. Bug 3: intent_clarity 标记在流式输出中可见(stripStructuredMarkers)
+4. Bug 4: "继续完善当前模块"导致重复读取全部资料(planOutlineContextReuse)
+
+## 更新记录
+
+### 20260713-1930 初始创建
+
+- 从 main(2.2.34)创建分支
+- 设计文档:`docs/superpowers/specs/2026-07-13-outline-bugfixes-design.html`
+- 是否提交 git:未提交
+
+### 20260713-1940 四个 Bug 修复完成
+
+#### Bug 1: EventStream 下拉箭头遮挡
+- 文件:`src/components/common/event-stream.tsx`
+- 改动:新增 useEffect,isStreaming 变为 false 时重置 showScrollButton 和 userScrolledRef
+
+#### Bug 2: 多 Agent 续传
+- 文件:`src/lib/novel/outline-multi-agent-orchestrator.ts` — 新增 resumeOutlineMultiAgentWorkflow 函数
+- 文件:`src/stores/outline-chat-store.ts` — OutlineMultiAgentRunState 增加 resumeablePlan 字段
+- 文件:`src/components/sources/outline-multi-agent-panel.tsx` — 新增"继续未完成的任务"按钮 + onResume/resumeDisabled 属性
+- 文件:`src/components/sources/outline-chat-panel.tsx` — 新增 handleResumeMultiAgent 回调 + 失败时填充 resumeablePlan
+
+#### Bug 3: intent_clarity 标记泄漏
+- 文件:`src/lib/novel/outline-intent-clarity.ts`
+- 改动:stripStructuredMarkers 增加不完整标记清理(开标签无闭标签时截断)
+
+#### Bug 4: 重复读取资料
+- 文件:`src/lib/novel/outline-context-reuse.ts` — OutlineContextReuseInput 增加 systemGenerated 字段,跳过关键词检测
+- 文件:`src/components/sources/outline-chat-panel.tsx` — handleSend options 增加 systemGenerated,3 处系统生成 prompt 调用传入 systemGenerated: true
+
+#### 验证
+- TypeScript 类型检查:通过
+- 单元测试:46/46 通过(chat-message 16 + intent-clarity 8 + next-step 7 + context-reuse 10 + multi-agent-orchestrator 5)
+- 是否提交 git:未提交
+
+### 20260713-1945 风险与限制修复
+
+#### 风险1: store 持久化 resumeablePlan 数据验证
+- 文件:`src/stores/outline-chat-store.ts`
+- 改动:loadFromDisk 加载时验证 resumeablePlan 的 plan/completedResults/failedAgentIds 字段完整性,结构不完整时清除
+
+#### 风险2: Bug 3 截断逻辑边界测试
+- 文件:`src/lib/novel/outline-intent-clarity.spec.ts` — 新增 7 个边界测试用例
+- 文件:`src/lib/novel/outline-context-reuse.spec.ts` — 新增 3 个 systemGenerated 测试用例
+- 覆盖:流式中间态截断、完整标记对优先级、裸闭标签清理、非标记名 HTML 注释不受影响、systemGenerated 跳过/不跳过关键词检测
+
+#### 风险3: handleSend 调用覆盖补全
+- 文件:`src/components/sources/outline-chat-panel.tsx`
+- 改动:向导多 Agent 调用(handleSubmitOutlineWizard)补充 systemGenerated: true,确保所有系统生成 prompt 路径都标记
+
+#### 验证
+- TypeScript 类型检查:通过
+- 单元测试:55/55 通过(新增 9 个边界测试)
+- 是否提交 git:未提交

+ 1 - 1
package.json

@@ -1,7 +1,7 @@
 {
   "name": "qmai",
   "private": true,
-  "version": "2.2.33",
+  "version": "2.2.34",
   "license": "GPL-3.0-or-later",
   "type": "module",
   "scripts": {

+ 1 - 1
src-tauri/Cargo.lock

@@ -5728,7 +5728,7 @@ checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
 
 [[package]]
 name = "qmai"
-version = "2.2.33"
+version = "2.2.34"
 dependencies = [
  "arrow-array",
  "arrow-schema",

+ 1 - 1
src-tauri/Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = "qmai"
-version = "2.2.33"
+version = "2.2.34"
 description = "QMAI - AI writing system for long-form novels"
 authors = ["Mochocyang"]
 edition = "2021"

+ 1 - 1
src-tauri/tauri.conf.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://schema.tauri.app/config/2",
   "productName": "QMaiWrite",
-  "version": "2.2.33",
+  "version": "2.2.34",
   "identifier": "com.qingmuai.writer",
   "build": {
     "beforeDevCommand": "npm run dev",

+ 8 - 0
src/components/common/event-stream.tsx

@@ -63,6 +63,14 @@ function EventStreamImpl({ events, isStreaming, totalDurationMs, totalTokens }:
     return () => container.removeEventListener("scroll", handleScroll)
   }, [isStreaming])
 
+  // 流式结束后重置滚动按钮状态,避免 ArrowDown 残留遮挡底部耗时统计
+  useEffect(() => {
+    if (!isStreaming) {
+      setShowScrollButton(false)
+      userScrolledRef.current = false
+    }
+  }, [isStreaming])
+
   useEffect(() => {
     if (!isStreaming || userScrolledRef.current) return
     if (containerRef.current) {

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

@@ -82,8 +82,10 @@ import {
 import {
   type OutlineSubAgentPlan,
   planOutlineSubAgents,
+  resumeOutlineMultiAgentWorkflow,
   runOutlineMultiAgentWorkflow,
 } from "@/lib/novel/outline-multi-agent-orchestrator";
+import type { OutlineSubAgentResult } from "@/lib/novel/outline-result-protocol";
 import {
   buildDynamicOutlinePlannerPrompt,
   parseDynamicOutlinePlan,
@@ -836,6 +838,8 @@ function OutlineAssistantMessage({
   onConfirmToolSave,
   onRejectTool,
   onSendMessage,
+  onResumeMultiAgent,
+  resumeMultiAgentDisabled,
   nextStepDisabled,
   nextStepDisabledReason,
   generationContext,
@@ -854,6 +858,8 @@ function OutlineAssistantMessage({
   onConfirmToolSave: (call: ToolCallRecord & { preview?: string }) => void;
   onRejectTool: (call: ToolCallRecord & { preview?: string }) => void;
   onSendMessage: (text: string, options?: { intentPhase?: "intent_analysis" | "generation" | "waiting_user_input"; scope?: string }) => Promise<boolean>;
+  onResumeMultiAgent: (messageId: string) => Promise<void>;
+  resumeMultiAgentDisabled: boolean;
   nextStepDisabled: boolean;
   nextStepDisabledReason?: string;
   generationContext: boolean;
@@ -913,7 +919,11 @@ function OutlineAssistantMessage({
   return (
     <>
 
-      <OutlineMultiAgentPanel run={msg.multiAgentRun} />
+      <OutlineMultiAgentPanel
+        run={msg.multiAgentRun}
+        onResume={() => { void onResumeMultiAgent(msg.id) }}
+        resumeDisabled={resumeMultiAgentDisabled}
+      />
       <AgentToolCallMessage
         toolCalls={msg.agentToolCalls}
         thinkingContent={thinking || undefined}
@@ -1687,6 +1697,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         clearDraft?: boolean;
         intentPhase?: "intent_analysis" | "generation" | "waiting_user_input";
         novelGenerationRequest?: NovelGenerationRequestPackage;
+        systemGenerated?: boolean;
       } = {},
     ): Promise<OutlineSendResult> => {
       const prompt = inputText.trim();
@@ -1772,6 +1783,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         inputText: prompt,
         enableMultiAgent: options.enableMultiAgent,
         forceRefresh,
+        systemGenerated: options.systemGenerated,
       });
       const cachedSummary =
         contextDecision.mode === "reuse"
@@ -1975,6 +1987,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         };
 
         let finalText = "";
+        let capturedSuccessfulResults: OutlineSubAgentResult[] = [];
         if (options.enableMultiAgent) {
           const maxConcurrency = 3;
           const fallbackSubAgentPlan = planOutlineSubAgents({
@@ -2119,6 +2132,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             },
             mergeResults: async (subAgentResults) => {
               if (!isCurrentRun()) throw new Error("aborted");
+              capturedSuccessfulResults = subAgentResults;
               result = "";
               updateOutlineMultiAgentRun(convId, assistantId, (run) => run ? ({
                 ...run,
@@ -2195,6 +2209,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             if (!run) return run;
             const successful = new Set(multiAgentResult.successfulAgents);
             const failed = new Set(multiAgentResult.failedAgents);
+            const shouldStoreResume = multiAgentResult.mode === "single-agent-fallback"
+              && multiAgentResult.successfulAgents.length > 0;
             return {
               ...run,
               mode: multiAgentResult.mode,
@@ -2216,6 +2232,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                     status: "skipped",
                     summary: "已回退为单 Agent 生成。",
                   },
+              resumeablePlan: shouldStoreResume ? {
+                plan: subAgentPlan as unknown[],
+                completedResults: capturedSuccessfulResults as unknown[],
+                failedAgentIds: multiAgentResult.failedAgents,
+              } : undefined,
             };
           });
           finalText = multiAgentResult.finalText;
@@ -2357,6 +2378,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             conversationId: capturedConvId,
             clearDraft: false,
             intentPhase: "generation",
+            systemGenerated: true,
           });
         }
         return { started: true, sent: true };
@@ -2453,7 +2475,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         setOutlineWorkflowStages((stages) => setOutlineSessionValue(stages, capturedConvId, "intent_analysis"));
       }
       const intentPrompt = buildIntentAnalysisPrompt(title, requestHint);
-      void handleSend(intentPrompt, [], { conversationId: capturedConvId, intentPhase: "intent_analysis" });
+      void handleSend(intentPrompt, [], { conversationId: capturedConvId, intentPhase: "intent_analysis", systemGenerated: true });
     },
     [activeConversationId, createConversation, handleSend, outlineWorkflowStages],
   );
@@ -2483,7 +2505,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           const intentContext = intentContextsRef.current[capturedConvId] ?? { title: "", hint: "" };
           const generationPrompt = buildGenerationPrompt(intentContext.title, intentContext.hint, scope, intentContext.outputMode);
           const references = outlineReferenceTokens;
-          const result = await handleSend(generationPrompt, references, { conversationId: capturedConvId, intentPhase: "generation", clearDraft: false });
+          const result = await handleSend(generationPrompt, references, { conversationId: capturedConvId, intentPhase: "generation", clearDraft: false, systemGenerated: true });
           if (result.sent) {
             if (shouldClearOutlineReferences({
               invocationConversationId: capturedConvId,
@@ -2523,6 +2545,202 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     [activeConversationId, canStartConversationRun, handleSend, outlineReferenceTokens, outlineWorkflowStage],
   );
 
+  const handleResumeMultiAgent = useCallback(
+    async (messageId: string) => {
+      if (!project || !activeConversationId) return;
+      const conv = useOutlineChatStore
+        .getState()
+        .conversations.find((c) => c.id === activeConversationId);
+      if (!conv) return;
+      const targetMsg = conv.messages.find((m) => m.id === messageId);
+      if (!targetMsg?.multiAgentRun?.resumeablePlan) return;
+
+      const { plan, completedResults, failedAgentIds } = targetMsg.multiAgentRun.resumeablePlan;
+
+      let effectiveLlmConfig = resolveNovelModel(llmConfig, novelConfig, "writing");
+      if (effectiveOutlineModelId) {
+        effectiveLlmConfig = resolveModelConfig(effectiveOutlineModelId, effectiveLlmConfig, providerConfigs);
+      }
+      const effectiveModelId = effectiveOutlineModelId || effectiveLlmConfig.model || "";
+      if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
+        toast.error("请先在设置中配置并选择一个可用的 AI 模型。");
+        return;
+      }
+
+      const capturedConvId = activeConversationId;
+      const runId = crypto.randomUUID();
+      if (!startConversationRun(capturedConvId, runId)) return;
+      const controller = new AbortController();
+      outlineConversationRunRegistry.register(capturedConvId, controller);
+      const isCurrentRun = () => canApplyOutlineRunEffect(
+        useOutlineChatStore.getState().runStates,
+        capturedConvId,
+        runId,
+      );
+
+      try {
+        const skillConfig = await loadDeAiSkillConfig(project.path).catch((): DeAiSkillConfig | null => null);
+        const soulDoc = await readSoulDoc(project.path).catch(() => "");
+        const systemPrompt = buildOutlineAgentSystemPrompt({ projectName: project.name, soulDoc });
+        const buildConfig = (_skillNames: string[], disableWriteTools: boolean) => {
+          const r = new ToolRegistry();
+          const c = buildAgentConfig(effectiveModelId, systemPrompt, r, {
+            wikiPath: `${normalizePath(project.path)}/wiki`,
+            getSkillConfig: () => skillConfig,
+            getUserSkills: () => outlineWritingSkills,
+            getChatConversations: () => {
+              const state = useChatStore.getState();
+              return state.conversations.map((conversation) => ({
+                id: conversation.id,
+                title: conversation.title,
+                messages: state.messages
+                  .filter((message) => message.conversationId === conversation.id)
+                  .map((message) => ({ role: message.role, content: message.content })),
+              }));
+            },
+            getOutlineConversations: () => mapOutlineConversationsForModel(useOutlineChatStore.getState().conversations),
+            llmConfig: effectiveLlmConfig,
+            disabledTools: disableWriteTools ? OUTLINE_CHAT_DISABLED_TOOLS : [],
+          });
+          return { agentConfig: c, registry: r };
+        };
+
+        // 更新状态为续传运行中
+        updateOutlineMultiAgentRun(capturedConvId, messageId, (run) => run ? ({
+          ...run,
+          status: "running",
+          resumeablePlan: undefined,
+          agents: run.agents.map((agent) =>
+            failedAgentIds.includes(agent.id)
+              ? { ...agent, status: "waiting" as const, error: undefined, startedAt: undefined, finishedAt: undefined }
+              : agent,
+          ),
+        }) : run);
+
+        const resumeResult = await resumeOutlineMultiAgentWorkflow({
+          plan: plan as OutlineSubAgentPlan[],
+          completedResults: completedResults as OutlineSubAgentResult[],
+          failedAgentIds,
+          runSubAgent: async (subAgentPlan) => {
+            if (!isCurrentRun()) throw new Error("aborted");
+            updateOutlineMultiAgentItem(capturedConvId, messageId, subAgentPlan.id, (agent) => ({
+              ...agent,
+              status: "running",
+              startedAt: Date.now(),
+              error: undefined,
+            }));
+            const { agentConfig, registry: reg } = buildConfig(subAgentPlan.skillNames, true);
+            let runText = "";
+            let agentError: Error | null = null;
+            await new AgentRunner().run(agentConfig, reg, [
+              { role: "system", content: [systemPrompt, "", "## 子 Agent 运行规则", `当前身份:${subAgentPlan.name}`, "你只能处理本 Agent 负责的维度,禁止写入文件。", "必须输出符合 AI 大纲子 Agent JSON 协议的 JSON,不要输出额外说明。"].join("\n") },
+              { role: "user", content: subAgentPlan.taskPrompt },
+            ], {
+              onText: (chunk) => { runText += chunk; },
+              onToolCall: () => {},
+              onToolResult: () => {},
+              onToolError: () => {},
+              onToolEvent: () => {},
+              onDone: () => {},
+              onError: (error) => { agentError = error; },
+            }, controller.signal);
+            if (agentError) throw agentError;
+            return runText;
+          },
+          mergeResults: async (subAgentResults) => {
+            if (!isCurrentRun()) throw new Error("aborted");
+            updateOutlineMultiAgentRun(capturedConvId, messageId, (run) => run ? ({
+              ...run,
+              status: "merging",
+              merge: { status: "running", startedAt: Date.now(), summary: `正在合并 ${subAgentResults.length} 个子 Agent 结果。` },
+            }) : run);
+            const { agentConfig, registry: reg } = buildConfig([], true);
+            let mergeText = "";
+            let mergeError: Error | null = null;
+            await new AgentRunner().run(agentConfig, reg, [
+              { role: "system", content: [systemPrompt, "", "## 合并 Agent 运行规则", "你负责合并多个子 Agent 的结构化结果,形成最终可预览的大纲草稿。", "输出必须是用户可直接阅读和保存的大纲正文,不要输出内部调度报告。"].join("\n") },
+              { role: "user", content: ["请合并以下 AI 大纲子 Agent 结果,解决冲突并输出最终大纲草稿。", "", "## 子 Agent 结构化结果", JSON.stringify(subAgentResults, null, 2)].join("\n") },
+            ], {
+              onText: (chunk) => {
+                mergeText += chunk;
+                if (isCurrentRun()) setStreamingContent(capturedConvId, mergeText);
+              },
+              onToolCall: () => {},
+              onToolResult: () => {},
+              onToolError: () => {},
+              onToolEvent: () => {},
+              onDone: () => {},
+              onError: (error) => { mergeError = error; },
+            }, controller.signal);
+            if (mergeError) throw mergeError;
+            return mergeText || "AI大纲未返回内容。";
+          },
+          onStatusChange: (event) => {
+            if (!isCurrentRun()) return;
+            updateOutlineMultiAgentItem(capturedConvId, messageId, event.agentId, (agent) => ({
+              ...agent,
+              status: event.status === "running" ? "running" : event.status === "retrying" ? "retrying" : event.status === "completed" ? "done" : event.status === "failed" ? "error" : "waiting",
+              retryCount: Math.max(0, event.attempt - 1),
+              error: event.error,
+              summary: event.status === "completed" ? agent.summary ?? "已完成本 Agent 任务。" : agent.summary,
+              finishedAt: event.status === "completed" || event.status === "failed" ? Date.now() : undefined,
+            }));
+          },
+        });
+
+        if (!isCurrentRun()) return;
+
+        // 更新最终状态
+        updateOutlineMultiAgentRun(capturedConvId, messageId, (run) => {
+          if (!run) return run;
+          const successful = new Set(resumeResult.successfulAgents);
+          const failed = new Set(resumeResult.failedAgents);
+          return {
+            ...run,
+            mode: resumeResult.mode,
+            status: resumeResult.mode === "multi-agent" ? "done" : "fallback",
+            fallbackReason: resumeResult.fallbackReason ?? run.fallbackReason,
+            failureDetails: resumeResult.failureDetails ?? run.failureDetails,
+            agents: run.agents.map((agent) => {
+              if (successful.has(agent.id) && agent.status !== "done") {
+                return { ...agent, status: "done" as const, summary: agent.summary ?? "已完成。", finishedAt: Date.now() };
+              }
+              if (failed.has(agent.id) && agent.status !== "error") {
+                return { ...agent, status: "error" as const, error: agent.error ?? "子 Agent 执行失败。", finishedAt: Date.now() };
+              }
+              return agent;
+            }),
+            merge: resumeResult.mode === "multi-agent"
+              ? { status: "done" as const, finishedAt: Date.now(), summary: "合并完成,已输出最终大纲草稿。" }
+              : run.merge?.status === "error"
+                ? run.merge
+                : { status: "skipped" as const, summary: "已回退为单 Agent 生成。" },
+            resumeablePlan: resumeResult.mode === "single-agent-fallback" && resumeResult.successfulAgents.length > 0
+              ? { plan, completedResults, failedAgentIds: resumeResult.failedAgents }
+              : undefined,
+          };
+        });
+
+        // 更新消息内容
+        if (resumeResult.finalText) {
+          updateOutlineAssistantMessage(capturedConvId, messageId, (message) => ({
+            ...message,
+            content: resumeResult.finalText,
+          }));
+        }
+      } catch (err) {
+        const aborted = controller.signal.aborted || (err instanceof Error ? err.message : "").toLowerCase().includes("aborted");
+        if (!aborted) {
+          toast.error("续传失败,请稍后重试。");
+        }
+      } finally {
+        stopConversationRun(capturedConvId, runId);
+        clearStreamingContent(capturedConvId);
+      }
+    },
+    [project, activeConversationId, llmConfig, novelConfig, effectiveOutlineModelId, providerConfigs, outlineWritingSkills, startConversationRun, stopConversationRun, clearStreamingContent],
+  );
+
   const handleFocusInput = useCallback(() => {
     setTimeout(() => {
       const textarea = document.querySelector<HTMLTextAreaElement>(
@@ -2540,6 +2758,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         preferredSkillNames: getOutlineWizardSkillNames(request),
         enableMultiAgent: true,
         novelGenerationRequest: createNovelGenerationRequestPackage(request, modelContent),
+        systemGenerated: true,
       });
     },
     [handleSend],
@@ -3260,6 +3479,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                   onConfirmToolSave={handleConfirmToolSave}
                   onRejectTool={handleRejectTool}
                   onSendMessage={handleSendMessage}
+                  onResumeMultiAgent={handleResumeMultiAgent}
+                  resumeMultiAgentDisabled={isStreaming}
                   nextStepDisabled={submitDisabled}
                   generationContext={activeMessages.slice(0, i).some((message) => message.role === "user" && Boolean(message.novelGenerationRequest))
                     || Boolean(msg.nextStepRecommendation?.completedModule && /^#{1,6}\s/m.test(msg.content))}

+ 27 - 11
src/components/sources/outline-multi-agent-panel.tsx

@@ -6,6 +6,7 @@ import {
   Circle,
   Loader2,
   Network,
+  RotateCw,
   XCircle,
 } from "lucide-react"
 import type {
@@ -16,6 +17,8 @@ import { cn } from "@/lib/utils"
 
 interface OutlineMultiAgentPanelProps {
   run?: OutlineMultiAgentRunState
+  onResume?: () => void
+  resumeDisabled?: boolean
 }
 
 function statusLabel(status: OutlineMultiAgentStepStatus | OutlineMultiAgentRunState["status"]): string {
@@ -103,7 +106,7 @@ function summarizeFailureDetails(values: string[] | undefined): string[] {
   return summaries
 }
 
-export function OutlineMultiAgentPanel({ run }: OutlineMultiAgentPanelProps) {
+export function OutlineMultiAgentPanel({ run, onResume, resumeDisabled }: OutlineMultiAgentPanelProps) {
   const [showFallbackDetails, setShowFallbackDetails] = useState(false)
   const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "")
   const fallbackDetailsId = `outline-multi-agent-fallback-details-${generatedId}`
@@ -128,16 +131,29 @@ export function OutlineMultiAgentPanel({ run }: OutlineMultiAgentPanelProps) {
             <AlertTriangle className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
             <span className="min-w-0 break-words [overflow-wrap:anywhere]">{FALLBACK_SUMMARY}</span>
           </div>
-          <button
-            type="button"
-            className="inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-1 text-xs font-medium text-amber-800 hover:bg-amber-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-200 dark:hover:bg-amber-950/50"
-            aria-expanded={showFallbackDetails}
-            aria-controls={showFallbackDetails ? fallbackDetailsId : undefined}
-            onClick={() => setShowFallbackDetails((value) => !value)}
-          >
-            <ChevronRight className={cn("h-3.5 w-3.5 transition-transform", showFallbackDetails && "rotate-90")} />
-            {showFallbackDetails ? "\u6536\u8d77\u8be6\u60c5" : "\u67e5\u770b\u8be6\u60c5"}
-          </button>
+          <div className="flex shrink-0 items-center gap-1.5">
+            {run.resumeablePlan && onResume ? (
+              <button
+                type="button"
+                className="inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-1 text-xs font-medium text-emerald-700 hover:bg-emerald-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:text-emerald-300 dark:hover:bg-emerald-950/30 disabled:cursor-not-allowed disabled:opacity-50"
+                onClick={onResume}
+                disabled={resumeDisabled}
+              >
+                <RotateCw className="h-3.5 w-3.5" />
+                {"\u7ee7\u7eed\u672a\u5b8c\u6210\u7684\u4efb\u52a1"}
+              </button>
+            ) : null}
+            <button
+              type="button"
+              className="inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-1 text-xs font-medium text-amber-800 hover:bg-amber-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-200 dark:hover:bg-amber-950/50"
+              aria-expanded={showFallbackDetails}
+              aria-controls={showFallbackDetails ? fallbackDetailsId : undefined}
+              onClick={() => setShowFallbackDetails((value) => !value)}
+            >
+              <ChevronRight className={cn("h-3.5 w-3.5 transition-transform", showFallbackDetails && "rotate-90")} />
+              {showFallbackDetails ? "\u6536\u8d77\u8be6\u60c5" : "\u67e5\u770b\u8be6\u60c5"}
+            </button>
+          </div>
         </div>
 
         {showFallbackDetails ? (

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

@@ -192,4 +192,41 @@ describe("AI 大纲上下文复用策略", () => {
     expect(plan.showToolProcessOnError).toBe(true)
     expect(plan.sources).toContain("过程: 已隐藏重复工具过程")
   })
+
+  it("systemGenerated 标记跳过关键词检测,避免系统 prompt 触发刷新", () => {
+    // 系统生成的 prompt 包含"读取资料"关键词,但不应触发刷新
+    const decision = planOutlineContextReuse({
+      hasPriorAssistantAnswer: true,
+      attachedReferenceCount: 0,
+      inputText: "3. 读取资料。4. 提取关键内容。5. 生成大纲。",
+      enableMultiAgent: false,
+      systemGenerated: true,
+    })
+
+    expect(decision.mode).toBe("reuse")
+    expect(decision.disabledTools).toEqual(OUTLINE_CONTEXT_REUSE_DISABLED_TOOLS)
+  })
+
+  it("用户手动输入包含读取关键词时仍触发刷新(systemGenerated 为 false)", () => {
+    const decision = planOutlineContextReuse({
+      hasPriorAssistantAnswer: true,
+      attachedReferenceCount: 0,
+      inputText: "读取资料后重新分析",
+      enableMultiAgent: false,
+      systemGenerated: false,
+    })
+
+    expect(decision.mode).toBe("refresh")
+  })
+
+  it("systemGenerated 为 undefined 时保持原有行为(关键词检测生效)", () => {
+    const decision = planOutlineContextReuse({
+      hasPriorAssistantAnswer: true,
+      attachedReferenceCount: 0,
+      inputText: "读取资料后重新分析",
+      enableMultiAgent: false,
+    })
+
+    expect(decision.mode).toBe("refresh")
+  })
 })

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

@@ -27,6 +27,8 @@ export interface OutlineContextReuseInput {
   inputText: string
   enableMultiAgent?: boolean
   forceRefresh?: boolean
+  /** 标记 prompt 由系统生成(如 buildGenerationPrompt),跳过关键词检测 */
+  systemGenerated?: boolean
 }
 
 export interface OutlineContextReuseDecision {
@@ -76,7 +78,7 @@ export function planOutlineContextReuse(input: OutlineContextReuseInput): Outlin
     input.forceRefresh === true ||
     input.enableMultiAgent === true ||
     input.attachedReferenceCount > 0 ||
-    REFRESH_KEYWORD_PATTERN.test(trimmed)
+    (!input.systemGenerated && REFRESH_KEYWORD_PATTERN.test(trimmed))
 
   if (shouldRefresh) {
     return {

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

@@ -57,4 +57,51 @@ describe("stripStructuredMarkers", () => {
     const text = "纯文本内容"
     expect(stripStructuredMarkers(text)).toBe("纯文本内容")
   })
+
+  it("流式中间态:intent_clarity 开标签无闭标签时截断后续内容", () => {
+    const text = "分析结果如下:\n<!-- intent_clarity -->\n{\"clarity\":\"clear\",\"module\":\"章节"
+    const result = stripStructuredMarkers(text)
+    expect(result).toBe("分析结果如下:")
+    expect(result).not.toContain("intent_clarity")
+    expect(result).not.toContain("clarity")
+  })
+
+  it("流式中间态:next_step 开标签无闭标签时截断后续内容", () => {
+    const text = "正文内容\n<!-- next_step -->\n{\"recommendations\":["
+    const result = stripStructuredMarkers(text)
+    expect(result).toBe("正文内容")
+    expect(result).not.toContain("next_step")
+    expect(result).not.toContain("recommendations")
+  })
+
+  it("完整标记对优先于不完整截断逻辑", () => {
+    // 完整标记对在步骤1被移除后,不应再被步骤2截断
+    const text = "<!-- intent_clarity -->\n{\"clarity\":\"clear\"}\n<!-- /intent_clarity -->\n正文内容"
+    const result = stripStructuredMarkers(text)
+    expect(result).toBe("正文内容")
+  })
+
+  it("残留裸闭标签被清理", () => {
+    const text = "正文内容\n<!-- /intent_clarity -->\n更多内容"
+    const result = stripStructuredMarkers(text)
+    expect(result).not.toContain("intent_clarity")
+    expect(result).toContain("正文内容")
+    expect(result).toContain("更多内容")
+  })
+
+  it("正常文本中包含 HTML 注释但不匹配标记名时不受影响", () => {
+    const text = "这是正文\n<!-- regular comment -->\n更多正文"
+    const result = stripStructuredMarkers(text)
+    expect(result).toContain("regular comment")
+    expect(result).toContain("这是正文")
+    expect(result).toContain("更多正文")
+  })
+
+  it("同时存在完整 intent_clarity 和不完整 next_step 时正确处理", () => {
+    const text = "<!-- intent_clarity -->\n{\"clarity\":\"clear\"}\n<!-- /intent_clarity -->\n正文\n<!-- next_step -->\n{\"recommendations\":["
+    const result = stripStructuredMarkers(text)
+    expect(result).toBe("正文")
+    expect(result).not.toContain("intent_clarity")
+    expect(result).not.toContain("next_step")
+  })
 })

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

@@ -101,8 +101,18 @@ export function buildIntentAnalysisPrompt(title: string, requestHint: string): s
 
 export function stripStructuredMarkers(text: string): string {
   return text
+    // 1. 移除完整的标记对(现有逻辑)
     .replace(/<!--\s*intent_clarity\s*-->[\s\S]*?<!--\s*\/intent_clarity\s*-->/gi, "")
     .replace(/<!--\s*next_step\s*-->[\s\S]*?<!--\s*\/next_step\s*-->/gi, "")
+    // 2. 移除不完整的 intent_clarity 开标签及其后所有内容
+    //    (流式中间态:开标签已到达,闭标签未到达,后续是 JSON payload)
+    .replace(/<!--\s*intent_clarity\s*-->[\s\S]*$/gi, "")
+    // 3. 移除不完整的 next_step 开标签及其后所有内容
+    .replace(/<!--\s*next_step\s*-->[\s\S]*$/gi, "")
+    // 4. 清理可能残留的裸闭标签
+    .replace(/<!--\s*\/intent_clarity\s*-->/gi, "")
+    .replace(/<!--\s*\/next_step\s*-->/gi, "")
+    // 5. 清理多余空行
     .replace(/\n{3,}/g, "\n\n")
     .trim()
 }

+ 112 - 1
src/lib/novel/outline-multi-agent-orchestrator.ts

@@ -1,4 +1,4 @@
-import {
+import {
   coerceOutlineSubAgentResult,
   type OutlineSubAgentResult,
 } from "./outline-result-protocol"
@@ -339,3 +339,114 @@ function buildSubAgentTaskPrompt(
     }, null, 2),
   ].join("\n")
 }
+
+export interface OutlineMultiAgentResumeInput {
+  /** 上一次运行的完整 plan */
+  plan: OutlineSubAgentPlan[]
+  /** 已成功 Agent 的结果(跳过不重试) */
+  completedResults: OutlineSubAgentResult[]
+  /** 上次运行中失败的 Agent ID 列表 */
+  failedAgentIds: string[]
+  maxConcurrency?: number
+  runSubAgent: (plan: OutlineSubAgentPlan) => Promise<string>
+  mergeResults: (results: OutlineSubAgentResult[]) => Promise<string>
+  onStatusChange?: (event: OutlineSubAgentStatusEvent) => void
+}
+
+/**
+ * 仅重试失败 Agent,已成功 Agent 的结果直接复用。
+ * 最终合并 completedResults + 新成功的失败 Agent 结果。
+ */
+export async function resumeOutlineMultiAgentWorkflow(
+  input: OutlineMultiAgentResumeInput,
+): Promise<OutlineMultiAgentRunResult> {
+  const failedPlan = input.plan.filter((task) =>
+    input.failedAgentIds.includes(task.id),
+  )
+
+  if (failedPlan.length === 0) {
+    // 没有失败 Agent,直接合并已有结果
+    try {
+      const finalText = await input.mergeResults(input.completedResults)
+      return {
+        mode: "multi-agent",
+        finalText,
+        successfulAgents: input.completedResults.map((item) => item.agentId),
+        failedAgents: [],
+        failureDetails: [],
+      }
+    } catch (error) {
+      const detail = formatError(error)
+      return {
+        mode: "single-agent-fallback",
+        finalText: "",
+        successfulAgents: input.completedResults.map((item) => item.agentId),
+        failedAgents: [],
+        fallbackReason: `合并 Agent 失败:${detail}`,
+        failureDetails: [`合并 Agent:${detail}`],
+      }
+    }
+  }
+
+  const maxConcurrency = Math.min(
+    DEFAULT_MAX_CONCURRENCY,
+    Math.max(1, input.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY),
+  )
+
+  // 仅执行失败 Agent
+  const resumeInput: OutlineMultiAgentRunInput = {
+    plan: failedPlan,
+    maxConcurrency,
+    runSubAgent: input.runSubAgent,
+    runSingleAgentFallback: async () => "",
+    mergeResults: input.mergeResults,
+    onStatusChange: input.onStatusChange,
+  }
+
+  const outcomes = await runDependencyGraph(failedPlan, maxConcurrency, resumeInput)
+
+  // 收集本次重试的结果
+  const retriedSuccessful = failedPlan
+    .map((task) => outcomes.get(task.id)?.result)
+    .filter((result): result is OutlineSubAgentResult => Boolean(result))
+
+  const retriedFailures = failedPlan
+    .map((task) => ({ task, error: outcomes.get(task.id)?.error }))
+    .filter((item): item is { task: OutlineSubAgentPlan; error: string } => Boolean(item.error))
+
+  const allSuccessful = [...input.completedResults, ...retriedSuccessful]
+  const stillFailedAgents = retriedFailures.map(({ task }) => task.id)
+  const failureDetails = retriedFailures.map(({ task, error }) => `${task.name}:${error}`)
+
+  if (allSuccessful.length === 0) {
+    return {
+      mode: "single-agent-fallback",
+      finalText: "",
+      successfulAgents: [],
+      failedAgents: stillFailedAgents,
+      fallbackReason: "续传后仍无成功结果。",
+      failureDetails,
+    }
+  }
+
+  try {
+    const finalText = await input.mergeResults(allSuccessful)
+    return {
+      mode: "multi-agent",
+      finalText,
+      successfulAgents: allSuccessful.map((item) => item.agentId),
+      failedAgents: stillFailedAgents,
+      failureDetails,
+    }
+  } catch (error) {
+    const detail = formatError(error)
+    return {
+      mode: "single-agent-fallback",
+      finalText: "",
+      successfulAgents: allSuccessful.map((item) => item.agentId),
+      failedAgents: stillFailedAgents,
+      fallbackReason: `合并 Agent 失败:${detail}`,
+      failureDetails: [...failureDetails, `合并 Agent:${detail}`],
+    }
+  }
+}

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

@@ -65,6 +65,12 @@ export interface OutlineMultiAgentRunState {
   }
   fallbackReason?: string
   failureDetails?: string[]
+  /** 当多 Agent 部分失败时,存储可恢复的 plan 和成功结果,用于"继续未完成的任务" */
+  resumeablePlan?: {
+    plan: unknown[]
+    completedResults: unknown[]
+    failedAgentIds: string[]
+  }
 }
 
 export interface OutlineChatMessage {
@@ -371,6 +377,19 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => {
           novelGenerationRequest: isNovelGenerationRequestPackage(message.novelGenerationRequest)
             ? message.novelGenerationRequest
             : undefined,
+          // 验证 resumeablePlan 数据完整性,清除结构不完整的续传数据
+          multiAgentRun: message.multiAgentRun
+            ? {
+                ...message.multiAgentRun,
+                resumeablePlan: message.multiAgentRun.resumeablePlan
+                  && Array.isArray(message.multiAgentRun.resumeablePlan.plan)
+                  && Array.isArray(message.multiAgentRun.resumeablePlan.completedResults)
+                  && Array.isArray(message.multiAgentRun.resumeablePlan.failedAgentIds)
+                  && message.multiAgentRun.resumeablePlan.failedAgentIds.length > 0
+                  ? message.multiAgentRun.resumeablePlan
+                  : undefined,
+              }
+            : message.multiAgentRun,
         })),
       }))
       const conversationIds = new Set(conversations.map((conversation) => conversation.id))