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

feat(novel): 大纲对话上下文复用与质量检查闭环

- 新增大纲上下文复用策略:后续追问自动复用已有上下文,禁用文件读取类工具,节省 Token
- 历史消息智能压缩:low/medium/high 三档,自动裁剪历史,支持上下文摘要缓存
- 生成后质量检查:自动检查章纲质量,给出可修复项,支持「修订质量问题」一键修复
- 思考过程与工具过程智能显隐:复用模式下隐藏,出错时才显示
- 子 Agent 失败重试:多 Agent 并行生成时结构化输出解析失败自动重试一次
- JSON 输出自动转 Markdown:write_outline_node 工具自动转换 JSON 格式为易读 Markdown
- 保存确认信息增强:显示文件类型、写入方式、来源、引用 skill
- 重复文件名警告:Agent 工作流面板检测同一文件多个写入项并警告
- 文件名规范提示:system prompt 中增加不同角色/类型必须分文件的规范
- 流式 UI 优化:统一「正在生成...」提示条
Mochocyang 3 месяцев назад
Родитель
Сommit
d697f9e2ce

+ 34 - 0
src/components/chat/agent-workflow-panel.tsx

@@ -189,6 +189,28 @@ export function AgentWorkflowPanel({
   const safeToolCalls = toolCalls ?? []
   const pendingApprovalCalls = safeToolCalls.filter((call) => call.status === "approval_required")
 
+  // 检测同批写入调用中是否有重复的大纲文件名
+  const duplicateOutlineCalls = (() => {
+    if (pendingApprovalCalls.length < 2) return []
+    const groupedByFile = new Map<string, typeof pendingApprovalCalls>()
+    for (const call of pendingApprovalCalls) {
+      if (call.name !== "write_outline_node") continue
+      const outlineName = (call.params as Record<string, unknown>)?.outlineName as string | undefined
+      if (!outlineName) continue
+      const existing = groupedByFile.get(outlineName) ?? []
+      existing.push(call)
+      groupedByFile.set(outlineName, existing)
+    }
+    const duplicates: Array<{ outlineName: string; callCount: number }> = []
+    for (const [outlineName, calls] of groupedByFile) {
+      if (calls.length > 1) {
+        duplicates.push({ outlineName, callCount: calls.length })
+      }
+    }
+    return duplicates
+  })()
+
+
   const isStepOpen = (step: AgentWorkflowStep) => {
     if (allOpen) return true
     if (step.id in openMap) return openMap[step.id]
@@ -254,6 +276,18 @@ export function AgentWorkflowPanel({
 
       {pendingApprovalCalls.length > 0 && onConfirmSave && onReject ? (
         <div className="mt-3 space-y-2 rounded-md border border-amber-200 bg-amber-50/70 p-2 text-xs text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-200">
+          {duplicateOutlineCalls.length > 0 ? (
+            <div className="mb-2 rounded-md border border-red-200 bg-red-50/70 p-2 text-xs text-red-800 dark:border-red-900/40 dark:bg-red-950/20 dark:text-red-300">
+              <span className="font-semibold">⚠ 警告:以下文件有多个写入项指向同一文件名:</span>
+              <ul className="mt-1 list-inside list-disc space-y-0.5">
+                {duplicateOutlineCalls.map(({ outlineName, callCount }) => (
+                  <li key={outlineName}>
+                    共 {callCount} 项写入「{outlineName}」,确认前建议先在中部编辑区合并内容
+                  </li>
+                ))}
+              </ul>
+            </div>
+          ) : null}
           {pendingApprovalCalls.map((call) => (
             <div key={call.id} className="flex min-w-0 flex-wrap items-center justify-between gap-2">
               <span className="min-w-0 flex-1 truncate">

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

@@ -89,7 +89,8 @@ describe("OutlineChatPanel controls", () => {
   it("settles running outline tool calls when generation finishes", () => {
     expect(source).toContain("settleRunningAgentToolCalls")
     expect(source).toMatch(/settleRunningAgentToolCalls\(\s*record\.toolCalls\.length\s*\?\s*record\.toolCalls\s*:\s*message\.agentToolCalls/s)
-    expect(source).toMatch(/settleRunningAgentToolCalls\(\s*message\.agentToolCalls,\s*"error"/s)
+    expect(source).toContain("historyPlan.showToolProcessOnError")
+    expect(source).toContain("message.agentToolCalls?.length ? message.agentToolCalls : hiddenToolCalls")
   })
 
   it("uses an outline-only tool set that cannot write chapters or memory", () => {
@@ -102,6 +103,38 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("用户确认前不得生成完整文件")
   })
 
+  it("后续普通追问复用 AI 大纲上下文并节流资料读取工具", () => {
+    expect(source).toContain("planOutlineContextReuse")
+    expect(source).toContain("planOutlineAgentHistory")
+    expect(source).toContain("buildOutlineContextSummary")
+    expect(source).toContain("contextDecision")
+    expect(source).toContain("historyPlan")
+    expect(source).toContain("contextDecision.instruction")
+    expect(source).toContain("contextDecision.disabledTools")
+    expect(source).toContain("contextDecision.sourceLabel")
+    expect(source).toContain("historyPlan.messages")
+    expect(source).toContain("hiddenToolCalls")
+    expect(source).toContain("mergeDisabledTools")
+  })
+
+  it("提供 AI 大纲上下文状态、强制刷新和预算面板", () => {
+    // 已删除上下文状态条,不再展示 "上下文状态" 和 "强制刷新上下文"
+    // 改为在输入框之上展示 "正在生成..." 状态提示
+    expect(source).not.toContain("上下文状态")
+    expect(source).not.toContain("强制刷新上下文")
+    expect(source).toContain("正在生成...")
+    expect(source).toContain("isStreaming")
+  })
+
+  it("将 AI 大纲上下文摘要持久化到会话字段而不是组件内存缓存", () => {
+    expect(source).toContain("contextSummary:")
+    expect(source).toContain("buildOutlineContextSummary")
+    // 上下文摘要已通过 setConversationContextSummary 持久化到会话字段
+    expect(source).toContain("setConversationContextSummary")
+    expect(source).not.toContain("contextSummaryByConversation")
+    expect(source).not.toContain("setContextSummaryByConversation")
+  })
+
   it("keeps outline reference chips as tool-readable hints instead of preloading file contents", () => {
     expect(source).toContain("buildOutlineAgentUserContent")
     expect(source).toContain("请优先使用工具读取引用内容")
@@ -198,6 +231,13 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("fallbackReason")
   })
 
+  it("子 Agent 结构化输出为空或解析失败时会自动重试一次", () => {
+    expect(source).toContain("retrySubAgentMessages")
+    expect(source).toContain("结构化输出解析失败")
+    expect(source).toContain("请只重新输出一个合法 JSON 对象")
+    expect(source).toContain("subAgentRetryRun")
+  })
+
   it("keeps wizard prompt bubbles readable and stops streaming in the original conversation", () => {
     expect(source).toContain("streamingConversationIdRef")
     expect(source).toContain("streamingConversationIdRef.current = convId")
@@ -217,12 +257,21 @@ describe("OutlineChatPanel controls", () => {
 
   it("auto-saves structured AI outline save requests from assistant output", () => {
     expect(source).toContain("parseOutlineSaveRequests")
+    expect(source).toContain("formatOutlineSaveParseFeedback")
     expect(source).toContain("saveOutlineSaveRequests")
     expect(source).toContain("outlineSaveRequest")
     expect(source).toContain("已自动保存")
     expect(source).toContain("AI 大纲输出协议")
   })
 
+  it("生成后对可保存大纲内容输出质量检查反馈并支持继续修订", () => {
+    expect(source).toContain("buildOutlineGenerationQualityFeedback")
+    expect(source).toContain("qualityFeedback")
+    expect(source).toContain("生成后质量检查")
+    expect(source).toContain("修订质量问题")
+    expect(source).toContain("repairPrompt")
+  })
+
   it("uses save confirm dialog for classified outline saves", () => {
     expect(source).toContain("OutlineSaveConfirmDialog")
     expect(source).toContain("extractCharacterSaveDrafts")

+ 211 - 57
src/components/sources/outline-chat-panel.tsx

@@ -66,11 +66,14 @@ import {
 } from "@/lib/novel/character-save-extractor";
 import { classifyOutlineSaveTarget } from "@/lib/novel/outline-save-classifier";
 import {
+  buildOutlineGenerationQualityFeedback,
   formatChapterOutlineQualityReport,
+  type OutlineGenerationQualityFeedback,
   summarizeChapterOutlineQuality,
 } from "@/lib/novel/outline-quality-check";
 import {
   characterDraftsToSaveRequests,
+  formatOutlineSaveParseFeedback,
   type OutlineSaveRequest,
   parseOutlineSaveRequests,
   saveOutlineSaveRequests,
@@ -124,6 +127,11 @@ import {
   collectWebResearch,
   shouldUseWebResearch,
 } from "@/lib/web-research";
+import {
+  buildOutlineContextSummary,
+  planOutlineAgentHistory,
+  planOutlineContextReuse,
+} from "@/lib/novel/outline-context-reuse";
 import {
   getConversationTabTitle,
   splitConversationToolbarItems,
@@ -135,6 +143,11 @@ const OUTLINE_CHAT_WIZARD_DISABLED_TOOLS = [
   ...OUTLINE_CHAT_DISABLED_TOOLS,
   "write_outline_node",
 ];
+
+function mergeDisabledTools(...groups: Array<readonly string[] | undefined>): string[] {
+  return Array.from(new Set(groups.flatMap((group) => group ?? [])));
+}
+
 const OUTLINE_CHAT_SKILL_ROUTES = [
   "outline",
   "setting",
@@ -250,6 +263,7 @@ function buildOutlineAgentSystemPrompt(options: {
     "## AI 大纲输出协议",
     "当本轮生成了可保存的大纲、卷纲、章纲、人物、设定、伏笔、组织或质量检查内容时,最终回复末尾必须附加一个 json 代码块,顶层字段为 outlineSaveRequest 或 outlineSaveRequests。",
     "保存请求必须包含 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent、content。fileName 必须是 .md 文件,targetFolder 必须位于大纲文件树文件夹内。",
+    "文件名规范:不同类型内容必须使用不同文件名,禁止多项内容写入同一文件。不同角色必须每人一个独立文件(如 角色-主角林风.md、角色-反官方傲.md),严禁将所有角色塞入「角色卡.md」或同一文件。不同势力、不同伏笔、不同卷纲、不同章纲也必须各自独立文件。",
     "最终回复只输出大纲标题和大纲正文;如果内容需要自动保存,末尾附加 AI 大纲输出协议 JSON 保存块。禁止输出工具调用报告、分析过程、完成报告、下一步行动、无法直接保存的大段说明。",
     "工具调用过程只应展示在工具调用 UI 中,不要混入最终正文。资料不足以生成完整正文时,先提出最少必要澄清问题,不要用流程说明冒充生成结果。",
     "所有面向用户的回复必须使用中文。",
@@ -621,6 +635,8 @@ function OutlineAssistantMessage({
     () => separateThinking(displayContent),
     [displayContent],
   );
+  const visibleThinking =
+    msg.showThinkingProcess === false ? null : thinking;
   const actionContent = answer || displayContent;
 
   // Parse for file edits
@@ -658,8 +674,8 @@ function OutlineAssistantMessage({
 
   return (
     <>
-      {thinking ? (
-        <OutlineThinkingBlock content={thinking} open={isStreaming} />
+      {visibleThinking ? (
+        <OutlineThinkingBlock content={visibleThinking} open={isStreaming} />
       ) : null}
       <OutlineMultiAgentPanel run={msg.multiAgentRun} />
       <AgentToolCallMessage
@@ -847,6 +863,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const setConversationModel = useOutlineChatStore(
     (s) => s.setConversationModel,
   );
+  const setConversationContextSummary = useOutlineChatStore(
+    (s) => s.setConversationContextSummary,
+  );
   const setStreamingContent = useOutlineChatStore((s) => s.setStreamingContent);
   const setIsStreaming = useOutlineChatStore((s) => s.setIsStreaming);
   const pendingReferenceTokens = useOutlineChatStore(
@@ -892,6 +911,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const [outlineReferenceTokens, setOutlineReferenceTokens] = useState<
     ReferenceToken[]
   >([]);
+  const [forceRefreshNext, setForceRefreshNext] = useState(false);
   const [referencePickerOpen, setReferencePickerOpen] = useState(false);
   const [outlineWizardOpen, setOutlineWizardOpen] = useState(false);
   const [localModelId, setLocalModelId] = useState(activeConv?.modelId ?? "");
@@ -1043,6 +1063,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   }, [activeConversationId]);
 
   const [saveStatus, setSaveStatus] = useState("");
+  const [qualityFeedbackState, setQualityFeedbackState] =
+    useState<OutlineGenerationQualityFeedback | null>(null);
   const [saveConfirmState, setSaveConfirmState] = useState<{
     title: string;
     mode: "normal" | "character";
@@ -1132,12 +1154,31 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       const parsed = parseOutlineSaveRequests(assistantContent);
       if (parsed.requests.length === 0) {
         if (parsed.errors.length > 0) {
-          setSaveStatus(`自动保存失败:${parsed.errors.slice(0, 2).join(";")}`);
+          setSaveStatus(formatOutlineSaveParseFeedback(parsed.errors));
         }
         return;
       }
 
-      setSaveStatus("正在自动保存大纲...");
+      const qualityFeedback = parsed.requests
+        .map((request) =>
+          buildOutlineGenerationQualityFeedback({
+            fileType: request.fileType,
+            fileName: request.fileName,
+            content: request.content,
+          }),
+        )
+        .find((feedback): feedback is OutlineGenerationQualityFeedback =>
+          Boolean(feedback && feedback.status !== "pass"),
+        );
+      if (qualityFeedback) {
+        setQualityFeedbackState(qualityFeedback);
+      }
+
+      setSaveStatus(
+        qualityFeedback
+          ? `${qualityFeedback.title}:${qualityFeedback.summary}`
+          : "正在自动保存大纲...",
+      );
       try {
         const projectPath = normalizePath(project.path);
         const split = splitConfirmRequiredSaveRequests(parsed.requests);
@@ -1184,7 +1225,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           const skipped = saveResult.skipped.length > 0
             ? `;${saveResult.skipped.slice(0, 2).join(";")}`
             : "";
-          setSaveStatus(`已自动保存 ${saveResult.saved.length} 个大纲文件:${names}${skipped}`);
+          const qualityText = qualityFeedback
+            ? `;${qualityFeedback.title}发现可修复项,可点击“修订质量问题”。`
+            : "";
+          setSaveStatus(`已自动保存 ${saveResult.saved.length} 个大纲文件:${names}${skipped}${qualityText}`);
           return;
         }
         if (saveResult.skipped.length > 0) {
@@ -1209,6 +1253,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         disableWriteTools?: boolean;
         preferredSkillNames?: string[];
         enableMultiAgent?: boolean;
+        forceRefresh?: boolean;
       } = {},
     ) => {
       const prompt = inputText.trim();
@@ -1268,6 +1313,32 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               content: message.content,
             }) satisfies AgentMessage,
         );
+      const hasPriorAssistantAnswer = historyBeforeSend.some(
+        (message) => message.role === "assistant" && message.content.trim(),
+      );
+      const forceRefresh = options.forceRefresh === true || forceRefreshNext;
+      const contextDecision = planOutlineContextReuse({
+        hasPriorAssistantAnswer,
+        attachedReferenceCount: tokens.length,
+        inputText: prompt,
+        enableMultiAgent: options.enableMultiAgent,
+        forceRefresh,
+      });
+      const cachedSummary =
+        contextDecision.mode === "reuse"
+          ? useOutlineChatStore
+              .getState()
+              .conversations.find((conversation) => conversation.id === convId)
+              ?.contextSummary
+          : undefined;
+      const historyPlan = planOutlineAgentHistory({
+        history: historyBeforeSend,
+        contextDecision,
+        cachedSummary,
+      });
+      if (forceRefreshNext) {
+        setForceRefreshNext(false);
+      }
       const userMsg: OutlineChatMessage = {
         id: crypto.randomUUID(),
         role: "user",
@@ -1278,20 +1349,27 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         (token) =>
           `@${referenceCategoryLabel(token.category)}: ${token.title || token.displayTitle}`,
       );
+      const contextSources = [
+        `上下文: ${contextDecision.sourceLabel}`,
+        `原因: ${contextDecision.reason}`,
+        ...historyPlan.sources,
+      ];
       const assistantId = crypto.randomUUID();
       addMessage(convId, userMsg);
       addMessage(convId, {
         id: assistantId,
         role: "assistant",
         content: "",
-        sources: initialSources,
+        sources: [...contextSources, ...initialSources],
         agentToolCalls: [],
+        showThinkingProcess: historyPlan.showThinkingProcess,
         isAgentRunning: true,
       });
       setIsStreaming(true);
       setStreamingContent("");
       streamingConversationIdRef.current = convId;
       userScrolledUpRef.current = false;
+      let hiddenToolCalls: AgentRunRecord["toolCalls"] = [];
 
       try {
         let webResearchMarkdown = "";
@@ -1322,10 +1400,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           projectName: project.name,
           webResearchContext: webResearchMarkdown,
           soulDoc,
-        });
+        }) + `\n\n## 本轮上下文策略\n${contextDecision.instruction}\n\n${historyPlan.instruction}`;
         const agentMessages: AgentMessage[] = [
           { role: "system", content: systemPrompt },
-          ...historyBeforeSend,
+          ...historyPlan.messages,
           {
             role: "user",
             content: buildOutlineAgentUserContent(prompt, tokens),
@@ -1376,9 +1454,12 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                     })),
                   })),
               llmConfig: effectiveLlmConfig,
-              disabledTools: disableWriteTools
-                ? OUTLINE_CHAT_WIZARD_DISABLED_TOOLS
-                : OUTLINE_CHAT_DISABLED_TOOLS,
+              disabledTools: mergeDisabledTools(
+                disableWriteTools
+                  ? OUTLINE_CHAT_WIZARD_DISABLED_TOOLS
+                  : OUTLINE_CHAT_DISABLED_TOOLS,
+                contextDecision.disabledTools,
+              ),
             },
           );
           return { agentConfig, registry };
@@ -1418,6 +1499,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               onToolResult: () => {},
               onToolError: () => {},
               onToolEvent: (event) => {
+                if (!historyPlan.showToolProcess) {
+                  hiddenToolCalls = applyAgentToolEvent(hiddenToolCalls, event);
+                  return;
+                }
                 updateOutlineAssistantMessage(convId, assistantId, (message) => ({
                   ...message,
                   agentToolCalls: applyAgentToolEvent(
@@ -1483,7 +1568,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                     "必须输出符合 AI 大纲子 Agent JSON 协议的 JSON,不要输出额外说明。",
                   ].join("\n"),
                 },
-                ...historyBeforeSend,
+                ...historyPlan.messages,
                 {
                   role: "user",
                   content: buildOutlineAgentUserContent(
@@ -1515,38 +1600,67 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 usedSkills: subAgentPlan.skillNames,
                 stage: subAgentPlan.kind,
               });
-              if (!parsed.ok) {
+              let finalParsed = parsed;
+              if (!finalParsed.ok) {
+                const retrySubAgentMessages: AgentMessage[] = [
+                  ...subAgentMessages,
+                  {
+                    role: "assistant",
+                    content: subRun.text || "[空输出]",
+                  },
+                  {
+                    role: "user",
+                    content: [
+                      `结构化输出解析失败:${finalParsed.error}`,
+                      "请只重新输出一个合法 JSON 对象,不要输出 Markdown 代码围栏,不要输出解释说明。",
+                      "必须包含字段:agent_id、agent_name、stage、used_skills、confidence、summary、content_markdown、constraints、writeback_items、risks、questions。",
+                    ].join("\n"),
+                  },
+                ];
+                const subAgentRetryRun = await runOutlineAgentOnce(retrySubAgentMessages, {
+                  skillNames: subAgentPlan.skillNames,
+                  disableWriteTools: true,
+                  statusText: `多 Agent 并行生成中...\n正在重试:${subAgentPlan.name}`,
+                });
+                finalParsed = coerceOutlineSubAgentResult(subAgentRetryRun.text, {
+                  agentId: subAgentPlan.id,
+                  agentName: subAgentPlan.name,
+                  usedSkills: subAgentPlan.skillNames,
+                  stage: subAgentPlan.kind,
+                });
+              }
+              if (!finalParsed.ok) {
                 updateOutlineMultiAgentItem(convId, assistantId, subAgentPlan.id, (agent) => ({
                   ...agent,
                   status: "error",
-                  error: parsed.error,
+                  error: finalParsed.error,
                   finishedAt: Date.now(),
                 }));
-                throw new Error(parsed.error);
+                throw new Error(finalParsed.error);
               }
               updateOutlineMultiAgentItem(convId, assistantId, subAgentPlan.id, (agent) => ({
                 ...agent,
                 status: "done",
-                summary: parsed.value.summary || "已完成本 Agent 负责内容。",
+                summary: finalParsed.value.summary || "已完成本 Agent 负责内容。",
                 finishedAt: Date.now(),
               }));
               return JSON.stringify({
-                agent_id: parsed.value.agentId,
-                agent_name: parsed.value.agentName,
-                stage: parsed.value.stage,
-                used_skills: parsed.value.usedSkills,
-                confidence: parsed.value.confidence,
-                summary: parsed.value.summary,
-                content_markdown: parsed.value.contentMarkdown,
-                constraints: parsed.value.constraints,
-                writeback_items: parsed.value.writebackItems.map((item) => ({
+                agent_id: finalParsed.value.agentId,
+                agent_name: finalParsed.value.agentName,
+                stage: finalParsed.value.stage,
+                used_skills: finalParsed.value.usedSkills,
+                confidence: finalParsed.value.confidence,
+                summary: finalParsed.value.summary,
+                content_markdown: finalParsed.value.contentMarkdown,
+                constraints: finalParsed.value.constraints,
+                writeback_items: finalParsed.value.writebackItems.map((item) => ({
                   type: item.type,
                   name: item.name,
                   content: item.content,
                   target_folder: item.targetFolder,
                 })),
-                risks: parsed.value.risks,
-                questions: parsed.value.questions,
+                risks: finalParsed.value.risks,
+                questions: finalParsed.value.questions,
               });
             },
             runSingleAgentFallback: async () => {
@@ -1587,7 +1701,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                     "输出必须是用户可直接阅读和保存的大纲正文,不要输出内部调度报告。",
                   ].join("\n"),
                 },
-                ...historyBeforeSend,
+                ...historyPlan.messages,
                 {
                   role: "user",
                   content: [
@@ -1653,20 +1767,36 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
 
         const finalSources = Array.from(
           new Set([
+            ...contextSources,
             ...outlineSources,
             ...outlineToolCallsToSources(allToolCalls),
           ]),
         );
         const finalContent = finalText || result || "AI大纲未返回内容。";
+        const visibleToolCalls = allToolCalls.length ? allToolCalls : [];
+        const shouldShowToolProcess =
+          historyPlan.showToolProcess ||
+          visibleToolCalls.some((call) => call.status === "approval_required");
         updateOutlineAssistantMessage(convId, assistantId, (message) => ({
           ...message,
           content: finalContent,
           sources: finalSources,
-          agentToolCalls: settleRunningAgentToolCalls(
-            allToolCalls.length ? allToolCalls : message.agentToolCalls,
-          ),
+          showThinkingProcess: historyPlan.showThinkingProcess,
+          agentToolCalls: shouldShowToolProcess
+            ? settleRunningAgentToolCalls(
+                allToolCalls.length ? allToolCalls : message.agentToolCalls,
+              )
+            : [],
           isAgentRunning: false,
         }));
+        const nextContextSummaryPayload = {
+          contextSummary: buildOutlineContextSummary([
+            ...historyBeforeSend,
+            { role: "user", content: prompt },
+            { role: "assistant", content: finalContent },
+          ]),
+        };
+        setConversationContextSummary(convId, nextContextSummaryPayload.contextSummary);
         await handleAutoSaveOutlineRequests(finalContent);
         const firstUser = useOutlineChatStore
           .getState()
@@ -1694,10 +1824,14 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           updateOutlineAssistantMessage(convId, assistantId, (message) => ({
             ...message,
             content: partial,
-            agentToolCalls: settleRunningAgentToolCalls(message.agentToolCalls, "error",
-              Date.now(),
-              err instanceof Error ? err.message : String(err),
-            ),
+            agentToolCalls: historyPlan.showToolProcessOnError
+              ? settleRunningAgentToolCalls(
+                  message.agentToolCalls?.length ? message.agentToolCalls : hiddenToolCalls,
+                  "error",
+                  Date.now(),
+                  err instanceof Error ? err.message : String(err),
+                )
+              : [],
             isAgentRunning: false,
           }));
         } else {
@@ -1706,10 +1840,14 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             updateOutlineAssistantMessage(convId, assistantId, (message) => ({
               ...message,
               content: `生成失败:${errorMsg}`,
-                agentToolCalls: settleRunningAgentToolCalls(message.agentToolCalls, "error",
-                Date.now(),
-                errorMsg,
-              ),
+              agentToolCalls: historyPlan.showToolProcessOnError
+                ? settleRunningAgentToolCalls(
+                    message.agentToolCalls?.length ? message.agentToolCalls : hiddenToolCalls,
+                    "error",
+                    Date.now(),
+                    errorMsg,
+                  )
+                : [],
               isAgentRunning: false,
             }));
           } else {
@@ -1735,7 +1873,9 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       activeConv,
       activeConversationId,
       createConversation,
+      forceRefreshNext,
       addMessage,
+      setConversationContextSummary,
       replaceLastAssistant,
       removeLastMessage,
       handleAutoSaveOutlineRequests,
@@ -2062,6 +2202,14 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         if (classification.fileType === "chapter-outline") {
           const quality = summarizeChapterOutlineQuality(mdContent);
           if (!quality.valid) {
+            const qualityFeedback = buildOutlineGenerationQualityFeedback({
+              fileType: classification.fileType,
+              fileName: classification.fileName,
+              content: mdContent,
+            });
+            if (qualityFeedback) {
+              setQualityFeedbackState(qualityFeedback);
+            }
             setSaveStatus(formatChapterOutlineQualityReport(quality, {
               maxIssues: 4,
               includeWarnings: true,
@@ -2148,6 +2296,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     void useOutlineChatStore.getState().saveToDisk();
   }, []);
 
+  const handleRepairQualityFeedback = useCallback(() => {
+    const repairPrompt = qualityFeedbackState?.repairPrompt;
+    if (!repairPrompt) return;
+    setQualityFeedbackState(null);
+    void handleSend(repairPrompt, [], { forceRefresh: true });
+  }, [handleSend, qualityFeedbackState]);
+
   return (
     <div className="flex h-full flex-col overflow-hidden border-border bg-background">
       {/* Header with conversation tabs */}
@@ -2271,6 +2426,18 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             : null}
         </div>
         <div className="ml-auto flex shrink-0 items-center gap-1">
+          {qualityFeedbackState && qualityFeedbackState.status !== "pass" ? (
+            <button
+              type="button"
+              disabled={isStreaming}
+              onClick={handleRepairQualityFeedback}
+              aria-label="修订生成后质量检查发现的问题"
+              className="rounded-md border border-amber-300 bg-amber-50 px-2 py-1 text-xs text-amber-800 hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50"
+              title={qualityFeedbackState.summary}
+            >
+              修订质量问题
+            </button>
+          ) : null}
           {saveStatus && (
             <span className="text-xs text-muted-foreground">{saveStatus}</span>
           )}
@@ -2339,24 +2506,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             </div>
           </div>
         ))}
-        {isStreaming &&
-        streamingContent &&
-        activeMessages.length > 0 &&
-        activeMessages[activeMessages.length - 1]?.content ===
-          "" ? null : isStreaming &&
-          streamingContent &&
-          activeMessages[activeMessages.length - 1]?.role !== "assistant" ? (
-          <div className="flex justify-start">
-            <div className="w-fit max-w-full lg:max-w-[50vw] rounded-lg bg-muted px-3 py-2 text-sm text-foreground">
-              <div
-                className="prose prose-sm dark:prose-invert max-w-none break-words"
-                style={{ overflowWrap: "anywhere", wordBreak: "break-word" }}
-              >
-                <ReactMarkdown>{normalizeOutlineMarkdown(streamingContent)}</ReactMarkdown>
-              </div>
-            </div>
-          </div>
-        ) : null}
       </div>
 
       {/* Input */}
@@ -2372,6 +2521,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             </div>
           </TooltipProvider>
         </div>
+        {isStreaming && (
+          <div className="mb-2 animate-pulse rounded-md border border-sky-200 bg-sky-50 px-3 py-2 text-xs text-sky-700 dark:border-sky-800 dark:bg-sky-950/30 dark:text-sky-300">
+            <span className="font-medium">正在生成...</span>
+          </div>
+        )}
         <div className="mb-2 flex items-center justify-between gap-2 rounded-lg border bg-muted/30 px-2 py-1.5">
           <div className="min-w-0 text-xs text-muted-foreground">
             通过固定选项生成大纲需求,再交给 AI 分析和追问

+ 30 - 0
src/components/sources/outline-save-confirm-dialog.spec.tsx

@@ -130,6 +130,36 @@ describe("OutlineSaveConfirmDialog", () => {
     expect(document.body.textContent).toContain("章纲")
   })
 
+  it("非人物保存预览展示类型、写入方式、来源和引用 skill", async () => {
+    await act(async () => {
+      root.render(
+        <OutlineSaveConfirmDialog
+          open
+          title="保存章纲"
+          mode="normal"
+          characterDrafts={[]}
+          requests={[{
+            targetFolder: "章纲",
+            fileName: "章纲-第001章.md",
+            fileType: "chapter-outline",
+            writeMode: "create",
+            referencedSkills: ["ZhanggangSkill/chapter-outline-builder"],
+            sourceIntent: "生成第001章章纲",
+            content: "正文",
+          }]}
+          onClose={() => {}}
+          onConfirm={() => {}}
+        />,
+      )
+    })
+
+    const text = document.body.textContent ?? ""
+    expect(text).toContain("类型:chapter-outline")
+    expect(text).toContain("写入方式:create")
+    expect(text).toContain("来源:生成第001章章纲")
+    expect(text).toContain("引用 skill:ZhanggangSkill/chapter-outline-builder")
+  })
+
   it("非人物保存时允许用户选择目标文件夹并提交选择结果", async () => {
     const onConfirm = vi.fn()
     await act(async () => {

+ 10 - 0
src/components/sources/outline-save-confirm-dialog.tsx

@@ -139,6 +139,16 @@ export function OutlineSaveConfirmDialog({
                   className="rounded-md border px-3 py-2"
                 >
                   <div className="font-medium">{request.fileName}</div>
+                  <div className="mt-1 grid gap-0.5 text-xs text-muted-foreground">
+                    <div>类型:{request.fileType}</div>
+                    <div>写入方式:{request.writeMode}</div>
+                    <div>来源:{request.sourceIntent || "未标注"}</div>
+                    <div>
+                      引用 skill:{request.referencedSkills.length > 0
+                        ? request.referencedSkills.join("、")
+                        : "无"}
+                    </div>
+                  </div>
                   <div className="mt-2 grid gap-1.5 text-xs text-muted-foreground">
                     <label htmlFor={`outline-save-folder-${index}`}>
                       保存文件夹

+ 22 - 0
src/lib/agent/tools/write-outline-node.spec.ts

@@ -17,6 +17,28 @@ describe("write-outline-node helpers", () => {
     expect(content).toBe("## 第1章\n\n正文\n")
   })
 
+  it("将结构化 JSON 节点内容转换为可阅读 Markdown,避免把 JSON 原文写入大纲", () => {
+    const content = buildOutlineNodeWriteContent(
+      "白莲教圣公",
+      JSON.stringify({
+        角色名: "白莲教圣公",
+        定位: "第二方势力首领",
+        核心目标: ["夺取龙脉", "逼主角暴露底牌"],
+        关系: {
+          主角: "表面合作,暗中试探",
+          反派: "互相利用",
+        },
+      }),
+    )
+
+    expect(content).toContain("## 白莲教圣公")
+    expect(content).toContain("### 核心目标")
+    expect(content).toContain("- 夺取龙脉")
+    expect(content).toContain("### 关系")
+    expect(content).toContain("- 主角:表面合作,暗中试探")
+    expect(content.trim()).not.toMatch(/^\{/)
+  })
+
   it("拒绝不安全的大纲写入目标", () => {
     expect(validateOutlineWriteTarget("../章纲.md")).toContain("上级目录")
     expect(validateOutlineWriteTarget("C:/Book/章纲.md")).toContain("绝对路径")

+ 68 - 0
src/lib/agent/tools/write-outline-node.ts

@@ -2,9 +2,77 @@ import type { Tool } from "../types"
 import { readFile, writeFile, fileExists } from "@/commands/fs"
 import { isLikelyChapterOutline, summarizeChapterOutlineQuality } from "@/lib/novel/outline-quality-check"
 
+function isJsonContent(text: string): boolean {
+  const trimmed = text.trim()
+  return (trimmed.startsWith("{") && trimmed.endsWith("}")) ||
+    (trimmed.startsWith("[") && trimmed.endsWith("]"))
+}
+
+function convertJsonToMarkdown(jsonText: string): string {
+  try {
+    const parsed = JSON.parse(jsonText)
+    // Start at depth 3 because nodeTitle already uses ## (depth 2)
+    return jsonObjectToMarkdown(parsed, 3)
+  } catch {
+    return jsonText
+  }
+}
+
+function jsonObjectToMarkdown(value: unknown, depth: number): string {
+  const heading = (): string => "#".repeat(Math.min(depth, 5))
+  const indent = (): string => "  ".repeat(Math.max(0, depth - 2))
+
+  if (value === null || value === undefined) return "(空)"
+  if (typeof value === "string") return value
+  if (typeof value === "number" || typeof value === "boolean") return String(value)
+
+  if (Array.isArray(value)) {
+    if (value.length === 0) return "(空列表)"
+    return value.map((item) => {
+      if (typeof item === "object" && item !== null) {
+        const keys = Object.keys(item as Record<string, unknown>)
+        if (keys.length <= 2) {
+          // Simple objects like {主角: "xxx", 反派: "xxx"} → bullet with colon
+          return `${indent()}- ${Object.entries(item as Record<string, unknown>)
+            .map(([k, v]) => `${k}:${jsonObjectToMarkdown(v, depth + 1)}`)
+            .join(",")}`
+        }
+        return `${indent()}- ${jsonObjectToMarkdown(item, depth + 1)}`
+      }
+      return `${indent()}- ${jsonObjectToMarkdown(item, depth + 1)}`
+    }).join("\n")
+  }
+
+  if (typeof value === "object") {
+    const obj = value as Record<string, unknown>
+    const keys = Object.keys(obj)
+    if (keys.length === 0) return "(空对象)"
+    return keys.map((key) => {
+      const val = obj[key]
+      if (typeof val === "object" && val !== null && !Array.isArray(val)) {
+        // Nested object → heading + sub bullets
+        return `${heading()} ${key}\n\n${Object.entries(val as Record<string, unknown>)
+          .map(([sk, sv]) => `${indent()}- ${sk}:${jsonObjectToMarkdown(sv, depth + 1)}`)
+          .join("\n")}`
+      }
+      return `${heading()} ${key}\n\n${jsonObjectToMarkdown(val, depth + 1)}`
+    }).join("\n\n")
+  }
+
+  return String(value)
+}
+
 export function buildOutlineNodeWriteContent(nodeTitle: string, nodeContent: string): string {
   const trimmed = nodeContent.trim()
+  // If content is already pure Markdown headings, preserve it
   if (/^#{1,6}\s+/.test(trimmed)) return `${trimmed}\n`
+  // If content looks like JSON, convert to readable Markdown
+  if (isJsonContent(trimmed)) {
+    const markdown = convertJsonToMarkdown(trimmed)
+    if (markdown !== trimmed) {
+      return `## ${nodeTitle}\n\n${markdown}\n`
+    }
+  }
   return `## ${nodeTitle}\n\n${trimmed}\n`
 }
 

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

@@ -0,0 +1,195 @@
+import { describe, expect, it } from "vitest"
+import {
+  buildOutlineContextSummary,
+  estimateOutlineContextBudget,
+  OUTLINE_CONTEXT_REUSE_DISABLED_TOOLS,
+  planOutlineAgentHistory,
+  planOutlineContextReuse,
+} from "./outline-context-reuse"
+
+describe("AI 大纲上下文复用策略", () => {
+  it("首次生成时刷新上下文并保留读取工具", () => {
+    const decision = planOutlineContextReuse({
+      hasPriorAssistantAnswer: false,
+      attachedReferenceCount: 0,
+      inputText: "生成一本玄幻小说大纲",
+      enableMultiAgent: true,
+    })
+
+    expect(decision.mode).toBe("refresh")
+    expect(decision.disabledTools).toEqual([])
+    expect(decision.sourceLabel).toContain("本轮将读取")
+  })
+
+  it("后续普通追问时复用已有上下文并禁用资料读取工具", () => {
+    const decision = planOutlineContextReuse({
+      hasPriorAssistantAnswer: true,
+      attachedReferenceCount: 0,
+      inputText: "继续把刚才的主角动机说清楚",
+      enableMultiAgent: false,
+    })
+
+    expect(decision.mode).toBe("reuse")
+    expect(decision.disabledTools).toEqual(OUTLINE_CONTEXT_REUSE_DISABLED_TOOLS)
+    expect(decision.instruction).toContain("不要重新读取项目资料")
+    expect(decision.sourceLabel).toContain("已复用上次上下文")
+  })
+
+  it("后续追问带引用或明确要求读取时刷新上下文", () => {
+    expect(planOutlineContextReuse({
+      hasPriorAssistantAnswer: true,
+      attachedReferenceCount: 1,
+      inputText: "结合这个章纲继续分析",
+      enableMultiAgent: false,
+    }).mode).toBe("refresh")
+
+    expect(planOutlineContextReuse({
+      hasPriorAssistantAnswer: true,
+      attachedReferenceCount: 0,
+      inputText: "重新读取大纲文件后再分析",
+      enableMultiAgent: false,
+    }).mode).toBe("refresh")
+  })
+
+  it("用户手动强制刷新时刷新上下文并说明来源", () => {
+    const decision = planOutlineContextReuse({
+      hasPriorAssistantAnswer: true,
+      attachedReferenceCount: 0,
+      inputText: "继续优化主角动机",
+      enableMultiAgent: false,
+      forceRefresh: true,
+    })
+
+    expect(decision.mode).toBe("refresh")
+    expect(decision.reason).toContain("用户手动要求")
+    expect(decision.sourceLabel).toContain("强制刷新")
+  })
+
+  it("复用模式下裁剪长历史,只保留首轮目标、最近结论和最近对话", () => {
+    const history = [
+      { role: "user" as const, content: "我要写一本男频玄幻长篇,核心是废材逆袭。" },
+      { role: "assistant" as const, content: "完整大纲:" + "世界观设定。".repeat(120) },
+      { role: "user" as const, content: "继续补充主角。" },
+      { role: "assistant" as const, content: "主角设定:" + "动机与成长。".repeat(120) },
+      { role: "user" as const, content: "继续补反派。" },
+      { role: "assistant" as const, content: "反派设定:" + "压迫与冲突。".repeat(120) },
+      { role: "user" as const, content: "刚才那版动机再清楚一点。" },
+      { role: "assistant" as const, content: "最新结论:" + "动机来自家族危机。".repeat(80) },
+    ]
+
+    const plan = planOutlineAgentHistory({
+      history,
+      contextDecision: planOutlineContextReuse({
+        hasPriorAssistantAnswer: true,
+        attachedReferenceCount: 0,
+        inputText: "继续优化主角动机",
+        enableMultiAgent: false,
+      }),
+    })
+
+    expect(plan.level).toBe("high")
+    expect(plan.messages[0]).toEqual(history[0])
+    expect(plan.messages.length).toBeLessThan(history.length)
+    expect(plan.messages[plan.messages.length - 1]?.content).toContain("最新结论")
+    expect(plan.instruction).toContain("已压缩历史上下文")
+  })
+
+  it("复用长历史时优先注入摘要缓存", () => {
+    const history = [
+      { role: "user" as const, content: "我要写一本女频仙侠短篇。" },
+      { role: "assistant" as const, content: "设定:" + "师门、禁术、情感拉扯。".repeat(100) },
+      { role: "user" as const, content: "继续补反派。" },
+      { role: "assistant" as const, content: "反派:" + "旧情、权力、背叛。".repeat(100) },
+    ]
+    const summary = buildOutlineContextSummary(history)
+
+    const plan = planOutlineAgentHistory({
+      history,
+      contextDecision: planOutlineContextReuse({
+        hasPriorAssistantAnswer: true,
+        attachedReferenceCount: 0,
+        inputText: "继续",
+        enableMultiAgent: false,
+      }),
+      cachedSummary: summary,
+    })
+
+    expect(summary).toContain("上下文摘要缓存")
+    expect(plan.messages[0].role).toBe("assistant")
+    expect(plan.messages[0].content).toContain("上下文摘要缓存")
+    expect(plan.sources).toContain("摘要: 已复用上下文摘要缓存")
+  })
+
+  it("估算上下文预算并计算压缩节省", () => {
+    const original = [
+      { role: "user" as const, content: "生成大纲" + "需求".repeat(500) },
+      { role: "assistant" as const, content: "大纲" + "内容".repeat(1000) },
+    ]
+    const planned = [
+      { role: "assistant" as const, content: "上下文摘要缓存:保留核心需求。" },
+    ]
+
+    const budget = estimateOutlineContextBudget({ original, planned })
+
+    expect(budget.originalTokens).toBeGreaterThan(budget.plannedTokens)
+    expect(budget.savedTokens).toBeGreaterThan(0)
+    expect(budget.label).toContain("预计节省")
+  })
+
+  it("按中文、英文单词和标点分层估算 token", () => {
+    const chinese = estimateOutlineContextBudget({
+      original: [{ role: "user" as const, content: "主角进入宗门,发现师尊隐瞒禁术真相。" }],
+      planned: [],
+    })
+    const english = estimateOutlineContextBudget({
+      original: [{ role: "user" as const, content: "The protagonist enters the sect and finds the hidden truth." }],
+      planned: [],
+    })
+    const punctuation = estimateOutlineContextBudget({
+      original: [{ role: "user" as const, content: "!!!???......" }],
+      planned: [],
+    })
+
+    expect(chinese.originalTokens).toBeGreaterThan(english.originalTokens)
+    expect(punctuation.originalTokens).toBeGreaterThan(0)
+  })
+
+  it("刷新模式保留完整历史并允许显示工具过程", () => {
+    const history = [
+      { role: "user" as const, content: "生成大纲" },
+      { role: "assistant" as const, content: "大纲结果" },
+    ]
+    const plan = planOutlineAgentHistory({
+      history,
+      contextDecision: planOutlineContextReuse({
+        hasPriorAssistantAnswer: true,
+        attachedReferenceCount: 1,
+        inputText: "重新读取这个章纲",
+        enableMultiAgent: false,
+      }),
+    })
+
+    expect(plan.level).toBe("low")
+    expect(plan.messages).toEqual(history)
+    expect(plan.showToolProcess).toBe(true)
+  })
+
+  it("复用模式隐藏重复工具过程但保留错误过程", () => {
+    const plan = planOutlineAgentHistory({
+      history: [
+        { role: "user" as const, content: "生成大纲" },
+        { role: "assistant" as const, content: "大纲结果" },
+      ],
+      contextDecision: planOutlineContextReuse({
+        hasPriorAssistantAnswer: true,
+        attachedReferenceCount: 0,
+        inputText: "继续优化",
+        enableMultiAgent: false,
+      }),
+    })
+
+    expect(plan.showToolProcess).toBe(false)
+    expect(plan.showToolProcessOnError).toBe(true)
+    expect(plan.sources).toContain("过程: 已隐藏重复工具过程")
+  })
+})

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

@@ -0,0 +1,255 @@
+export const OUTLINE_CONTEXT_REUSE_DISABLED_TOOLS = [
+  "read_chapter",
+  "read_outline",
+  "read_memory",
+  "read_deduction",
+  "read_chat_history",
+  "read_outline_history",
+  "search_chapters",
+  "list_chapters",
+  "list_outlines",
+  "list_memories",
+  "list_deductions",
+  "apply_skill",
+] as const
+
+export type OutlineContextReuseMode = "refresh" | "reuse"
+export type OutlineContextPressureLevel = "low" | "medium" | "high"
+
+export interface OutlineAgentHistoryMessage {
+  role: "user" | "assistant" | "tool" | "system"
+  content: string
+}
+
+export interface OutlineContextReuseInput {
+  hasPriorAssistantAnswer: boolean
+  attachedReferenceCount: number
+  inputText: string
+  enableMultiAgent?: boolean
+  forceRefresh?: boolean
+}
+
+export interface OutlineContextReuseDecision {
+  mode: OutlineContextReuseMode
+  disabledTools: string[]
+  instruction: string
+  sourceLabel: string
+  reason: string
+}
+
+export interface OutlineAgentHistoryInput {
+  history: OutlineAgentHistoryMessage[]
+  contextDecision: OutlineContextReuseDecision
+  cachedSummary?: string
+}
+
+export interface OutlineAgentHistoryPlan {
+  level: OutlineContextPressureLevel
+  messages: OutlineAgentHistoryMessage[]
+  instruction: string
+  sources: string[]
+  showThinkingProcess: boolean
+  showToolProcess: boolean
+  showToolProcessOnError: boolean
+}
+
+export interface OutlineContextBudgetInput {
+  original: OutlineAgentHistoryMessage[]
+  planned: OutlineAgentHistoryMessage[]
+}
+
+export interface OutlineContextBudget {
+  originalTokens: number
+  plannedTokens: number
+  savedTokens: number
+  compressionRatio: number
+  label: string
+}
+
+const REFRESH_KEYWORD_PATTERN =
+  /重新(读取|加载|分析|整理)|刷新(资料|上下文)|读取(项目|大纲|章纲|文件|资料|章节|记忆)|查看(项目|大纲|章纲|文件|资料|章节|记忆)|搜索|查找|引用|@/
+
+export function planOutlineContextReuse(input: OutlineContextReuseInput): OutlineContextReuseDecision {
+  const trimmed = input.inputText.trim()
+  const shouldRefresh =
+    !input.hasPriorAssistantAnswer ||
+    input.forceRefresh === true ||
+    input.enableMultiAgent === true ||
+    input.attachedReferenceCount > 0 ||
+    REFRESH_KEYWORD_PATTERN.test(trimmed)
+
+  if (shouldRefresh) {
+    return {
+      mode: "refresh",
+      disabledTools: [],
+      instruction: [
+        "本轮允许按用户需求读取项目资料、调用 Skill 和使用必要工具。",
+        "如果已经有足够上下文,不要重复读取无关文件。",
+      ].join("\n"),
+      sourceLabel: input.forceRefresh ? "强制刷新上下文" : "本轮将读取必要上下文",
+      reason: refreshReason(input),
+    }
+  }
+
+  return {
+    mode: "reuse",
+    disabledTools: [...OUTLINE_CONTEXT_REUSE_DISABLED_TOOLS],
+    instruction: [
+      "本轮是同一 AI 大纲会话的后续追问。",
+      "请优先复用已有对话历史、上一轮最终结论和用户本轮新输入,不要重新读取项目资料、章节、大纲、记忆、推演、历史会话或 Skill。",
+      "只有当用户明确要求重新读取资料,或本轮消息带有新的 @ 引用时,才应该进入刷新上下文流程。",
+      "请直接回答用户当前问题,保留必要推理结论,不要输出内部过程说明。",
+    ].join("\n"),
+    sourceLabel: "已复用上次上下文",
+    reason: "后续普通追问未附带新引用,也未要求重新读取资料。",
+  }
+}
+
+export function planOutlineAgentHistory(input: OutlineAgentHistoryInput): OutlineAgentHistoryPlan {
+  const history = input.history.filter((message) => message.content.trim())
+  if (input.contextDecision.mode === "refresh") {
+    return {
+      level: "low",
+      messages: history,
+      instruction: "本轮需要刷新上下文,允许保留完整有效对话历史。",
+      sources: ["过程: 将显示必要工具过程"],
+      showThinkingProcess: true,
+      showToolProcess: true,
+      showToolProcessOnError: true,
+    }
+  }
+
+  const totalChars = history.reduce((sum, message) => sum + message.content.length, 0)
+  const level: OutlineContextPressureLevel =
+    history.length > 6 || totalChars > 6_000 ? "high" : totalChars > 2_500 ? "medium" : "low"
+  const compactedMessages = level === "high" ? compactOutlineHistory(history) : history.slice(-4)
+  const messages = input.cachedSummary?.trim()
+    ? [
+        {
+          role: "assistant" as const,
+          content: input.cachedSummary.trim(),
+        },
+        ...compactedMessages.slice(-2),
+      ]
+    : compactedMessages
+  const instruction = [
+    level === "high"
+      ? "已压缩历史上下文:仅保留首轮目标、最近关键结论和最近对话,避免重复消耗 Token。"
+      : "已裁剪历史上下文:仅保留最近有效对话,避免重复发送旧过程。",
+    input.cachedSummary?.trim()
+      ? "已复用上下文摘要缓存;摘要只作为历史提要,当前用户新输入优先级更高。"
+      : "",
+    "不要把工具调用过程、来源列表或内部思考当成新的创作事实;以最终大纲结论为准。",
+  ].filter(Boolean).join("\n")
+
+  return {
+    level,
+    messages,
+    instruction,
+    sources: [
+      "过程: 已隐藏重复工具过程",
+      ...(input.cachedSummary?.trim() ? ["摘要: 已复用上下文摘要缓存"] : []),
+    ],
+    showThinkingProcess: false,
+    showToolProcess: false,
+    showToolProcessOnError: true,
+  }
+}
+
+export function buildOutlineContextSummary(history: OutlineAgentHistoryMessage[]): string {
+  const cleanHistory = history.filter((message) => message.content.trim())
+  const firstUser = cleanHistory.find((message) => message.role === "user")
+  const lastAssistant = [...cleanHistory].reverse().find((message) => message.role === "assistant")
+  const lastUser = [...cleanHistory].reverse().find((message) => message.role === "user")
+  return [
+    "## 上下文摘要缓存",
+    firstUser ? `- 初始目标:${trimMiddle(firstUser.content, 360)}` : "",
+    lastAssistant ? `- 最近结论:${trimMiddle(lastAssistant.content, 720)}` : "",
+    lastUser && lastUser !== firstUser ? `- 最近问题:${trimMiddle(lastUser.content, 240)}` : "",
+    "- 使用规则:摘要用于承接已确认方向,若与用户本轮新输入冲突,以用户本轮新输入为准。",
+  ].filter(Boolean).join("\n")
+}
+
+export function estimateOutlineContextBudget(input: OutlineContextBudgetInput): OutlineContextBudget {
+  const originalTokens = estimateMessagesTokens(input.original)
+  const plannedTokens = estimateMessagesTokens(input.planned)
+  const savedTokens = Math.max(0, originalTokens - plannedTokens)
+  const compressionRatio = originalTokens > 0 ? plannedTokens / originalTokens : 1
+  return {
+    originalTokens,
+    plannedTokens,
+    savedTokens,
+    compressionRatio,
+    label: savedTokens > 0
+      ? `预计节省约 ${savedTokens} tokens`
+      : "预计不节省 tokens",
+  }
+}
+
+function refreshReason(input: OutlineContextReuseInput): string {
+  if (!input.hasPriorAssistantAnswer) return "首次生成需要建立上下文。"
+  if (input.forceRefresh) return "用户手动要求强制刷新上下文。"
+  if (input.enableMultiAgent) return "固定生成向导或多 Agent 任务需要完整上下文。"
+  if (input.attachedReferenceCount > 0) return "本轮带有新的引用资料。"
+  if (REFRESH_KEYWORD_PATTERN.test(input.inputText.trim())) {
+    return "用户明确要求读取或刷新资料。"
+  }
+  return "本轮需要刷新上下文。"
+}
+
+function estimateMessagesTokens(messages: OutlineAgentHistoryMessage[]): number {
+  return messages.reduce((sum, message) => sum + estimateTextTokens(message.content), 0)
+}
+
+function estimateTextTokens(text: string): number {
+  if (!text.trim()) return 0
+  const cjkMatches = text.match(/[\u3400-\u9fff]/g) ?? []
+  const englishMatches = text.match(/[A-Za-z0-9]+(?:['-][A-Za-z0-9]+)?/g) ?? []
+  const newlineMatches = text.match(/\n/g) ?? []
+  const symbolMatches = text.match(/[^\sA-Za-z0-9\u3400-\u9fff]/g) ?? []
+  const englishTokens = englishMatches.reduce(
+    (sum, word) => sum + Math.max(1, Math.ceil(word.length / 5)),
+    0,
+  )
+  const symbolTokens = Math.ceil(symbolMatches.length / 3)
+  const newlineTokens = Math.ceil(newlineMatches.length / 2)
+  return cjkMatches.length + englishTokens + symbolTokens + newlineTokens
+}
+
+function compactOutlineHistory(history: OutlineAgentHistoryMessage[]): OutlineAgentHistoryMessage[] {
+  const firstUser = history.find((message) => message.role === "user")
+  const lastMessages = history.slice(-2)
+  const lastAssistant = [...history].reverse().find((message) => message.role === "assistant")
+  const compacted: OutlineAgentHistoryMessage[] = []
+  if (firstUser) compacted.push(firstUser)
+  if (
+    lastAssistant &&
+    !lastMessages.some((message) => message.role === "assistant" && message.content === lastAssistant.content)
+  ) {
+    compacted.push({
+      ...lastAssistant,
+      content: trimMiddle(lastAssistant.content, 1_800),
+    })
+  }
+  for (const message of lastMessages) {
+    compacted.push({
+      ...message,
+      content: message.role === "assistant" ? trimMiddle(message.content, 1_800) : message.content,
+    })
+  }
+  return dedupeAdjacentMessages(compacted)
+}
+
+function trimMiddle(text: string, maxLength: number): string {
+  if (text.length <= maxLength) return text
+  const headLength = Math.floor(maxLength * 0.55)
+  const tailLength = maxLength - headLength
+  return `${text.slice(0, headLength)}\n\n[中间内容已压缩,保留首尾关键结论]\n\n${text.slice(-tailLength)}`
+}
+
+function dedupeAdjacentMessages(messages: OutlineAgentHistoryMessage[]): OutlineAgentHistoryMessage[] {
+  return messages.filter((message, index) => {
+    const previous = messages[index - 1]
+    return !previous || previous.role !== message.role || previous.content !== message.content
+  })
+}

+ 16 - 0
src/lib/novel/outline-quality-check.spec.ts

@@ -1,5 +1,6 @@
 import { describe, expect, it } from "vitest"
 import {
+  buildOutlineGenerationQualityFeedback,
   extractChapterOutlineStatus,
   formatChapterOutlineQualityReport,
   isLikelyChapterOutline,
@@ -96,4 +97,19 @@ describe("章纲质量检查", () => {
 
     expect(report).toBe("章纲质量检查通过,但有 1 项提醒。建议完善:人物状态缺少「关键配角状态」字段。")
   })
+
+  it("生成后质量检查应给不完整章纲返回可修复项和修订提示", () => {
+    const feedback = buildOutlineGenerationQualityFeedback({
+      fileType: "chapter-outline",
+      fileName: "章纲-第001章.md",
+      content: "# 章纲-第001章\n\n## 核心事件\n\n- 事件1:只有一个事件",
+    })
+
+    expect(feedback).not.toBeNull()
+    expect(feedback?.status).toBe("error")
+    expect(feedback?.title).toBe("生成后质量检查")
+    expect(feedback?.summary).toContain("可修复项")
+    expect(feedback?.repairPrompt).toContain("请按章纲标准修订")
+    expect(feedback?.repairPrompt).toContain("章纲-第001章.md")
+  })
 })

+ 47 - 0
src/lib/novel/outline-quality-check.ts

@@ -27,6 +27,14 @@ export interface ChapterOutlineQualitySummary {
   items: QualityCheckItem[];
 }
 
+export interface OutlineGenerationQualityFeedback {
+  status: "pass" | "warn" | "error";
+  title: string;
+  summary: string;
+  issues: string[];
+  repairPrompt: string;
+}
+
 /**
  * 对卷纲 Markdown 内容执行全部质量检查。
  *
@@ -131,6 +139,45 @@ export function formatChapterOutlineQualityReport(
   ].join("");
 }
 
+export function buildOutlineGenerationQualityFeedback(input: {
+  fileType: string;
+  fileName: string;
+  content: string;
+}): OutlineGenerationQualityFeedback | null {
+  if (input.fileType !== "chapter-outline" && !isLikelyChapterOutline(input.content, input.fileName)) {
+    return null;
+  }
+
+  const summary = summarizeChapterOutlineQuality(input.content);
+  const issues = Array.from(new Set([...summary.errors, ...summary.warnings]));
+  if (issues.length === 0) {
+    return {
+      status: "pass",
+      title: "生成后质量检查",
+      summary: `${input.fileName} 章纲质量检查通过。`,
+      issues: [],
+      repairPrompt: "",
+    };
+  }
+
+  const status: "warn" | "error" = summary.valid ? "warn" : "error";
+  const preview = issues.slice(0, 5).join(";");
+  const remaining = issues.length > 5 ? `;另有 ${issues.length - 5} 项未列出` : "";
+  return {
+    status,
+    title: "生成后质量检查",
+    summary: `${input.fileName} 存在 ${issues.length} 个可修复项:${preview}${remaining}。`,
+    issues,
+    repairPrompt: [
+      `请按章纲标准修订「${input.fileName}」。`,
+      "必须补齐以下可修复项,不要改变已确认的剧情方向:",
+      ...issues.slice(0, 12).map((issue, index) => `${index + 1}. ${issue}`),
+      "",
+      "请输出修订后的完整章纲,并在末尾附加可保存的 outlineSaveRequest JSON。",
+    ].join("\n"),
+  };
+}
+
 export function isLikelyChapterOutline(content: string, fileName = ""): boolean {
   const text = `${fileName}\n${content}`;
   return /章纲|细纲|章节细纲|章节计划|本章目标|核心事件|场景顺序|章尾钩子/.test(text);

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

@@ -86,4 +86,19 @@ describe("AI大纲结构化输出协议", () => {
       expect(result.value.contentMarkdown).toContain("玄幻升级流")
     }
   })
+
+  it("子 Agent 空输出时返回明确的中文错误而不是 JSON 解析异常", () => {
+    const result = coerceOutlineSubAgentResult("", {
+      agentId: "character-agent",
+      agentName: "角色 Agent",
+      usedSkills: ["supporting-cast"],
+      stage: "character",
+    })
+
+    expect(result.ok).toBe(false)
+    if (!result.ok) {
+      expect(result.error).toContain("子 Agent 未返回内容")
+      expect(result.error).not.toContain("Unexpected end of JSON input")
+    }
+  })
 })

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

@@ -143,6 +143,13 @@ export function coerceOutlineSubAgentResult(
   text: string,
   context: OutlineSubAgentFallbackContext,
 ): OutlineProtocolParseResult<OutlineSubAgentResult> {
+  if (!text.trim()) {
+    return {
+      ok: false,
+      error: `子 Agent 未返回内容:${context.agentName}。请重试结构化输出。`,
+    }
+  }
+
   const parsed = parseOutlineSubAgentResult(text)
   if (parsed.ok) return parsed
 

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

@@ -1,6 +1,7 @@
 import { describe, expect, it } from "vitest"
 import {
   characterDraftsToSaveRequests,
+  formatOutlineSaveParseFeedback,
   parseOutlineSaveRequests,
   saveOutlineSaveRequests,
   splitConfirmRequiredSaveRequests,
@@ -150,4 +151,26 @@ describe("outline-save-request", () => {
     expect(result.confirmRequired).toHaveLength(1)
     expect(result.autoSaveable).toHaveLength(1)
   })
+
+  it("保存请求解析失败时返回可操作的中文纠错提示", () => {
+    const parsed = parseOutlineSaveRequests(JSON.stringify({
+      outlineSaveRequest: {
+        targetFolder: "",
+        fileName: "章纲-第001章.txt",
+        fileType: "unknown",
+        writeMode: "create",
+        referencedSkills: [],
+        sourceIntent: "保存章纲",
+        content: "正文",
+      },
+    }))
+
+    const feedback = formatOutlineSaveParseFeedback(parsed.errors)
+
+    expect(feedback).toContain("自动保存失败")
+    expect(feedback).toContain("请让 AI 重新输出 outlineSaveRequest")
+    expect(feedback).toContain("targetFolder")
+    expect(feedback).toContain("fileName")
+    expect(feedback).toContain("不会写入文件")
+  })
 })

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

@@ -182,6 +182,18 @@ export function parseOutlineSaveRequests(text: string): OutlineSaveRequestParseR
   return { requests, errors }
 }
 
+export function formatOutlineSaveParseFeedback(errors: string[]): string {
+  const uniqueErrors = Array.from(new Set(errors.filter(Boolean)))
+  if (uniqueErrors.length === 0) return ""
+  const preview = uniqueErrors.slice(0, 4).join(";")
+  const remaining = uniqueErrors.length > 4 ? `;另有 ${uniqueErrors.length - 4} 项未列出` : ""
+  return [
+    `自动保存失败:${preview}${remaining}。`,
+    "请让 AI 重新输出 outlineSaveRequest,必须包含 targetFolder、fileName、fileType、writeMode、referencedSkills、sourceIntent、content。",
+    "当前内容不会写入文件。",
+  ].join("")
+}
+
 export function characterDraftsToSaveRequests(
   drafts: CharacterSaveDraft[],
   sourceIntent: string,

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

@@ -56,6 +56,7 @@ export interface OutlineChatMessage {
   sources?: string[]
   agentToolCalls?: AgentRunRecord["toolCalls"]
   multiAgentRun?: OutlineMultiAgentRunState
+  showThinkingProcess?: boolean
   isAgentRunning?: boolean
   attachedReferences?: ReferenceToken[]
 }
@@ -67,6 +68,7 @@ export interface OutlineChatConversation {
   updatedAt: number
   messages: OutlineChatMessage[]
   modelId?: string
+  contextSummary?: string
 }
 
 interface OutlineChatState {
@@ -84,6 +86,7 @@ interface OutlineChatState {
   removeLastMessage: (convId: string) => void
   deleteConversation: (id: string) => void
   setConversationModel: (id: string, modelId: string) => void
+  setConversationContextSummary: (id: string, contextSummary: string) => void
   setStreamingContent: (content: string) => void
   setIsStreaming: (value: boolean) => void
   enqueueReferenceTokens: (tokens: ReferenceToken[]) => void
@@ -184,6 +187,16 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => ({
     void get().saveToDisk()
   },
 
+  setConversationContextSummary: (id, contextSummary) => {
+    const now = Date.now()
+    set((s) => ({
+      conversations: s.conversations.map((c) =>
+        c.id === id ? { ...c, contextSummary, updatedAt: now } : c
+      ),
+    }))
+    void get().saveToDisk()
+  },
+
   setStreamingContent: (content) => set({ streamingContent: content }),
   setIsStreaming: (value) => set({ isStreaming: value }),
   enqueueReferenceTokens: (tokens) => {