Explorar el Código

merge: PR #45 章节正文安全网、Context Cache v2 与测试回归 + 版本号升级至 v3.1.3

- 修复章节生成出错后正文无法复制/保存的问题,短通知时回退workflow正文
- 大纲提取支持全部/仅未提取范围选择,修复相对路径越界目录污染
- 去AI味剥离frontmatter/标题/围栏,Tauri导出命令注册修复
- 写章强制走workflow (requiredToolsOnce gate),大纲按文件夹分流
- Context Cache v2: SHA-256指纹替代全量路径表,8MiB/128MiB容量限制
- 项目切换显式释放Context Hub资源,v1缓存后台自动清理迁移
- 模型供应商enabled:false正确生效,结构化大纲Markdown收尾恢复
- 补齐Function Calling i18n翻译,修复2871项mock测试全部通过
- 版本号 3.1.2 → 3.1.3
Mochocyang hace 2 meses
padre
commit
3396a25130
Se han modificado 89 ficheros con 2732 adiciones y 507 borrados
  1. 1 1
      package.json
  2. 1 1
      src-tauri/Cargo.toml
  3. 1 4
      src-tauri/src/commands/cursor_cli.rs
  4. 43 12
      src-tauri/src/commands/fs.rs
  5. 12 0
      src-tauri/src/lib.rs
  6. 1 0
      src-tauri/src/main.rs
  7. 1 1
      src-tauri/tauri.conf.json
  8. 2 0
      src/App.tsx
  9. 36 10
      src/components/chat/chat-message.tsx
  10. 3 5
      src/components/chat/chat-model-selector.tsx
  11. 13 4
      src/components/chat/chat-panel.spec.tsx
  12. 100 39
      src/components/chat/chat-panel.tsx
  13. 9 0
      src/components/common/context-hub-details.spec.tsx
  14. 5 0
      src/components/common/context-hub-details.tsx
  15. 12 8
      src/components/layout/preview-panel.tsx
  16. 3 3
      src/components/novel/book-analysis-view.spec.ts
  17. 4 3
      src/components/novel/de-ai-batch-entry.spec.ts
  18. 38 1
      src/components/novel/de-ai-batch-review-dialog.spec.tsx
  19. 8 2
      src/components/novel/de-ai-batch-review-dialog.tsx
  20. 7 0
      src/components/sources/outline-action-toolbar.spec.ts
  21. 53 4
      src/components/sources/outline-action-toolbar.tsx
  22. 10 16
      src/components/sources/outline-chat-panel.spec.tsx
  23. 42 23
      src/components/sources/outline-chat-panel.tsx
  24. 12 0
      src/i18n/en.json
  25. 12 0
      src/i18n/zh.json
  26. 3 0
      src/lib/agent/plan-execute-policy.spec.ts
  27. 4 4
      src/lib/agent/plan-execute-policy.ts
  28. 1 1
      src/lib/agent/plugins/build-system-prompt-plugin.spec.ts
  29. 1 1
      src/lib/agent/plugins/build-system-prompt-plugin.ts
  30. 159 0
      src/lib/agent/required-tools-gate.spec.ts
  31. 78 0
      src/lib/agent/required-tools-gate.ts
  32. 199 0
      src/lib/agent/runner.spec.ts
  33. 32 0
      src/lib/agent/runner.ts
  34. 1 1
      src/lib/agent/tools/list-outlines.ts
  35. 11 3
      src/lib/agent/tools/list-tools.spec.ts
  36. 46 18
      src/lib/agent/tools/outline-list-helpers.spec.ts
  37. 28 6
      src/lib/agent/tools/outline-list-helpers.ts
  38. 21 1
      src/lib/agent/tools/read-markdown-resource.ts
  39. 2 2
      src/lib/agent/tools/read-outline.ts
  40. 20 0
      src/lib/agent/tools/read-tools.spec.ts
  41. 5 0
      src/lib/agent/types.ts
  42. 7 34
      src/lib/changelog.spec.ts
  43. 40 2
      src/lib/changelog.ts
  44. 58 0
      src/lib/chapter-selection.spec.ts
  45. 15 0
      src/lib/chapter-selection.ts
  46. 168 1
      src/lib/chat-copy-content.test.ts
  47. 91 2
      src/lib/chat-copy-content.ts
  48. 11 9
      src/lib/context-hub/composer.spec.ts
  49. 7 4
      src/lib/context-hub/composer.ts
  50. 42 21
      src/lib/context-hub/context-hub.spec.ts
  51. 57 9
      src/lib/context-hub/context-hub.ts
  52. 67 3
      src/lib/context-hub/data-source-cache.spec.ts
  53. 47 31
      src/lib/context-hub/data-source-cache.ts
  54. 5 0
      src/lib/context-hub/fingerprint.ts
  55. 9 1
      src/lib/context-hub/index.ts
  56. 52 0
      src/lib/context-hub/migration.spec.ts
  57. 1 1
      src/lib/context-hub/session-store-integration.spec.ts
  58. 24 10
      src/lib/context-hub/session-summary.spec.ts
  59. 19 15
      src/lib/context-hub/session-summary.ts
  60. 4 0
      src/lib/context-hub/source-paths.spec.ts
  61. 4 3
      src/lib/context-hub/source-paths.ts
  62. 52 0
      src/lib/context-hub/source-registry.spec.ts
  63. 76 11
      src/lib/context-hub/source-registry.ts
  64. 218 5
      src/lib/context-hub/storage.spec.ts
  65. 268 87
      src/lib/context-hub/storage.ts
  66. 28 6
      src/lib/context-hub/types.ts
  67. 3 0
      src/lib/export-center/export-service.spec.ts
  68. 1 1
      src/lib/hardcoded-ui-chinese-i18n.test.ts
  69. 16 1
      src/lib/llm-model-keys.spec.ts
  70. 3 2
      src/lib/llm-model-keys.ts
  71. 21 0
      src/lib/novel/chapter-content-cleanup.spec.ts
  72. 20 0
      src/lib/novel/chapter-content-cleanup.ts
  73. 10 3
      src/lib/novel/outline-find-protocol.spec.ts
  74. 12 10
      src/lib/novel/outline-find-protocol.ts
  75. 75 0
      src/lib/novel/outline-generation.spec.ts
  76. 35 3
      src/lib/novel/outline-generation.ts
  77. 0 41
      src/lib/novel/outline-quality-check.spec.ts
  78. 9 2
      src/lib/novel/result-parser.spec.ts
  79. 1 1
      src/lib/novel/result-parser.ts
  80. 8 0
      src/lib/novel/result-save-guard.spec.ts
  81. 1 1
      src/lib/novel/task-router.ts
  82. 45 3
      src/lib/persist.spec.ts
  83. 34 5
      src/lib/persist.ts
  84. 8 0
      src/lib/reset-project-state.spec.ts
  85. 2 0
      src/lib/reset-project-state.ts
  86. 5 2
      src/stores/outline-chat-store.spec.ts
  87. 10 1
      src/stores/outline-chat-store.ts
  88. 1 0
      src/test/chat-panel-mount.ts
  89. 2 2
      vite.config.ts

+ 1 - 1
package.json

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

+ 1 - 1
src-tauri/Cargo.toml

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

+ 1 - 4
src-tauri/src/commands/cursor_cli.rs

@@ -283,10 +283,7 @@ pub async fn cursor_proxy_status(state: State<'_, CursorProxyState>) -> Result<P
     let (base, managed) = {
         let guard = state.managed.lock().await;
         (
-            guard
-                .base_url
-                .clone()
-                .unwrap_or_else(|| DEFAULT_PROXY_BASE.to_string()),
+            normalize_proxy_base(guard.base_url.clone()),
             guard.child.is_some(),
         )
     };

+ 43 - 12
src-tauri/src/commands/fs.rs

@@ -216,19 +216,27 @@ fn cache_path_for(original: &Path) -> std::path::PathBuf {
         .file_name()
         .unwrap_or_default()
         .to_string_lossy();
-
-    // 如果缓存目录创建失败(如根目录无权限),回退到系统临时目录
-    if fs::create_dir_all(&cache_dir).is_err() {
-        return std::env::temp_dir()
-            .join("qmai-cache")
-            .join(format!("{}.txt", file_name));
-    }
     cache_dir.join(format!("{}.txt", file_name))
 }
 
+fn fallback_cache_path(original: &Path) -> std::path::PathBuf {
+    let file_name = original
+        .file_name()
+        .unwrap_or_default()
+        .to_string_lossy();
+    std::env::temp_dir()
+        .join("qmai-cache")
+        .join(format!("{}.txt", file_name))
+}
+
 fn read_cache(original: &Path) -> Option<String> {
-    let cache_path = cache_path_for(original);
     let original_modified = fs::metadata(original).ok()?.modified().ok()?;
+    let preferred_path = cache_path_for(original);
+    let cache_path = if fs::metadata(&preferred_path).is_ok() {
+        preferred_path
+    } else {
+        fallback_cache_path(original)
+    };
     let cache_modified = fs::metadata(&cache_path).ok()?.modified().ok()?;
     if cache_modified >= original_modified {
         fs::read_to_string(&cache_path).ok()
@@ -238,10 +246,18 @@ fn read_cache(original: &Path) -> Option<String> {
 }
 
 fn write_cache(original: &Path, text: &str) -> Result<(), String> {
-    let cache_path = cache_path_for(original);
-    if let Some(parent) = cache_path.parent() {
-        fs::create_dir_all(parent).ok();
-    }
+    let preferred_path = cache_path_for(original);
+    let cache_path = match preferred_path.parent() {
+        Some(parent) if fs::create_dir_all(parent).is_ok() => preferred_path,
+        _ => {
+            let fallback_path = fallback_cache_path(original);
+            if let Some(parent) = fallback_path.parent() {
+                fs::create_dir_all(parent)
+                    .map_err(|e| format!("Failed to create fallback cache directory: {e}"))?;
+            }
+            fallback_path
+        }
+    };
     crate::commands::file_sync::mark_app_write_path(&cache_path);
     fs::write(&cache_path, text)
         .map_err(|e| format!("Failed to write cache: {}", e))
@@ -1934,6 +1950,21 @@ mod tests {
     use super::*;
     use std::io::Write;
 
+    #[test]
+    fn missing_file_read_does_not_create_parent_cache_directory() {
+        let root = std::env::temp_dir().join(format!(
+            "qmai-missing-read-cache-{}",
+            std::time::SystemTime::now()
+                .duration_since(std::time::UNIX_EPOCH)
+                .unwrap()
+                .as_nanos()
+        ));
+        let missing_file = root.join("章纲").join("不存在.md");
+
+        assert!(do_read_file(&missing_file.to_string_lossy()).is_err());
+        assert!(!root.exists(), "读取不存在的文件不应创建缓存目录");
+    }
+
     #[test]
     fn directory_tree_includes_file_version_metadata() {
         let root = std::env::temp_dir().join(format!(

+ 12 - 0
src-tauri/src/lib.rs

@@ -10,6 +10,17 @@ fn set_proxy_env(config: proxy::ProxyConfig) -> String {
     summary
 }
 
+#[tauri::command]
+fn log_error(message: String, stack: String, component_stack: String) {
+    eprintln!("[frontend-error] message: {}", message);
+    if !stack.is_empty() {
+        eprintln!("[frontend-error] stack: {}", stack);
+    }
+    if !component_stack.is_empty() {
+        eprintln!("[frontend-error] component-stack: {}", component_stack);
+    }
+}
+
 pub fn run() {
     tauri::Builder::default()
         .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
@@ -110,6 +121,7 @@ pub fn run() {
             commands::writing_wake_lock::acquire_writing_wake_lock,
             commands::writing_wake_lock::release_writing_wake_lock,
             set_proxy_env,
+            log_error,
         ])
         .on_window_event(|window, event| {
             if let tauri::WindowEvent::CloseRequested { api, .. } = event {

+ 1 - 0
src-tauri/src/main.rs

@@ -63,6 +63,7 @@ fn main() {
             commands::fs::read_file,
             commands::fs::write_file,
             commands::fs::write_file_if_absent,
+            commands::fs::write_export_file,
             commands::fs::write_file_atomic,
             commands::fs::list_directory,
             commands::fs::copy_file,

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

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

+ 2 - 0
src/App.tsx

@@ -28,6 +28,7 @@ import { isChapterPathInProject, normalizePath } from "@/lib/path-utils"
 import { countChapterBodyWords } from "@/lib/chapter-word-count"
 import { flattenMdFiles } from "@/lib/novel/chapter-utils"
 import { runUserMemoryMaintenance } from "@/lib/user-memory/maintenance"
+import { initializeProjectContextCache } from "@/lib/context-hub/context-hub"
 
 function App() {
   const project = useWikiStore((s) => s.project)
@@ -417,6 +418,7 @@ function App() {
 
   async function handleProjectOpened(proj: WikiProject) {
     await resetProjectState()
+    await initializeProjectContextCache(proj.path)
 
     setProject(proj)
     useWikiStore.getState().clearTransientTaskState()

+ 36 - 10
src/components/chat/chat-message.tsx

@@ -62,7 +62,10 @@ interface ChatMessageProps {
   onRegenerate?: () => void;
   novelMode?: boolean;
   projectPath?: string | null;
-  onSaveAsChapter?: (content: string) => void;
+  onSaveAsChapter?: (
+    content: string,
+    toolCalls?: Array<{ name: string; result?: string; status?: string }>,
+  ) => void;
   onContinueNextChapter?: () => void;
   onContinueUnfinished?: () => void;
   onSaveAsDraft?: (content: string) => void;
@@ -217,7 +220,9 @@ export function ChatMessage({
             {novelMode && isLastAssistant && onSaveAsChapter && (
               <button
                 type="button"
-                onClick={() => onSaveAsChapter(message.content)}
+                onClick={() =>
+                  onSaveAsChapter(message.content, message.agentToolCalls)
+                }
                 disabled={isSaving}
                 className="rounded border border-border px-2 py-0.5 text-[11px] text-foreground hover:bg-accent disabled:opacity-50"
               >
@@ -246,7 +251,10 @@ export function ChatMessage({
               </button>
             )}
             {(hovered || (novelMode && isLastAssistant)) && (
-              <CopyButton content={message.content} />
+              <CopyButton
+                content={message.content}
+                toolCalls={message.agentToolCalls}
+              />
             )}
             {isLastAssistant && onRegenerate && (
               <button
@@ -307,25 +315,43 @@ export function ChatMessage({
   );
 }
 
-function CopyButton({ content }: { content: string }) {
+function CopyButton({
+  content,
+  toolCalls,
+}: {
+  content: string;
+  toolCalls?: ToolCallRecord[];
+}) {
   const [copied, setCopied] = useState(false);
+  const copyable = useMemo(
+    () => getCopyableAssistantContent(content, { toolCalls }),
+    [content, toolCalls],
+  );
+  const hasRecoveredChapterBody =
+    Boolean(copyable) &&
+    (/^出错:/.test(content.trim()) || content.includes("\n\n出错:"));
 
   const handleCopy = useCallback(async () => {
-    const clean = getCopyableAssistantContent(content);
-    await navigator.clipboard.writeText(clean);
+    if (!copyable) return;
+    await navigator.clipboard.writeText(copyable);
     setCopied(true);
     setTimeout(() => setCopied(false), 2000);
-  }, [content]);
+  }, [copyable]);
 
   return (
     <button
       type="button"
       onClick={handleCopy}
-      className="inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
-      title="复制到剪贴板"
+      disabled={!copyable}
+      className="inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors disabled:opacity-40"
+      title={
+        hasRecoveredChapterBody
+          ? "复制已完成的章节正文(不含错误信息)"
+          : "复制到剪贴板"
+      }
     >
       {copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
-      {copied ? "已复制" : "复制"}
+      {copied ? "已复制" : hasRecoveredChapterBody ? "复制正文" : "复制"}
     </button>
   );
 }

+ 3 - 5
src/components/chat/chat-model-selector.tsx

@@ -5,7 +5,7 @@ import { createPortal } from "react-dom"
 import { Button } from "@/components/ui/button"
 import { useWikiStore, type SavedModel } from "@/stores/wiki-store"
 import { LLM_PRESETS } from "@/components/settings/llm-presets"
-import { getEffectiveSavedModels } from "@/lib/llm-model-keys"
+import { getEffectiveSavedModels, isProviderAvailable } from "@/lib/llm-model-keys"
 
 interface ChatModelSelectorProps {
   value: string
@@ -82,9 +82,7 @@ export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelect
     const builtinKeys = Object.keys(providerConfigs).filter((k) => !k.startsWith("custom-"))
     for (const key of builtinKeys) {
       const config = providerConfigs[key]
-      const hasConfig = config.enabled === true
-        || ((config.apiKey || config.savedModels?.length) && (config.model || config.savedModels?.length))
-      if (!hasConfig) continue
+      if (!isProviderAvailable(key, config)) continue
       const models = getEffectiveSavedModels(config)
       if (models.length > 0) {
         const preset = LLM_PRESETS.find((p) => p.id === key)
@@ -99,7 +97,7 @@ export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelect
     const customKeys = Object.keys(providerConfigs).filter((k) => k.startsWith("custom-"))
     for (const key of customKeys) {
       const config = providerConfigs[key]
-      if (config.enabled === false) continue
+      if (!isProviderAvailable(key, config)) continue
       const models = getEffectiveSavedModels(config)
       if (models.length > 0) {
         groups.push({

+ 13 - 4
src/components/chat/chat-panel.spec.tsx

@@ -156,6 +156,13 @@ describe("chat-panel agent reference integration", () => {
     expect(source).toContain("aiWorkflowMode,")
   })
 
+  it("injects requiredToolsOnce for non-fast chapter writing via resolveRequiredToolsOnce", () => {
+    expect(source).toContain('import { resolveRequiredToolsOnce } from "@/lib/agent/required-tools-gate"')
+    expect(source).toContain("resolveRequiredToolsOnce({")
+    expect(source).toContain("...(requiredToolsOnce ? { requiredToolsOnce } : {})")
+    expect(source).toContain("必须调用 run_chapter_workflow 工具;未调用前禁止输出章节终稿正文。")
+  })
+
   it("does not bypass ReAct for chapter workflow execution", () => {
     expect(source).toContain("runAiChatSession")
     expect(source).toContain("run_chapter_workflow")
@@ -284,8 +291,8 @@ describe("chat-panel agent reference integration", () => {
   it("settles running tool calls when the agent session finishes", () => {
     expect(source).toContain("settleRunningAgentToolCalls")
     expect(source).not.toContain("const _settlePattern")
-    expect(source).toContain("agentToolCalls: settleRunningAgentToolCalls(record?.toolCalls.length ? record.toolCalls : message.agentToolCalls)")
-    expect(source).toContain('agentToolCalls: settleRunningAgentToolCalls(message.agentToolCalls, "error")')
+    expect(source).toContain("record?.toolCalls.length ? record.toolCalls : message.agentToolCalls")
+    expect(source).toContain('settleRunningAgentToolCalls(message.agentToolCalls, "error")')
   })
 
   it("settles visible tool calls when generation is cancelled from any chat confirmation path", () => {
@@ -462,7 +469,9 @@ describe("chat-panel post-write check integration (Stage D)", () => {
     const beforeStageDBlock = source.slice(0, stageDIndex)
     expect(beforeStageDBlock).toContain("useChatStore.getState()")
     expect(beforeStageDBlock).toContain("lastAssistantForValidation")
-    expect(beforeStageDBlock).toContain("finalContent = lastAssistantForValidation?.content ??")
+    expect(beforeStageDBlock).toContain("rawFinalContent = lastAssistantForValidation?.content ??")
+    expect(beforeStageDBlock).toContain("getCopyableAssistantContent(rawFinalContent")
+    expect(beforeStageDBlock).toContain("const finalContent = resolvedFinalContent")
     const stageDBlock = source.slice(stageDIndex, stageDIndex + 1200)
     expect(stageDBlock).toContain("const chapterContent = finalContent")
   })
@@ -477,7 +486,7 @@ describe("chat-panel post-write check integration (Stage D)", () => {
   it("skips empty content to avoid false reports", () => {
     const stageDIndex = source.indexOf("=== Stage D: 写后剧情自检 ===")
     const stageDBlock = source.slice(stageDIndex, stageDIndex + 1200)
-    expect(stageDBlock).toContain("if (chapterContent && !hasChapterPlanMarker)")
+    expect(stageDBlock).toContain("if (chapterContent && !hasChapterPlanMarker && chapterProtocolValid)")
   })
 
   it("writes the check result and meta into contextTrace.contextInfo", () => {

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

@@ -39,6 +39,7 @@ import {
 } from "@/lib/reference/providers"
 import type { ReferenceToken } from "@/lib/reference/types"
 import { runAiChatSession } from "@/lib/agent/ai-chat-session"
+import { resolveRequiredToolsOnce } from "@/lib/agent/required-tools-gate"
 import { runDraftReviewSkill } from "@/lib/agent/skills/draft-review-skill"
 import { useDraftReviewStore } from "@/stores/draft-review-store"
 import { shouldKeepAwakeForWriting, withWritingWakeLock } from "@/lib/writing-wake-lock"
@@ -99,7 +100,11 @@ import {
   extractContinueUnfinishedDeepChapterContext,
   stripContinueUnfinishedDeepChapterContext,
 } from "./chat-resume"
-import { getCopyableAssistantContent } from "@/lib/chat-copy-content"
+import {
+  getCopyableAssistantContent,
+  type CopyableToolCall,
+} from "@/lib/chat-copy-content"
+import { validateChapterBeforeSave } from "@/lib/novel/result-save-guard"
 import { decideChapterSaveStrategy, detectGeneratedTargetChapterNumber } from "@/lib/novel/chapter-save-strategy"
 import { loadBinding } from "@/lib/novel/story-simulation/framework-binding"
 import { loadFrameworks } from "@/lib/novel/story-simulation/framework-store"
@@ -320,7 +325,7 @@ function buildChatAgentSystemPrompt(options: {
     if (options.aiWorkflowMode === "fast") {
       lines.push("快速模式下可以读取必要上下文;除非用户明确要求使用工作流或 Skill,否则不要主动调用 run_chapter_workflow。")
     } else {
-      lines.push("章节生成、续写、改写或润色应优先调用 run_chapter_workflow 工具。")
+      lines.push("章节生成、续写、改写或润色必须调用 run_chapter_workflow 工具;未调用前禁止输出章节终稿正文。")
     }
   }
   if (options.aiWorkflowMode) {
@@ -1100,16 +1105,31 @@ export function ChatPanel() {
     }
   }, [closeChapterPlanDialog])
 
-  const handleSaveAsChapter = useCallback(async (content: string) => {
+  const handleSaveAsChapter = useCallback(async (
+    content: string,
+    toolCalls?: CopyableToolCall[],
+  ) => {
     if (!project) return
     const pp = normalizePath(project.path)
     setIsSavingChapter(true)
     setChapterSaveStatus("")
     try {
+      const resolvedContent = getCopyableAssistantContent(content, { toolCalls })
+      const saveGuard = validateChapterBeforeSave(resolvedContent)
+      if (!saveGuard.ok) {
+        setChapterSaveStatus(saveGuard.message || "章节结果校验未通过,已取消保存")
+        return
+      }
+
       // 使用带标题提取的清理函数
       const { content: cleanedContent, title: extractedTitle } = cleanGeneratedChapterContentWithTitle(
-        getCopyableAssistantContent(content),
+        resolvedContent,
       )
+      if (!cleanedContent.trim()) {
+        setChapterSaveStatus("章节正文为空,已取消保存")
+        return
+      }
+
       const selectedChapterNumber = await readSelectedChapterNumberForFile(selectedFile)
       const generatedTargetChapterNumber = detectGeneratedTargetChapterNumber(extractedTitle ?? cleanedContent)
       const explicitTargetPath = generatedTargetChapterNumber ? await findChapterFileByNumber(pp, generatedTargetChapterNumber) : null
@@ -1402,41 +1422,58 @@ export function ChatPanel() {
       let accumulatedReasoningContent = ""
 
       const markDone = (record?: AgentRunRecord) => {
-        updateAgentAssistantMessage(assistantMessage.id, (message) => ({
-          ...message,
-          content: message.content || record?.finalText || "Agent未返回内容。",
-          reasoning_content: accumulatedReasoningContent,
-          agentToolCalls: settleRunningAgentToolCalls(record?.toolCalls.length ? record.toolCalls : message.agentToolCalls),
-          agentStages: settleRunningAgentStages(message.agentStages, "done"),
-          references: (() => {
-            const existingReferences = message.references ?? []
-            const existingPaths = new Set(existingReferences.map((reference) => reference.path))
-            const agentReferences = agentToolCallsToMessageReferences(
-              settleRunningAgentToolCalls(record?.toolCalls.length ? record.toolCalls : message.agentToolCalls) ?? [],
-            ).filter((reference) => !existingPaths.has(reference.path))
-            return agentReferences.length > 0
-              ? [...existingReferences, ...agentReferences]
-              : message.references
-          })(),
-          contextTrace: contextTrace || message.contextTrace,
-          isAgentRunning: false,
-        }))
+        updateAgentAssistantMessage(assistantMessage.id, (message) => {
+          const settledTools = settleRunningAgentToolCalls(
+            record?.toolCalls.length ? record.toolCalls : message.agentToolCalls,
+          )
+          const rawContent = message.content || record?.finalText || "Agent未返回内容。"
+          const resolvedContent = getCopyableAssistantContent(rawContent, {
+            toolCalls: settledTools,
+          }) || rawContent
+          return {
+            ...message,
+            content: resolvedContent,
+            reasoning_content: accumulatedReasoningContent,
+            agentToolCalls: settledTools,
+            agentStages: settleRunningAgentStages(message.agentStages, "done"),
+            references: (() => {
+              const existingReferences = message.references ?? []
+              const existingPaths = new Set(existingReferences.map((reference) => reference.path))
+              const agentReferences = agentToolCallsToMessageReferences(
+                settledTools ?? [],
+              ).filter((reference) => !existingPaths.has(reference.path))
+              return agentReferences.length > 0
+                ? [...existingReferences, ...agentReferences]
+                : message.references
+            })(),
+            contextTrace: contextTrace || message.contextTrace,
+            isAgentRunning: false,
+          }
+        })
       }
 
       const markError = (error: Error) => {
         hasAgentError = true
         lastAgentError = error.message || "生成失败"
-        updateAgentAssistantMessage(assistantMessage.id, (message) => ({
-          ...message,
-          content: message.content
-            ? `${message.content}\n\n出错:${error.message}`
-            : `出错:${error.message}`,
-          reasoning_content: accumulatedReasoningContent,
-          agentToolCalls: settleRunningAgentToolCalls(message.agentToolCalls, "error"),
-          agentStages: settleRunningAgentStages(message.agentStages, "error"),
-          contextTrace: contextTrace || message.contextTrace,
-          isAgentRunning: false,
-        }))
+        updateAgentAssistantMessage(assistantMessage.id, (message) => {
+          const settledTools = settleRunningAgentToolCalls(message.agentToolCalls, "error")
+          const rawContent = message.content ?? ""
+          const recoveredChapterBody = getCopyableAssistantContent(rawContent, {
+            toolCalls: settledTools,
+          })
+          const baseContent = recoveredChapterBody || rawContent.trim()
+          return {
+            ...message,
+            content: baseContent
+              ? `${baseContent}\n\n出错:${error.message}`
+              : `出错:${error.message}`,
+            reasoning_content: accumulatedReasoningContent,
+            agentToolCalls: settledTools,
+            agentStages: settleRunningAgentStages(message.agentStages, "error"),
+            contextTrace: contextTrace || message.contextTrace,
+            isAgentRunning: false,
+          }
+        })
       }
 
       const finishAgentSession = (callback?: () => void) => {
@@ -1753,6 +1790,13 @@ export function ChatPanel() {
           intent: effectiveTaskRoute?.intent,
           planExecuteActive,
         })
+        const requiredToolsOnce = resolveRequiredToolsOnce({
+          novelMode,
+          intent: effectiveTaskRoute?.intent,
+          mode: aiWorkflowMode,
+          planExecuteActive,
+          enabledToolNames: prePluginResult?.enabledToolNames,
+        })
         const record = await withWritingWakeLock(keepAwake, () => runAiChatSession({
           userMessage: plainText,
           projectPath,
@@ -1761,6 +1805,7 @@ export function ChatPanel() {
             systemPrompt: systemPromptForConfig,
             projectPath,
             taskGoal: plainText,
+            ...(requiredToolsOnce ? { requiredToolsOnce } : {}),
             requestOverrides: {
               ...agentConfig.requestOverrides,
               userMemorySurface: "ai-chat",
@@ -1853,9 +1898,24 @@ export function ChatPanel() {
               const lastAssistantForValidation = storeStateForValidation.messages.find(
                 (m) => m.id === assistantMessage.id && m.role === "assistant",
               )
-              const finalContent = lastAssistantForValidation?.content ?? ""
+              const rawFinalContent = lastAssistantForValidation?.content ?? ""
+              const resolvedFinalContent = getCopyableAssistantContent(rawFinalContent, {
+                toolCalls: lastAssistantForValidation?.agentToolCalls ?? record.toolCalls,
+              }) || rawFinalContent
+              if (
+                resolvedFinalContent
+                && resolvedFinalContent !== rawFinalContent
+              ) {
+                updateAgentAssistantMessage(assistantMessage.id, (message) => ({
+                  ...message,
+                  content: resolvedFinalContent,
+                }))
+              }
+              const finalContent = resolvedFinalContent
+              let chapterProtocolValid = false
               if (finalContent) {
                 const protocolTrace = buildResultProtocolTrace("chapter", finalContent)
+                chapterProtocolValid = protocolTrace.valid
                 contextTrace = setContextInfo(contextTrace, { ...traceInfo, resultProtocol: protocolTrace })
               }
               // === Stage D: 写后剧情自检 ===
@@ -1865,9 +1925,10 @@ export function ChatPanel() {
                 effectiveTaskRoute.intent === "continue_chapter"
               ) {
                 const chapterContent = finalContent
-                // 排除含 chapter_plan 标记的内容(计划本身不是正文)与空内容
+                // 排除含 chapter_plan 标记的内容(计划本身不是正文)与空内容;
+                // 短完成句等未通过章节协议的结果也不进入自检/草稿校验。
                 const hasChapterPlanMarker = chapterContent.includes("chapter_plan")
-                if (chapterContent && !hasChapterPlanMarker) {
+                if (chapterContent && !hasChapterPlanMarker && chapterProtocolValid) {
                   void (async () => {
                     try {
                       const result = await runPostWriteCheckAI({
@@ -1890,7 +1951,7 @@ export function ChatPanel() {
                 }
                 // === Stage E: 草稿校验与修复(硬偏差) ===
                 // 仅在已打开项目时触发;未打开项目时跳过(避免空路径调用 skill)
-                if (chapterContent && !hasChapterPlanMarker && projectPath) {
+                if (chapterContent && !hasChapterPlanMarker && chapterProtocolValid && projectPath) {
                   const draftChapterNumber = effectiveTaskRoute.chapterNumber ?? 0
                   void (async () => {
                     try {
@@ -1939,7 +2000,7 @@ export function ChatPanel() {
             capturedConvId,
             buildSessionContextSummary({
               messages: completedMessages,
-              dependencies: contextHubResult.dependencies,
+              dependencyFingerprint: contextHubResult.dependencyStamp.fingerprint,
             }),
           )
         }

+ 9 - 0
src/components/common/context-hub-details.spec.tsx

@@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
 import { ContextHubDetails } from "./context-hub-details"
 import { CONTEXT_CACHE_SCHEMA_VERSION, type ContextHubSnapshot } from "@/lib/context-hub/types"
 
+const dependencyStamp = { fingerprint: "test", sourceCount: 1, kinds: ["outline" as const] }
+
 const snapshot: ContextHubSnapshot = {
   schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
   id: "assistant:1",
@@ -33,19 +35,25 @@ const snapshot: ContextHubSnapshot = {
       key: "data-source:outline",
       sourceName: "outline",
       status: "hit",
+      dependencyStamp: { ...dependencyStamp, sourceCount: 3 },
       dependencyPaths: ["wiki/outlines/main.md"],
+      dependencyPathsTruncated: true,
     },
     {
       key: "stable-core:ai-chat",
       sourceName: "stableCore",
       status: "refreshed",
+      dependencyStamp,
       dependencyPaths: ["wiki/settings/world.md"],
+      dependencyPathsTruncated: false,
     },
     {
       key: "data-source:book-analysis",
       sourceName: "bookAnalysisReferences",
       status: "hit",
+      dependencyStamp,
       dependencyPaths: [".qmai/book-analysis-context.json"],
+      dependencyPathsTruncated: false,
     },
   ],
   stableCore: "稳定核心正文",
@@ -103,6 +111,7 @@ describe("ContextHubDetails", () => {
     expect(host.textContent).toContain("拆书库分析")
     expect(host.textContent).toContain("稳定核心缓存")
     expect(host.textContent).toContain("wiki/outlines/main.md")
+    expect(host.textContent).toContain("另有 2 个文件")
     expect(host.textContent).toContain("稳定核心正文")
     expect(host.textContent).toContain("供应商已确认命中 800 Token(输入占比 50%)")
     expect(host.textContent).toContain("供应商新写入缓存 200 Token")

+ 5 - 0
src/components/common/context-hub-details.tsx

@@ -81,6 +81,11 @@ function CacheItemGroup({ status, items }: { status: ContextCacheItemStatus; ite
                 {item.dependencyPaths.map((path) => (
                   <li key={path} className="break-all">{path}</li>
                 ))}
+                {item.dependencyPathsTruncated ? (
+                  <li className="text-foreground/60">
+                    另有 {Math.max(0, item.dependencyStamp.sourceCount - item.dependencyPaths.length).toLocaleString()} 个文件,按集合指纹校验
+                  </li>
+                ) : null}
               </ul>
             )}
           </div>

+ 12 - 8
src/components/layout/preview-panel.tsx

@@ -43,6 +43,7 @@ import { useOutlineGenerationStore, type OutlineGenerationTask } from "@/stores/
 import { useImportProgressStore } from "@/stores/import-progress-store"
 import {
   buildPolishSelectionMessages,
+  extractDeAiChapterText,
   rebuildChapterBody,
   replaceChapterBodySelection,
   splitChapterHeading,
@@ -981,7 +982,8 @@ export function PreviewPanel() {
 
   const runWholeChapterDeAi = useCallback(async (skillContent: string, skillName: string) => {
     await syncDiskBeforeAction()
-    const source = wikiEditorRef.current?.getCurrentMarkdown() ?? fileContentRef.current
+    const markdown = wikiEditorRef.current?.getCurrentMarkdown() ?? fileContentRef.current
+    const source = extractDeAiChapterText(markdown)
     if (!source.trim() || !selectedFile || !project) return
     const state = useWikiStore.getState()
     const llmConfig = resolveNovelModel(state.llmConfig, state.novelConfig, "deAi")
@@ -1013,7 +1015,7 @@ export function PreviewPanel() {
           },
           onDone: () => {
             doneCalled = true
-            useDeAiTaskStore.getState().finishTask(taskId, result)
+            useDeAiTaskStore.getState().finishTask(taskId, extractDeAiChapterText(result))
           },
           onError: (error) => {
             doneCalled = true
@@ -1025,7 +1027,7 @@ export function PreviewPanel() {
       // 兜底:streamChat 正常返回但未调用 onDone/onError 时,用 result 完成
       if (!doneCalled) {
         if (result.trim()) {
-          useDeAiTaskStore.getState().finishTask(taskId, result)
+          useDeAiTaskStore.getState().finishTask(taskId, extractDeAiChapterText(result))
         } else {
           useDeAiTaskStore.getState().failTask(taskId, "去AI味未返回内容")
         }
@@ -1736,9 +1738,10 @@ export function PreviewPanel() {
              onConfirm={async (_taskId, chapterId, candidateContent) => {
               const task = readyTasks.find((t) => t.id === chapterId)
               if (!task) return
+              const body = extractDeAiChapterText(candidateContent)
               try {
-                await applyDeAiBatchChapter(task.chapterPath, candidateContent)
-                useDeAiTaskStore.getState().updateTask(chapterId, { candidateContent })
+                await applyDeAiBatchChapter(task.chapterPath, body)
+                useDeAiTaskStore.getState().updateTask(chapterId, { candidateContent: body })
                 useDeAiTaskStore.getState().confirmTask(chapterId)
                 useDeAiTaskStore.getState().closeReview(project.path)
                 toast.success(`${task.chapterTitle} 去AI味结果已保存`)
@@ -1750,7 +1753,7 @@ export function PreviewPanel() {
              onSaveDraft={async (_taskId, chapterId, candidateContent) => {
                const task = readyTasks.find((item) => item.id === chapterId)
                if (!task) return
-               await handleDeAiSaveDraft(task.chapterPath, candidateContent)
+               await handleDeAiSaveDraft(task.chapterPath, extractDeAiChapterText(candidateContent))
              }}
              onRegenerate={async (_taskId, chapterId) => {
               const task = readyTasks.find((t) => t.id === chapterId)
@@ -1768,13 +1771,14 @@ export function PreviewPanel() {
               }
               let result = ""
               try {
+                const source = extractDeAiChapterText(task.sourceContent)
                 await streamChat(
                   llmConfig,
-                  buildDeAiRewriteMessages(task.sourceContent, task.skillContent),
+                  buildDeAiRewriteMessages(source, task.skillContent),
                   {
                     onToken: (token) => { result += token },
                     onDone: () => {
-                      useDeAiTaskStore.getState().finishTask(chapterId, result)
+                      useDeAiTaskStore.getState().finishTask(chapterId, extractDeAiChapterText(result))
                     },
                     onError: (error) => {
                       useDeAiTaskStore.getState().failTask(chapterId, error.message ?? String(error))

+ 3 - 3
src/components/novel/book-analysis-view.spec.ts

@@ -404,14 +404,14 @@ afterEach(async () => {
 afterAll(() => restoreActEnvironment())
 
 describe("BookAnalysisView 批量导入运行时接线", () => {
-  it("挂载和项目切换时初始化对应项目,并在切换和卸载时异步释放", async () => {
+  it("挂载和项目切换时初始化对应项目,切换页面时保留后台调度器", async () => {
     await renderView()
     expect(mocks.initializeProject).toHaveBeenCalledWith("E:/项目甲")
 
     mocks.wikiState.project = { id: "project-b", name: "项目乙", path: "F:/项目乙" }
     await rerenderView()
 
-    expect(mocks.dispose).toHaveBeenCalledTimes(1)
+    expect(mocks.dispose).not.toHaveBeenCalled()
     expect(mocks.initializeProject).toHaveBeenLastCalledWith("F:/项目乙")
 
     await act(async () => {
@@ -419,7 +419,7 @@ describe("BookAnalysisView 批量导入运行时接线", () => {
       await Promise.resolve()
     })
     mounted = false
-    expect(mocks.dispose).toHaveBeenCalledTimes(2)
+    expect(mocks.dispose).not.toHaveBeenCalled()
   })
 
   it("弹窗通过同名批量入口原样提交完整候选列表,并在成功后关闭", async () => {

+ 4 - 3
src/components/novel/de-ai-batch-entry.spec.ts

@@ -13,8 +13,8 @@ describe("de-ai batch entry and settings", () => {
     expect(previewPanel).toContain("runWholeChapterDeAi")
     expect(previewPanel).toContain("registerEditorExternalUpdateHandler")
     expect(previewPanel).toContain("createDeAiBatchChapterApplier")
-    expect(previewPanel).toContain("await applyDeAiBatchChapter(task.chapterPath, candidateContent)")
-    expect(previewPanel).toContain("updateTask(chapterId, { candidateContent })")
+    expect(previewPanel).toContain("await applyDeAiBatchChapter(task.chapterPath, body)")
+    expect(previewPanel).toContain("updateTask(chapterId, { candidateContent: body })")
     expect(previewPanel).toContain("useDeAiTaskStore.getState().closeReview(project.path)")
     expect(previewPanel).toContain("saveDeAiDraftWithoutOverwrite")
     expect(previewPanel).toContain("writeFileIfAbsent")
@@ -25,7 +25,8 @@ describe("de-ai batch entry and settings", () => {
   it("keeps review tasks project-scoped and reuses the selected skill when regenerating", () => {
     expect(previewPanel).toContain("selectProjectDeAiTasks(deAiTasks, project?.path)")
     expect(previewPanel).toContain("skillContent,")
-    expect(previewPanel).toContain("buildDeAiRewriteMessages(task.sourceContent, task.skillContent)")
+    expect(previewPanel).toContain("extractDeAiChapterText(task.sourceContent)")
+    expect(previewPanel).toContain("buildDeAiRewriteMessages(source, task.skillContent)")
   })
 
   it("小说设置包含默认 3、范围 1–5 的批量并发设置", () => {

+ 38 - 1
src/components/novel/de-ai-batch-review-dialog.spec.tsx

@@ -237,4 +237,41 @@ describe("DeAiBatchReviewDialog", () => {
     expect(button("重新生成").disabled).toBe(true)
     expect(button("另存草稿").disabled).toBe(true)
     expect(button("取消当前章").disabled).toBe(true)
-  })})
+  })
+
+  it("对比时剥离 frontmatter 与标题,只展示正文", () => {
+    const source = record()
+    source.chapters[0] = {
+      ...source.chapters[0],
+      sourceContent: [
+        "---",
+        "type: chapter",
+        "title: 第一章",
+        "chapter_number: 1",
+        "---",
+        "",
+        "# 第一章",
+        "",
+        "第一章原文",
+      ].join("\n"),
+      candidateContent: [
+        "---",
+        "title: 不应出现",
+        "---",
+        "# 假标题",
+        "",
+        "第一章候选",
+      ].join("\n"),
+    }
+    const props = render("chapter-1", callbacks(), source)
+
+    expect(document.body.textContent).toContain("第一章原文")
+    expect(document.body.textContent).not.toContain("chapter_number")
+    expect(document.body.textContent).not.toContain("不应出现")
+    expect(document.body.querySelector<HTMLTextAreaElement>('textarea[aria-label="最新源码"]')?.value)
+      .toBe("第一章候选")
+
+    act(() => button("确认当前章").click())
+    expect(props.onConfirm).toHaveBeenCalledWith("task-a", "chapter-1", "第一章候选")
+  })
+})

+ 8 - 2
src/components/novel/de-ai-batch-review-dialog.tsx

@@ -14,6 +14,7 @@ import {
   ResizablePanelGroup,
 } from "@/components/ui/resizable"
 import { AiChangeComparePanel } from "@/components/common/ai-change-compare-panel"
+import { extractDeAiChapterText } from "@/lib/chapter-selection"
 import type { DeAiBatchChapter, DeAiBatchTaskRecord } from "@/lib/novel/de-ai-batch/types"
 
 const CHAPTER_STATUS: Record<DeAiBatchChapter["status"], string> = {
@@ -90,9 +91,14 @@ export function DeAiBatchReviewDialog({
     ?? record?.chapters[0]
     ?? null
   const candidateDraft = chapter ? candidateDrafts[chapter.id] : undefined
-  const candidateContent = chapter && candidateDraft?.generation === chapter.generation
+  const rawCandidateContent = chapter && candidateDraft?.generation === chapter.generation
     ? candidateDraft.content
     : chapter?.candidateContent ?? ""
+  // 旧任务可能把 frontmatter 一并塞进 source/candidate;对比与编辑都只暴露正文。
+  const originalContent = extractDeAiChapterText(chapter?.sourceContent ?? "")
+  const candidateContent = candidateDraft?.generation === chapter?.generation
+    ? rawCandidateContent
+    : extractDeAiChapterText(rawCandidateContent)
   const candidateEditable = !pending && chapter?.status === "ready"
   const canConfirm = candidateEditable && !!candidateContent.trim()
   const canSaveDraft = !pending
@@ -126,7 +132,7 @@ export function DeAiBatchReviewDialog({
 
   const comparisonPanel = (
     <AiChangeComparePanel
-      originalContent={chapter?.sourceContent ?? ""}
+      originalContent={originalContent}
       modifiedContent={candidateContent}
       originalLabel={"原文"}
       modifiedLabel={"候选"}

+ 7 - 0
src/components/sources/outline-action-toolbar.spec.ts

@@ -10,4 +10,11 @@ describe("OutlineActionToolbar", () => {
     expect(source).toContain("setOutlineChatOpen(!outlineChatOpen)")
     expect(source).toContain('aria-pressed={outlineChatOpen}')
   })
+
+  it("asks whether to extract all or only pending outlines before bulk ingest", () => {
+    expect(source).toContain("bulkIngestDialogOpen")
+    expect(source).toContain('handleBulkIngest("pending")')
+    expect(source).toContain('handleBulkIngest("all")')
+    expect(source).toContain('runBulkOutlineIngest(project.path, { mode })')
+  })
 })

+ 53 - 4
src/components/sources/outline-action-toolbar.tsx

@@ -2,7 +2,20 @@ import { useCallback, useState } from "react"
 import { Loader2, MessageSquare } from "lucide-react"
 import { useTranslation } from "react-i18next"
 import { Button } from "@/components/ui/button"
-import { runBulkOutlineIngest, formatBulkOutlineIngestResult, OutlineIngestNotReadyError } from "@/lib/novel/outline-generation"
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog"
+import {
+  runBulkOutlineIngest,
+  formatBulkOutlineIngestResult,
+  OutlineIngestNotReadyError,
+  type BulkOutlineIngestMode,
+} from "@/lib/novel/outline-generation"
 import { cn } from "@/lib/utils"
 import { toast } from "@/lib/toast"
 import { useImportProgressStore } from "@/stores/import-progress-store"
@@ -26,6 +39,7 @@ export function OutlineActionToolbar({
   const outlineChatOpen = useOutlineGenerationStore((s) => s.panelOpen)
   const setOutlineChatOpen = useOutlineGenerationStore((s) => s.setPanelOpen)
   const [bulkIngestRunning, setBulkIngestRunning] = useState(false)
+  const [bulkIngestDialogOpen, setBulkIngestDialogOpen] = useState(false)
 
   const bulkOutlineProgressRunning = useImportProgressStore((s) => (
     project != null && s.tasks.some((task) => (
@@ -46,12 +60,13 @@ export function OutlineActionToolbar({
     setActiveView("sources")
   }, [onToggleOutlineChat, outlineChatOpen, setActiveView, setOutlineChatOpen])
 
-  const handleBulkIngest = useCallback(async () => {
+  const handleBulkIngest = useCallback(async (mode: BulkOutlineIngestMode) => {
     if (!project || bulkIngestActive) return
+    setBulkIngestDialogOpen(false)
     setBulkIngestRunning(true)
     onBulkIngestResult?.(null)
     try {
-      const result = await runBulkOutlineIngest(project.path)
+      const result = await runBulkOutlineIngest(project.path, { mode })
       onBulkIngestResult?.(formatBulkOutlineIngestResult(result))
     } catch (err) {
       if (err instanceof OutlineIngestNotReadyError) {
@@ -72,7 +87,12 @@ export function OutlineActionToolbar({
         <MessageSquare className="mr-1 h-4 w-4" />
         AI大纲
       </Button>
-      <Button size="sm" variant="outline" onClick={() => void handleBulkIngest()} disabled={bulkIngestActive}>
+      <Button
+        size="sm"
+        variant="outline"
+        onClick={() => setBulkIngestDialogOpen(true)}
+        disabled={bulkIngestActive}
+      >
         {bulkIngestActive ? (
           <>
             <Loader2 className="mr-1 h-4 w-4 animate-spin" />
@@ -82,6 +102,35 @@ export function OutlineActionToolbar({
           t("novel.outlineGenerator.bulkIngest")
         )}
       </Button>
+      <Dialog
+        open={bulkIngestDialogOpen}
+        onOpenChange={(open) => {
+          if (!open) setBulkIngestDialogOpen(false)
+        }}
+      >
+        <DialogContent className="sm:max-w-md">
+          <DialogHeader>
+            <DialogTitle>{t("novel.outlineGenerator.bulkIngestDialogTitle")}</DialogTitle>
+            <DialogDescription>
+              {t("novel.outlineGenerator.bulkIngestDialogDescription")}
+            </DialogDescription>
+          </DialogHeader>
+          <div className="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
+            {t("novel.outlineGenerator.bulkIngestDialogHint")}
+          </div>
+          <DialogFooter>
+            <Button type="button" variant="outline" onClick={() => setBulkIngestDialogOpen(false)}>
+              {t("common.cancel")}
+            </Button>
+            <Button type="button" variant="secondary" onClick={() => void handleBulkIngest("pending")}>
+              {t("novel.outlineGenerator.bulkIngestPending")}
+            </Button>
+            <Button type="button" onClick={() => void handleBulkIngest("all")}>
+              {t("novel.outlineGenerator.bulkIngestAll")}
+            </Button>
+          </DialogFooter>
+        </DialogContent>
+      </Dialog>
     </div>
   )
 }

+ 10 - 16
src/components/sources/outline-chat-panel.spec.tsx

@@ -533,7 +533,7 @@ describe("OutlineChatPanel controls", () => {
   it("将 AI 大纲上下文摘要持久化到会话字段而不是组件内存缓存", () => {
     expect(source).toContain("contextSummary:")
     expect(source).toContain("buildSessionContextSummary")
-    expect(source).toContain("dependencies: contextHubResult?.dependencies")
+    expect(source).toContain("dependencyFingerprint: contextHubResult?.dependencyStamp.fingerprint")
     // 上下文摘要已通过 setConversationContextSummary 持久化到会话字段
     expect(source).toContain("setConversationContextSummary")
     expect(source).not.toContain("contextSummaryByConversation")
@@ -678,9 +678,6 @@ describe("OutlineChatPanel controls", () => {
     expect(source).toContain("classification.targetFolder")
     expect(source).toContain("classification.fileName")
     expect(source).toContain("保存大纲文件")
-    expect(source).toContain("summarizeChapterOutlineQuality")
-    expect(source).toContain("formatChapterOutlineQualityReport")
-    expect(source).toContain("includeWarnings: true")
   })
 
   it("parses structured AI outline save requests and requires user confirmation before writing", () => {
@@ -694,14 +691,6 @@ describe("OutlineChatPanel controls", () => {
     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 folder save confirm dialog for classified outline saves", () => {
     expect(source).toContain("OutlineSaveConfirmDialog")
     expect(source).toContain("OutlineSaveConfirmPayload")
@@ -758,11 +747,16 @@ describe("OutlineChatPanel controls", () => {
     expect(trigger).toBeDefined()
     await act(async () => {
       trigger?.click()
-      await new Promise((resolve) => setTimeout(resolve, 20))
     })
-    const option = Array.from(document.body.querySelectorAll("button")).find((button) =>
-      button !== trigger && button.textContent?.includes(label),
-    ) as HTMLButtonElement | undefined
+    let option: HTMLButtonElement | undefined
+    for (let attempt = 0; attempt < 50 && !option; attempt += 1) {
+      await act(async () => {
+        await new Promise((resolve) => setTimeout(resolve, 10))
+      })
+      option = Array.from(document.body.querySelectorAll("button")).find((button) =>
+        button !== trigger && button.textContent?.includes(label),
+      ) as HTMLButtonElement | undefined
+    }
     expect(option).toBeDefined()
     await act(async () => {
       option?.click()

+ 42 - 23
src/components/sources/outline-chat-panel.tsx

@@ -113,7 +113,7 @@ import {
   resolveNovelModel,
   resolveUsableModelKey,
 } from "@/lib/novel/model-resolver";
-import { getEffectiveSavedModels } from "@/lib/llm-model-keys";
+import { hasAvailableModels as hasConfiguredModels } from "@/lib/llm-model-keys";
 import { ChatModelSelector } from "@/components/chat/chat-model-selector";
 import { highlightCode } from "@/lib/streaming-code-highlight";
 import { separateThinking } from "@/lib/separate-thinking";
@@ -204,6 +204,8 @@ import {
 } from "@/lib/conversation-create-guard";
 import { outlineConversationRunRegistry } from "@/lib/conversation-run-registry";
 import { toast } from "@/lib/toast";
+import { finalizeStructuredMarkdownMessage } from "@/lib/novel/markdown-quality-finalizer";
+import { repairMarkdownFormatWithAi } from "@/lib/novel/markdown-quality-ai-repair";
 import {
   type OutlineWorkflowStage,
   canTransitionOutlineWorkflow,
@@ -1194,23 +1196,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   );
   const historyCount = historyConversations.length;
 
-  const hasAvailableModels = useMemo(() => {
-    for (const key of Object.keys(providerConfigs)) {
-      const config = providerConfigs[key];
-      if (key.startsWith("custom-")) {
-        if (config.enabled === false) continue;
-      } else {
-        // 内置预设:已启用,或有有效配置(apiKey + model/savedModels)
-        const hasConfig = config.enabled === true
-          || Boolean((config.apiKey || config.savedModels?.length) && (config.model || config.savedModels?.length));
-        if (!hasConfig) continue;
-      }
-      if (getEffectiveSavedModels(config).length > 0) {
-        return true;
-      }
-    }
-    return false;
-  }, [providerConfigs]);
+  const hasAvailableModels = useMemo(
+    () => hasConfiguredModels(providerConfigs),
+    [providerConfigs],
+  );
 
   const defaultOutlineLlmConfig = useMemo(
     () => resolveNovelModel(llmConfig, novelConfig, "writing"),
@@ -2524,7 +2513,26 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           completedModule: intentContextsRef.current[capturedConvId]?.title || "当前模块",
         });
         const cleanFinalContent = nextStepExtraction.cleanText || "AI大纲未返回内容。";
-        const finalContent = cleanFinalContent;
+        const structuredMarkdownEnabled = options.intentPhase === "generation"
+          || options.novelGenerationRequest !== undefined;
+        const finalContent = await finalizeStructuredMarkdownMessage(
+          cleanFinalContent,
+          {
+            enabled: structuredMarkdownEnabled,
+            repairWithAi: ({ content, maxTokens }) => repairMarkdownFormatWithAi({
+              content,
+              llmConfig: effectiveLlmConfig,
+              signal: controller.signal,
+              maxTokens,
+            }),
+            onFailure: () => {
+              if (!isCurrentRun()) return;
+              toast.info("Markdown 格式自动修复未完全通过,已保留内容最完整的版本。", {
+                dedupeKey: "outline-markdown-quality-incomplete",
+              });
+            },
+          },
+        );
         if (!isCurrentRun()) return { started: true, sent: false };
         // 先将流式内容同步为最终内容,确保打字机效果立即终止、切换无跳变
         setStreamingContent(capturedConvId, finalContent);
@@ -2603,7 +2611,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               { role: "user", content: prompt },
               { role: "assistant", content: finalContent },
             ],
-            dependencies: contextHubResult?.dependencies ?? {},
+            dependencyFingerprint: contextHubResult?.dependencyStamp.fingerprint ?? "",
           }),
         };
         if (!isCurrentRun()) return { started: true, sent: false };
@@ -3109,7 +3117,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         if (completedConversation) {
           setConversationContextSummary(capturedConvId, buildSessionContextSummary({
             messages: completedConversation.messages,
-            dependencies: contextHubResult?.dependencies ?? {},
+            dependencyFingerprint: contextHubResult?.dependencyStamp.fingerprint ?? "",
           }));
           void useOutlineChatStore.getState().saveToDisk();
         }
@@ -3441,7 +3449,18 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           { allowFallback: true, completedModule: "当前模块" },
         );
         const cleanFinalContent = nextStepExtraction.cleanText || "AI大纲未返回内容。";
-        const finalContent = cleanFinalContent;
+        const finalContent = await finalizeStructuredMarkdownMessage(cleanFinalContent, {
+          enabled: regenerationInput.structuredGeneration,
+          repairWithAi: ({ content, maxTokens }) => repairMarkdownFormatWithAi({
+            content,
+            llmConfig: effectiveLlmConfig,
+            signal: controller.signal,
+            maxTokens,
+          }),
+          onFailure: () => toast.info("Markdown 格式自动修复未完全通过,已保留内容最完整的版本。", {
+            dedupeKey: "outline-markdown-quality-incomplete",
+          }),
+        });
         if (!isCurrentRun()) return;
         // 先将流式内容同步为最终内容,确保打字机效果立即终止、切换无跳变
         setStreamingContent(capturedConvId, finalContent);
@@ -3464,7 +3483,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             { role: "user", content: lastUserRequest },
             { role: "assistant", content: finalContent },
           ],
-          dependencies: contextHubResult?.dependencies ?? {},
+          dependencyFingerprint: contextHubResult?.dependencyStamp.fingerprint ?? "",
         }));
         if (!isCurrentRun()) return;
         await handleAutoSaveOutlineRequests(capturedConvId, finalContent, isCurrentRun);

+ 12 - 0
src/i18n/en.json

@@ -798,6 +798,12 @@
         "emptyValue": "(empty)",
         "endpointPreviewWillUse": "Will use",
         "endpointPreviewAutoApply": "(auto-applies on blur)",
+        "functionCalling": {
+          "label": "Enable Function Calling",
+          "hint": "When disabled, requests through this provider omit tools and tool_choice, including built-in and MCP tools, for compatibility with relays or local models that do not support tool calling.",
+          "stateOn": "ON",
+          "stateOff": "OFF"
+        },
         "cliStatus": {
           "title": "CLI Status",
           "checking": "Checking…",
@@ -1487,7 +1493,13 @@
       "reingestTitle": "Re-extract initial memory (overwrites the previous extraction)",
       "bulkIngest": "Extract All",
       "bulkIngesting": "Extracting all...",
+      "bulkIngestDialogTitle": "Choose extraction scope",
+      "bulkIngestDialogDescription": "Choose which outlines this bulk extraction should process.",
+      "bulkIngestDialogHint": "“Extract all” overwrites already extracted outline memory. “Unextracted only” skips outlines that already have a snapshot.",
+      "bulkIngestAll": "Extract all",
+      "bulkIngestPending": "Unextracted only",
       "bulkIngestEmpty": "There are no outline documents to extract in the current outline library.",
+      "bulkIngestAlreadyExtracted": "All outlines have already been extracted. Nothing left to process.",
       "bulkIngestResult": "Bulk extraction complete: {{succeeded}} succeeded, {{failed}} failed, {{total}} total.",
       "bulkIngestResultWithFailures": "Bulk extraction complete: {{succeeded}} succeeded, {{failed}} failed, {{total}} total. Failures:",
       "bulkIngestMoreFailures": "{{count}} more outline(s) failed to extract.",

+ 12 - 0
src/i18n/zh.json

@@ -505,6 +505,12 @@
         "emptyValue": "未填写",
         "endpointPreviewWillUse": "将使用",
         "endpointPreviewAutoApply": ",失焦后自动应用",
+        "functionCalling": {
+          "label": "启用 Function Calling",
+          "hint": "关闭后,使用该供应商时请求不携带 tools/tool_choice(含内置工具与 MCP),用于兼容不支持工具调用的中转或本地模型。",
+          "stateOn": "ON",
+          "stateOff": "OFF"
+        },
         "cliStatus": {
           "title": "CLI 状态",
           "checking": "检查中...",
@@ -1429,7 +1435,13 @@
       "reingestTitle": "重新提取初始记忆(将覆盖上次提取的内容)",
       "bulkIngest": "一键提取",
       "bulkIngesting": "一键提取中...",
+      "bulkIngestDialogTitle": "选择提取范围",
+      "bulkIngestDialogDescription": "请选择本次一键提取要处理的大纲范围。",
+      "bulkIngestDialogHint": "“提取全部”会覆盖已提取过的大纲记忆;“仅未提取”会跳过已有快照的大纲。",
+      "bulkIngestAll": "提取全部",
+      "bulkIngestPending": "仅未提取",
       "bulkIngestEmpty": "当前大纲库里没有可提取的大纲文档。",
+      "bulkIngestAlreadyExtracted": "所有大纲都已提取过,没有需要处理的未提取大纲。",
       "bulkIngestResult": "一键提取完成:共 {{total}} 个大纲,成功 {{succeeded}} 个,失败 {{failed}} 个。",
       "bulkIngestResultWithFailures": "一键提取完成:共 {{total}} 个大纲,成功 {{succeeded}} 个,失败 {{failed}} 个。失败明细:",
       "bulkIngestMoreFailures": "另有 {{count}} 个大纲提取失败。",

+ 3 - 0
src/lib/agent/plan-execute-policy.spec.ts

@@ -12,18 +12,21 @@ describe("Plan Execute policy", () => {
     expect(shouldRequirePlan(true, "fast", "write_chapter")).toBe(true)
     expect(buildPlanExecutePolicyPrompt("fast")).toContain("快速模式")
     expect(buildPlanExecutePolicyPrompt("fast")).toContain("先给出最短可执行计划")
+    expect(buildPlanExecutePolicyPrompt("fast")).toContain("必须调用 run_chapter_workflow")
   })
 
   it("creates a lightweight plan in standard mode when Plan Execute is enabled", () => {
     expect(shouldRequirePlan(true, "standard", "write_chapter")).toBe(true)
     expect(buildPlanExecutePolicyPrompt("standard")).toContain("标准模式")
     expect(buildPlanExecutePolicyPrompt("standard")).toContain("先创建轻量计划")
+    expect(buildPlanExecutePolicyPrompt("standard")).toContain("必须调用 run_chapter_workflow")
   })
 
   it("requires plan execute and review in strict mode when Plan Execute is enabled", () => {
     expect(shouldRequirePlan(true, "strict", "rewrite_chapter")).toBe(true)
     expect(buildPlanExecutePolicyPrompt("strict")).toContain("必须先计划")
     expect(buildPlanExecutePolicyPrompt("strict")).toContain("执行后审查")
+    expect(buildPlanExecutePolicyPrompt("strict")).toContain("必须调用 run_chapter_workflow")
   })
 
   it("requires a user-facing executable plan instead of workflow narration", () => {

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

@@ -27,7 +27,7 @@ export function buildPlanExecutePolicyPrompt(mode: LegacyAiWorkflowMode): string
     "计划必须整体包裹在 `<!-- chapter_plan -->` 和 `<!-- /chapter_plan -->` 标记中。",
     "输出计划后必须暂停,等待用户确认后再进入正文或执行阶段。",
     "计划必须包含:任务目标、已读取依据、缺失资料、执行步骤、确认后动作。",
-    "读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件;不要凭空编造章节、大纲或记忆条目名称。list_outlines 后按 type 分流:优先关注 overview(索引)与 concept(硬约束),对 outline 类必须读正文确认对应该章后再写。",
+    "读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件;不要凭空编造章节、大纲或记忆条目名称。list_outlines 后按文件夹分流(优先):关注 大纲(索引)、设定(硬约束)、章纲(本章主候选)、卷纲;旧 type 仅无标准文件夹时作兼容参考;对章纲/卷纲必须读正文确认对应该章后再写。",
     "如果资料缺失,必须在“缺失资料”里说明,并基于已读取内容继续制定可执行方案。",
   ].join("\n")
 
@@ -37,7 +37,7 @@ export function buildPlanExecutePolicyPrompt(mode: LegacyAiWorkflowMode): string
       "快速模式:用户已主动开启计划执行,先给出最短可执行计划再直接执行。",
       "计划最多 3 条,只写将要读取和执行的关键步骤。",
       executablePlanFormat,
-      "如果需要生成、续写、改写或润色章节,优先调用 run_chapter_workflow。",
+      "如果需要生成、续写、改写或润色章节,必须调用 run_chapter_workflow;未调用前禁止输出章节终稿。",
     ].join("\n")
   }
 
@@ -48,7 +48,7 @@ export function buildPlanExecutePolicyPrompt(mode: LegacyAiWorkflowMode): string
       "计划必须简短,最多 5 条,只写将要读取和执行的关键步骤。",
       "执行后审查结果是否满足用户请求、项目设定和输出边界。",
       executablePlanFormat,
-      "如果是章节生成、续写、改写或润色,必须优先调用 run_chapter_workflow。",
+      "如果是章节生成、续写、改写或润色,必须调用 run_chapter_workflow;未调用前禁止输出章节终稿。",
     ].join("\n")
   }
 
@@ -57,6 +57,6 @@ export function buildPlanExecutePolicyPrompt(mode: LegacyAiWorkflowMode): string
     "标准模式:先创建轻量计划,再快速执行。",
     "计划最多 3 条,不能替代正文,不能把计划混入最终章节正文。",
     executablePlanFormat,
-    "如果是章节生成、续写、改写或润色,优先调用 run_chapter_workflow。",
+    "如果是章节生成、续写、改写或润色,必须调用 run_chapter_workflow;未调用前禁止输出章节终稿。",
   ].join("\n")
 }

+ 1 - 1
src/lib/agent/plugins/build-system-prompt-plugin.spec.ts

@@ -106,7 +106,7 @@ describe("BuildSystemPromptPlugin selected skills", () => {
     expect(result.finalSystemPrompt).toContain("planBlueprint")
     expect(result.finalSystemPrompt).toContain("大纲定位协议")
     expect(result.finalSystemPrompt).toContain("list_outlines")
-    expect(result.finalSystemPrompt).toContain("按 type 分流")
+    expect(result.finalSystemPrompt).toContain("按文件夹分流")
     expect(result.finalSystemRulesPrompt).toContain("章节主编策划协议")
     expect(result.finalSystemRulesPrompt).not.toContain("context prompt")
     const finalPrompt = result.finalSystemPrompt ?? ""

+ 1 - 1
src/lib/agent/plugins/build-system-prompt-plugin.ts

@@ -97,7 +97,7 @@ function buildChapterPlanProtocol(mode: LegacyAiWorkflowMode): string {
     "输出规范:",
     "1. 计划必须整体包裹在 `<!-- chapter_plan -->` 和 `<!-- /chapter_plan -->` 标记中。",
     "2. 计划只供用户确认,正文生成必须等待用户确认后再开始。当前阶段禁用正文生成类工具,只能使用读取类工具收集资料。",
-    "3. 计划必须基于会话上下文包;读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件名,绝不编造资料名称。list_outlines 后按 type 分流:优先关注 overview(索引)与 concept(硬约束),对 outline 类必须 read_outline 读正文确认对应该章后再写。",
+    "3. 计划必须基于会话上下文包;读取资料前先用 list_chapters、list_outlines、list_memories 确认可用文件名,绝不编造资料名称。list_outlines 后按文件夹分流(优先):关注 大纲(索引)、设定(硬约束)、章纲(本章主候选)、卷纲;旧 type 仅无标准文件夹时作兼容参考;对章纲/卷纲必须 read_outline 读正文确认对应该章后再写。",
     "4. 计划总长控制在 1200-1800字,避免堆砌分析维度;用结论和执行项表达。",
     "5. 场景必须用 S1/S2/S3 编号,后续正文会按编号执行。",
     "6. 不要使用旧版分析报告式编号标题。",

+ 159 - 0
src/lib/agent/required-tools-gate.spec.ts

@@ -0,0 +1,159 @@
+import { describe, expect, it } from "vitest"
+import {
+  RequiredToolsNotCalledError,
+  buildRequiredToolNudgeMessage,
+  missingRequiredToolsOnce,
+  resolveRequiredToolsOnce,
+  shouldBlockFinalWithoutRequiredTools,
+} from "./required-tools-gate"
+
+describe("required-tools-gate", () => {
+  it("does not block when no required tools configured", () => {
+    expect(
+      shouldBlockFinalWithoutRequiredTools({
+        availableToolNames: ["run_chapter_workflow"],
+        calledToolNames: [],
+        toolsEnabled: true,
+      }),
+    ).toBe(false)
+  })
+
+  it("blocks when required tool is available but not called", () => {
+    const missing = missingRequiredToolsOnce({
+      requiredToolsOnce: ["run_chapter_workflow"],
+      availableToolNames: ["read_chapter", "run_chapter_workflow"],
+      calledToolNames: ["read_chapter"],
+      toolsEnabled: true,
+    })
+    expect(missing).toEqual(["run_chapter_workflow"])
+    expect(
+      shouldBlockFinalWithoutRequiredTools({
+        requiredToolsOnce: ["run_chapter_workflow"],
+        availableToolNames: ["run_chapter_workflow"],
+        calledToolNames: [],
+        toolsEnabled: true,
+      }),
+    ).toBe(true)
+  })
+
+  it("does not block when required tool was already called", () => {
+    expect(
+      shouldBlockFinalWithoutRequiredTools({
+        requiredToolsOnce: ["run_chapter_workflow"],
+        availableToolNames: ["run_chapter_workflow"],
+        calledToolNames: ["run_chapter_workflow"],
+        toolsEnabled: true,
+      }),
+    ).toBe(false)
+  })
+
+  it("does not block when tools are disabled", () => {
+    expect(
+      shouldBlockFinalWithoutRequiredTools({
+        requiredToolsOnce: ["run_chapter_workflow"],
+        availableToolNames: ["run_chapter_workflow"],
+        calledToolNames: [],
+        toolsEnabled: false,
+      }),
+    ).toBe(false)
+  })
+
+  it("ignores required tools that are not available", () => {
+    expect(
+      missingRequiredToolsOnce({
+        requiredToolsOnce: ["run_chapter_workflow"],
+        availableToolNames: ["read_chapter"],
+        calledToolNames: [],
+        toolsEnabled: true,
+      }),
+    ).toEqual([])
+  })
+
+  it("builds a Chinese nudge that forbids direct final prose", () => {
+    const message = buildRequiredToolNudgeMessage(["run_chapter_workflow"])
+    expect(message).toContain("禁止直接输出章节终稿")
+    expect(message).toContain("run_chapter_workflow")
+  })
+
+  it("RequiredToolsNotCalledError names missing tools", () => {
+    const err = new RequiredToolsNotCalledError(["run_chapter_workflow"])
+    expect(err.name).toBe("RequiredToolsNotCalledError")
+    expect(err.message).toContain("run_chapter_workflow")
+    expect(err.missingTools).toEqual(["run_chapter_workflow"])
+  })
+})
+
+describe("resolveRequiredToolsOnce", () => {
+  it("requires workflow for non-fast chapter writing when tool is enabled", () => {
+    expect(
+      resolveRequiredToolsOnce({
+        novelMode: true,
+        intent: "write_chapter",
+        mode: "strict",
+        planExecuteActive: false,
+        enabledToolNames: ["read_chapter", "run_chapter_workflow"],
+      }),
+    ).toEqual(["run_chapter_workflow"])
+
+    expect(
+      resolveRequiredToolsOnce({
+        novelMode: true,
+        intent: "polish_chapter",
+        mode: "standard",
+        planExecuteActive: false,
+      }),
+    ).toEqual(["run_chapter_workflow"])
+  })
+
+  it("skips fast mode, plan phase, non-novel, and non-writing intents", () => {
+    expect(
+      resolveRequiredToolsOnce({
+        novelMode: true,
+        intent: "write_chapter",
+        mode: "fast",
+        planExecuteActive: false,
+        enabledToolNames: ["run_chapter_workflow"],
+      }),
+    ).toBeUndefined()
+
+    expect(
+      resolveRequiredToolsOnce({
+        novelMode: true,
+        intent: "write_chapter",
+        mode: "strict",
+        planExecuteActive: true,
+        enabledToolNames: ["run_chapter_workflow"],
+      }),
+    ).toBeUndefined()
+
+    expect(
+      resolveRequiredToolsOnce({
+        novelMode: false,
+        intent: "write_chapter",
+        mode: "strict",
+        planExecuteActive: false,
+      }),
+    ).toBeUndefined()
+
+    expect(
+      resolveRequiredToolsOnce({
+        novelMode: true,
+        intent: "character_query",
+        mode: "strict",
+        planExecuteActive: false,
+      }),
+    ).toBeUndefined()
+  })
+
+  it("skips when enabledToolNames explicitly omits the workflow tool", () => {
+    expect(
+      resolveRequiredToolsOnce({
+        novelMode: true,
+        intent: "write_chapter",
+        mode: "strict",
+        planExecuteActive: false,
+        enabledToolNames: ["read_chapter", "read_outline"],
+      }),
+    ).toBeUndefined()
+  })
+})

+ 78 - 0
src/lib/agent/required-tools-gate.ts

@@ -0,0 +1,78 @@
+import type { NovelTaskIntent } from "@/lib/novel/task-router"
+import { OUTLINE_FIND_CHAPTER_INTENTS } from "@/lib/novel/outline-find-protocol"
+import { resolveAiWorkflowMode, type LegacyAiWorkflowMode } from "./workflow-mode"
+
+export interface RequiredToolsGateInput {
+  requiredToolsOnce?: string[] | null
+  availableToolNames: Iterable<string>
+  calledToolNames: Iterable<string>
+  toolsEnabled: boolean
+}
+
+/** 返回仍缺失的必调工具名;空数组表示不拦截。 */
+export function missingRequiredToolsOnce(input: RequiredToolsGateInput): string[] {
+  const required = (input.requiredToolsOnce ?? []).filter((name) => name.trim())
+  if (!input.toolsEnabled || required.length === 0) return []
+
+  const available = new Set(input.availableToolNames)
+  const applicable = required.filter((name) => available.has(name))
+  if (applicable.length === 0) return []
+
+  const called = new Set(input.calledToolNames)
+  return applicable.filter((name) => !called.has(name))
+}
+
+export function shouldBlockFinalWithoutRequiredTools(input: RequiredToolsGateInput): boolean {
+  return missingRequiredToolsOnce(input).length > 0
+}
+
+export function buildRequiredToolNudgeMessage(missing: string[]): string {
+  const names = missing.join("、")
+  return [
+    "系统约束:禁止直接输出章节终稿正文。",
+    `本轮必须先调用以下工具:${names}。`,
+    "请立即调用 run_chapter_workflow(可先补读必要上下文),不要把章节正文作为本轮最终回复。",
+  ].join("\n")
+}
+
+export class RequiredToolsNotCalledError extends Error {
+  readonly missingTools: string[]
+
+  constructor(missingTools: string[]) {
+    super(
+      `章节写作未调用必选工具(${missingTools.join("、")}),已拒绝未走工作流的终稿。请重试并调用 run_chapter_workflow。`,
+    )
+    this.name = "RequiredToolsNotCalledError"
+    this.missingTools = missingTools
+  }
+}
+
+export interface ResolveRequiredToolsOnceInput {
+  novelMode: boolean
+  intent?: string | null
+  mode?: LegacyAiWorkflowMode | null
+  planExecuteActive: boolean
+  enabledToolNames?: string[] | null
+}
+
+/**
+ * 非 fast 章节写作、且工具可用、非计划收集阶段时,要求至少调用一次 run_chapter_workflow。
+ */
+export function resolveRequiredToolsOnce(
+  input: ResolveRequiredToolsOnceInput,
+): string[] | undefined {
+  if (!input.novelMode) return undefined
+  if (input.planExecuteActive) return undefined
+  if (!input.intent || !OUTLINE_FIND_CHAPTER_INTENTS.has(input.intent as NovelTaskIntent)) {
+    return undefined
+  }
+  const mode = input.mode == null ? "strict" : resolveAiWorkflowMode(input.mode)
+  if (mode === "fast") return undefined
+
+  const enabled = input.enabledToolNames
+  if (Array.isArray(enabled) && !enabled.includes("run_chapter_workflow")) {
+    return undefined
+  }
+
+  return ["run_chapter_workflow"]
+}

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

@@ -88,6 +88,205 @@ describe("AgentRunner", () => {
     expect(callbacks.onError).not.toHaveBeenCalled()
   })
 
+  it("nudges and continues when requiredToolsOnce is missing on a no-tool final", async () => {
+    const tool: Tool = {
+      name: "run_chapter_workflow",
+      description: "workflow",
+      category: "action",
+      parameters: {
+        userRequest: { type: "string", description: "request", required: true },
+      },
+      execute: vi.fn().mockResolvedValue("最终正文:\n章节内容"),
+    }
+    registry.register(tool)
+
+    mockStreamChat
+      .mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+        cb.onToken("我直接写完了")
+        cb.onDone()
+      })
+      .mockImplementationOnce(async (_config: unknown, msgs: unknown[], cb: StreamCallbacks) => {
+        const messages = msgs as AgentMessage[]
+        expect(messages.some((message) =>
+          message.role === "system" &&
+          typeof message.content === "string" &&
+          message.content.includes("禁止直接输出章节终稿"),
+        )).toBe(true)
+        cb.onToolCallDelta?.({ index: 0, id: "call_workflow_1", name: "run_chapter_workflow" })
+        cb.onToolCallDelta?.({ index: 0, arguments: '{"userRequest":"写第1章"}' })
+        cb.onDone()
+      })
+      .mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+        cb.onToken("最终交付正文")
+        cb.onDone()
+      })
+
+    const callbacks = {
+      onText: vi.fn(),
+      onToolCall: vi.fn(),
+      onToolResult: vi.fn(),
+      onToolError: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+    const result = await runner.run(
+      {
+        maxRounds: 4,
+        tools: [tool],
+        systemPrompt: "You are helpful",
+        llmConfig: mockLlmConfig,
+        requiredToolsOnce: ["run_chapter_workflow"],
+      },
+      registry,
+      [systemMsg, userMsg],
+      callbacks,
+      undefined,
+    )
+
+    expect(tool.execute).toHaveBeenCalledOnce()
+    expect(callbacks.onDone).toHaveBeenCalledOnce()
+    expect(callbacks.onError).not.toHaveBeenCalled()
+    expect(result.finalText).toBe("最终交付正文")
+    expect(result.roundsUsed).toBe(3)
+    // blocked draft must not stream as final
+    expect(callbacks.onText.mock.calls.flat()).not.toContain("我直接写完了")
+  })
+
+  it("errors on last round when requiredToolsOnce is never called", async () => {
+    const tool: Tool = {
+      name: "run_chapter_workflow",
+      description: "workflow",
+      category: "action",
+      parameters: {
+        userRequest: { type: "string", description: "request", required: true },
+      },
+      execute: vi.fn().mockResolvedValue("ok"),
+    }
+    registry.register(tool)
+
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      cb.onToken("直出正文")
+      cb.onDone()
+    })
+
+    const callbacks = {
+      onText: vi.fn(),
+      onToolCall: vi.fn(),
+      onToolResult: vi.fn(),
+      onToolError: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+    const result = await runner.run(
+      {
+        maxRounds: 2,
+        tools: [tool],
+        systemPrompt: "You are helpful",
+        llmConfig: mockLlmConfig,
+        requiredToolsOnce: ["run_chapter_workflow"],
+      },
+      registry,
+      [systemMsg, userMsg],
+      callbacks,
+      undefined,
+    )
+
+    expect(callbacks.onDone).not.toHaveBeenCalled()
+    expect(callbacks.onError).toHaveBeenCalledOnce()
+    expect(callbacks.onError.mock.calls[0][0].name).toBe("RequiredToolsNotCalledError")
+    expect(result.finalText).toBe("")
+    expect(tool.execute).not.toHaveBeenCalled()
+  })
+
+  it("does not block no-tool finals when requiredToolsOnce is unset", async () => {
+    const tool: Tool = {
+      name: "run_chapter_workflow",
+      description: "workflow",
+      category: "action",
+      parameters: {},
+      execute: vi.fn().mockResolvedValue("ok"),
+    }
+    registry.register(tool)
+
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      cb.onToken("普通回复")
+      cb.onDone()
+    })
+
+    const callbacks = {
+      onText: vi.fn(),
+      onToolCall: vi.fn(),
+      onToolResult: vi.fn(),
+      onToolError: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+    const result = await runner.run(
+      {
+        maxRounds: 2,
+        tools: [tool],
+        systemPrompt: "You are helpful",
+        llmConfig: mockLlmConfig,
+      },
+      registry,
+      [systemMsg, userMsg],
+      callbacks,
+      undefined,
+    )
+
+    expect(result.finalText).toBe("普通回复")
+    expect(callbacks.onDone).toHaveBeenCalledOnce()
+    expect(callbacks.onError).not.toHaveBeenCalled()
+  })
+
+  it("skips required-tools gate after tools fallback disables tools", async () => {
+    const tool: Tool = {
+      name: "run_chapter_workflow",
+      description: "workflow",
+      category: "action",
+      parameters: {},
+      execute: vi.fn().mockResolvedValue("ok"),
+    }
+    registry.register(tool)
+
+    let callCount = 0
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks, _signal?: unknown, overrides?: { tools?: unknown }) => {
+      callCount += 1
+      if (callCount === 1 && overrides?.tools) {
+        cb.onError?.(new Error("tools are not supported"))
+        return
+      }
+      cb.onToken("无工具回退正文")
+      cb.onDone()
+    })
+
+    const callbacks = {
+      onText: vi.fn(),
+      onToolCall: vi.fn(),
+      onToolResult: vi.fn(),
+      onToolError: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+    const result = await runner.run(
+      {
+        maxRounds: 2,
+        tools: [tool],
+        systemPrompt: "You are helpful",
+        llmConfig: mockLlmConfig,
+        requiredToolsOnce: ["run_chapter_workflow"],
+      },
+      registry,
+      [systemMsg, userMsg],
+      callbacks,
+      undefined,
+    )
+
+    expect(result.finalText).toBe("无工具回退正文")
+    expect(callbacks.onDone).toHaveBeenCalledOnce()
+    expect(callbacks.onError).not.toHaveBeenCalled()
+  })
+
   it("replays reasoning_content on tool-call assistant messages in the next round", async () => {
     const tool: Tool = {
       name: "read_chapter",

+ 32 - 0
src/lib/agent/runner.ts

@@ -19,6 +19,11 @@ import { addLlmUsage } from "../llm-usage"
 import { trimChatMessagesToBudget } from "../chat-request-budget"
 import { logReasoningReplay } from "../reasoning-replay-debug"
 import { ToolEvidenceLedger } from "./tool-evidence-ledger"
+import {
+  RequiredToolsNotCalledError,
+  buildRequiredToolNudgeMessage,
+  missingRequiredToolsOnce,
+} from "./required-tools-gate"
 
 export class ModelDoesNotSupportToolsError extends Error {
   constructor() {
@@ -256,6 +261,33 @@ export class AgentRunner {
       }
 
       if (toolCalls.length === 0) {
+        const missingRequired = missingRequiredToolsOnce({
+          requiredToolsOnce: config.requiredToolsOnce,
+          availableToolNames: config.tools.map((tool) => tool.name),
+          calledToolNames: record.toolCalls.map((call) => call.name),
+          toolsEnabled: Boolean(openaiTools),
+        })
+        if (missingRequired.length > 0) {
+          if (roundText.trim() || roundReasoningContent) {
+            workingMessages.push({
+              role: "assistant",
+              content: roundText || "",
+              reasoning_content: roundReasoningContent,
+            })
+          }
+          workingMessages.push({
+            role: "system",
+            content: buildRequiredToolNudgeMessage(missingRequired),
+          })
+          const isLastRound = round >= maxRounds - 1
+          if (isLastRound) {
+            await clearPersistedBreakpoint()
+            callbacks.onError(new RequiredToolsNotCalledError(missingRequired))
+            return record
+          }
+          continue
+        }
+
         finalText = roundText
         record.finalText = finalText
         if (roundText) callbacks.onText(roundText)

+ 1 - 1
src/lib/agent/tools/list-outlines.ts

@@ -33,7 +33,7 @@ export function createListOutlinesTool(
   return {
     name: "list_outlines",
     description:
-      "列出大纲目录下全部 Markdown 文件(含子目录),并标注 frontmatter 的 type / outline_type。可选参数 chapterNumber 用于在结果中标注本次目标章号。写章节前应先调用本工具,再按 type 分流并用 read_outline 读正文判断哪份对应该章。",
+      "列出大纲目录下全部 Markdown 文件(含子目录),优先标注标准文件夹(大纲/卷纲/章纲/设定等),旧 frontmatter 的 type / outline_type 仅作兼容附加。可选参数 chapterNumber 用于在结果中标注本次目标章号。写章节前应先调用本工具,再按文件夹分流并用 read_outline 读正文判断哪份对应该章。",
     category: "read",
     parameters: {
       chapterNumber: {

+ 11 - 3
src/lib/agent/tools/list-tools.spec.ts

@@ -43,12 +43,13 @@ describe("list tools", () => {
     expect(result).toContain("本次目标章号:第 167 章")
   })
 
-  it("list_outlines recursively lists files with type annotations", async () => {
+  it("list_outlines recursively lists files with folder-first annotations", async () => {
     vi.mocked(listDirectory).mockImplementation(async (path: string) => {
       if (path.endsWith("/outlines")) {
         return [
           { name: "全局设定.md", path: `${path}/全局设定.md`, is_dir: false },
           { name: "卷纲", path: `${path}/卷纲`, is_dir: true },
+          { name: "章纲", path: `${path}/章纲`, is_dir: true },
         ]
       }
       if (path.endsWith("/卷纲")) {
@@ -56,6 +57,11 @@ describe("list tools", () => {
           { name: "第三卷大纲.md", path: `${path}/第三卷大纲.md`, is_dir: false },
         ]
       }
+      if (path.endsWith("/章纲")) {
+        return [
+          { name: "第52章-条件.md", path: `${path}/第52章-条件.md`, is_dir: false },
+        ]
+      }
       return []
     })
     vi.mocked(readFile).mockImplementation(async (path: string) => {
@@ -65,14 +71,16 @@ describe("list tools", () => {
       if (path.includes("第三卷大纲")) {
         return `---\ntype: outline\ntitle: "第三卷"\n---\n`
       }
-      return ""
+      return "# 纯 Markdown 章纲\n"
     })
 
     const tool = createListOutlinesTool("/project/wiki/outlines")
     const result = await tool.execute({ chapterNumber: 167 })
     expect(result).toContain("全局设定.md  type=overview")
-    expect(result).toContain("卷纲/第三卷大纲.md  type=outline")
+    expect(result).toContain("卷纲/第三卷大纲.md  folder=卷纲  type=outline")
+    expect(result).toContain("章纲/第52章-条件.md  folder=章纲")
     expect(result).toContain("本次目标章号:第 167 章")
+    expect(result).toContain("优先按文件夹(folder)分流")
   })
 
   it("list_memories returns file list from memory dir", async () => {

+ 46 - 18
src/lib/agent/tools/outline-list-helpers.spec.ts

@@ -1,6 +1,7 @@
 import { describe, expect, it } from "vitest"
 import {
   buildOutlineListToolResult,
+  extractOutlineFolder,
   extractOutlineTypeFields,
   formatOutlineListLine,
   type OutlineListEntry,
@@ -29,46 +30,73 @@ title: "规则"
     expect(extractOutlineTypeFields("# 无 frontmatter")).toEqual({})
   })
 
-  it("formats list lines with type fields", () => {
-    const entry: OutlineListEntry = {
+  it("extracts standard outline folder from relative path", () => {
+    expect(extractOutlineFolder("章纲/第52章-条件.md")).toBe("章纲")
+    expect(extractOutlineFolder("设定/写作通则.md")).toBe("设定")
+    expect(extractOutlineFolder("第三卷大纲.md")).toBeUndefined()
+    expect(extractOutlineFolder("其他/说明.md")).toBeUndefined()
+  })
+
+  it("formats list lines with folder first, then optional type fields", () => {
+    const withFolderAndType: OutlineListEntry = {
       relativePath: "卷纲/第三卷.md",
       absolutePath: "/p/wiki/outlines/卷纲/第三卷.md",
+      folder: "卷纲",
       type: "outline",
       outlineType: "volume-outline",
     }
-    expect(formatOutlineListLine(entry, 0)).toBe(
-      "1. 卷纲/第三卷.md  type=outline  outline_type=volume-outline",
+    expect(formatOutlineListLine(withFolderAndType, 0)).toBe(
+      "1. 卷纲/第三卷.md  folder=卷纲  type=outline  outline_type=volume-outline",
+    )
+
+    const folderOnly: OutlineListEntry = {
+      relativePath: "章纲/第52章-条件.md",
+      absolutePath: "/p/wiki/outlines/章纲/第52章-条件.md",
+      folder: "章纲",
+    }
+    expect(formatOutlineListLine(folderOnly, 1)).toBe(
+      "2. 章纲/第52章-条件.md  folder=章纲",
     )
   })
 
-  it("builds tool result with mixed types and optional target chapter", () => {
+  it("builds tool result with folder-first guidance and optional target chapter", () => {
     const result = buildOutlineListToolResult(
       [
         {
-          relativePath: "第三卷大纲.md",
-          absolutePath: "/o/第三卷大纲.md",
+          relativePath: "章纲/第52章-条件.md",
+          absolutePath: "/o/章纲/第52章-条件.md",
+          folder: "章纲",
+        },
+        {
+          relativePath: "设定/写作通则.md",
+          absolutePath: "/o/设定/写作通则.md",
+          folder: "设定",
+        },
+        {
+          relativePath: "卷纲/第三卷.md",
+          absolutePath: "/o/卷纲/第三卷.md",
+          folder: "卷纲",
           type: "outline",
+          outlineType: "volume-outline",
         },
         {
           relativePath: "全局设定.md",
           absolutePath: "/o/全局设定.md",
           type: "overview",
         },
-        {
-          relativePath: "钢铁洪流系统规则.md",
-          absolutePath: "/o/钢铁洪流系统规则.md",
-          type: "concept",
-        },
       ],
       167,
     )
 
-    expect(result).toContain("1. 第三卷大纲.md  type=outline")
-    expect(result).toContain("2. 全局设定.md  type=overview")
-    expect(result).toContain("3. 钢铁洪流系统规则.md  type=concept")
+    expect(result).toContain("1. 章纲/第52章-条件.md  folder=章纲")
+    expect(result).toContain("2. 设定/写作通则.md  folder=设定")
+    expect(result).toContain("3. 卷纲/第三卷.md  folder=卷纲  type=outline  outline_type=volume-outline")
+    expect(result).toContain("4. 全局设定.md  type=overview")
     expect(result).toContain("本次目标章号:第 167 章")
-    expect(result).toContain("overview:优先当索引读")
-    expect(result).toContain("concept:全书硬约束")
-    expect(result).toContain("不要跳过 overview/concept")
+    expect(result).toContain("优先按文件夹(folder)分流")
+    expect(result).toContain("章纲:本章主候选")
+    expect(result).toContain("设定:全书硬约束")
+    expect(result).toContain("旧兼容:overview≈大纲")
+    expect(result).toContain("优先在章纲中定位")
   })
 })

+ 28 - 6
src/lib/agent/tools/outline-list-helpers.ts

@@ -1,12 +1,21 @@
+/**
+ * list_outlines 列表与标注:文件夹优先,旧 frontmatter type 仅作兼容附加。
+ */
+
 import { listDirectory, readFile } from "@/commands/fs"
 import { parseFrontmatter } from "@/lib/frontmatter"
+import { DEFAULT_OUTLINE_FOLDERS } from "@/lib/novel/outline-workbench"
 import { normalizePath } from "@/lib/path-utils"
 import type { FileNode } from "@/types/wiki"
 
+const STANDARD_OUTLINE_FOLDERS = new Set(DEFAULT_OUTLINE_FOLDERS.map((folder) => folder.name))
+
 export interface OutlineListEntry {
   /** 相对 outlines 根目录的路径,含 .md */
   relativePath: string
   absolutePath: string
+  /** 路径首段命中标准大纲文件夹时 */
+  folder?: string
   type?: string
   outlineType?: string
 }
@@ -33,6 +42,13 @@ export function extractOutlineTypeFields(content: string): {
   }
 }
 
+/** 从相对路径首段解析标准大纲文件夹名 */
+export function extractOutlineFolder(relativePath: string): string | undefined {
+  const first = relativePath.split("/").filter(Boolean)[0]
+  if (!first || first.toLowerCase().endsWith(".md")) return undefined
+  return STANDARD_OUTLINE_FOLDERS.has(first) ? first : undefined
+}
+
 async function collectOutlineMarkdownFiles(
   rootDir: string,
   depth = 0,
@@ -69,6 +85,7 @@ function toRelativePath(outlinesDir: string, absolutePath: string): string {
 
 export function formatOutlineListLine(entry: OutlineListEntry, index: number): string {
   const parts = [`${index + 1}. ${entry.relativePath}`]
+  if (entry.folder) parts.push(`folder=${entry.folder}`)
   if (entry.type) parts.push(`type=${entry.type}`)
   if (entry.outlineType) parts.push(`outline_type=${entry.outlineType}`)
   return parts.join("  ")
@@ -85,6 +102,7 @@ export async function listOutlineEntries(
 
   for (const file of files) {
     const relativePath = toRelativePath(outlinesDir, file.path) || file.name
+    const folder = extractOutlineFolder(relativePath)
     let type: string | undefined
     let outlineType: string | undefined
     try {
@@ -99,6 +117,7 @@ export async function listOutlineEntries(
     entries.push({
       relativePath,
       absolutePath: file.path,
+      folder,
       type,
       outlineType,
     })
@@ -122,16 +141,19 @@ export function buildOutlineListToolResult(
     "可用大纲列表:",
     ...entries.map((entry, index) => formatOutlineListLine(entry, index)),
     "",
-    "说明:请根据 frontmatter 的 type / outline_type 分流。",
-    "- overview:优先当索引读,用来发现规则与卷纲入口。",
-    "- concept:全书硬约束/机制,写正文前关注并读取相关项;不要用章号匹配。",
-    "- outline:读正文判断是否对目标章;同为 outline 也可能不是卷纲。",
-    "不要只凭文件名选择;不要跳过 overview/concept 只读一份卷纲。",
+    "说明:优先按文件夹(folder)分流;无标准文件夹时再参考旧 frontmatter 的 type / outline_type。",
+    "- 大纲:索引/总纲入口,用来发现规则与卷纲入口;通常不是本章剧情细纲。",
+    "- 设定:全书硬约束/机制,写正文前关注并读取相关项;不要用章号匹配。",
+    "- 章纲:本章主候选,按目标章号定位后读正文。",
+    "- 卷纲:卷级候选,确认章号落在该卷后再读。",
+    "- 人物小传 / 伏笔 / 组织:辅助资料,不当本章剧情大纲。",
+    "- 旧兼容:overview≈大纲,concept≈设定,outline≈卷纲/章纲(视 outline_type)。",
+    "不要只凭文件名选择;不要跳过 大纲/设定(或旧 overview/concept)只读一份卷纲。",
   ]
 
   if (typeof targetChapterNumber === "number" && targetChapterNumber > 0) {
     lines.push(
-      `本次目标章号:第 ${targetChapterNumber} 章。请结合 overview/concept 约束,为该章找到正文内容对应的大纲。`,
+      `本次目标章号:第 ${targetChapterNumber} 章。请优先在章纲中定位,并结合设定/大纲约束,为该章找到对应大纲。`,
     )
   }
 

+ 21 - 1
src/lib/agent/tools/read-markdown-resource.ts

@@ -1,4 +1,5 @@
 import { listDirectory, readFile } from "@/commands/fs"
+import { isAbsolutePath, isPathInside, normalizePath } from "@/lib/path-utils"
 import type { FileNode } from "@/types/wiki"
 
 interface MarkdownCandidate {
@@ -14,6 +15,21 @@ interface DirectoryCandidate {
 
 export type ReadTextFile = (path: string) => Promise<string>
 
+function resolveExplicitResourcePath(baseDir: string, path: string): string | null {
+  const normalizedBase = normalizePath(baseDir).replace(/\/+$/, "")
+  const normalizedPath = normalizePath(path).trim()
+  if (!normalizedBase || !normalizedPath) return null
+
+  let candidate = normalizedPath
+  if (!isAbsolutePath(normalizedPath)) {
+    const segments = normalizedPath.split("/").filter((segment) => segment && segment !== ".")
+    if (segments.some((segment) => segment === "..")) return null
+    candidate = `${normalizedBase}/${segments.join("/")}`
+  }
+
+  return isPathInside(candidate, normalizedBase) ? candidate : null
+}
+
 function ensureMarkdownName(name: string): string {
   return name.toLowerCase().endsWith(".md") ? name : `${name}.md`
 }
@@ -140,8 +156,12 @@ export async function readMarkdownResource(
   const displayName = name || explicitPath
 
   if (explicitPath) {
+    const resolvedPath = resolveExplicitResourcePath(baseDir, explicitPath)
+    if (!resolvedPath) {
+      return `错误:无法读取${label}「${displayName}」,文件路径必须位于${label}目录内`
+    }
     try {
-      return await readTextFile(explicitPath)
+      return await readTextFile(resolvedPath)
     } catch {
       return `错误:无法读取${label}「${displayName}」,请确认文件存在`
     }

+ 2 - 2
src/lib/agent/tools/read-outline.ts

@@ -61,11 +61,11 @@ export function createReadOutlineTool(
 ): Tool {
   return {
     name: "read_outline",
-    description: "读取指定大纲文件的完整内容。参数 path 为大纲文件的完整路径,或 name 为大纲名称。",
+    description: "读取指定大纲文件的完整内容。参数 path 可为大纲目录内的完整路径或相对路径,或用 name 指定大纲名称。",
     category: "read",
     parameters: {
       name: { type: "string", description: "大纲名称" },
-      path: { type: "string", description: "大纲文件完整路径(可选,与 name 二选一)" },
+      path: { type: "string", description: "大纲文件完整路径或相对大纲目录的路径(可选,与 name 二选一)" },
     },
     execute: async (params) => {
       const result = await readMarkdownResource(outlinesDir, params, "大纲", readTextFile)

+ 20 - 0
src/lib/agent/tools/read-tools.spec.ts

@@ -149,6 +149,26 @@ describe("read tools", () => {
     expect(result).toBe("outline content")
   })
 
+  it("read_outline resolves an explicit relative path inside outlines dir", async () => {
+    vi.mocked(readFile).mockResolvedValue("nested outline content")
+    const tool = createReadOutlineTool("/project/wiki/outlines")
+
+    const result = await tool.execute({ path: "章纲/第9章-谋划.md" })
+
+    expect(result).toBe("nested outline content")
+    expect(readFile).toHaveBeenCalledWith("/project/wiki/outlines/章纲/第9章-谋划.md")
+    expect(readFile).not.toHaveBeenCalledWith("章纲/第9章-谋划.md")
+  })
+
+  it("read_outline rejects an explicit path outside outlines dir", async () => {
+    const tool = createReadOutlineTool("/project/wiki/outlines")
+
+    const result = await tool.execute({ path: "../chapters/第9章.md" })
+
+    expect(result).toContain("文件路径必须位于大纲目录内")
+    expect(readFile).not.toHaveBeenCalled()
+  })
+
   it("read_outline matches punctuation-insensitive outline names", async () => {
     vi.mocked(readFile).mockImplementation(async (path) => {
       if (path === "/project/wiki/outlines/他只想活着大纲.md") return "大纲内容"

+ 5 - 0
src/lib/agent/types.ts

@@ -58,6 +58,11 @@ export interface AgentConfig {
   projectPath?: string
   /** Stage F: 本次任务目标,用于断点恢复 */
   taskGoal?: string
+  /**
+   * 本 run 结束前必须至少发起过一次的工具名。
+   * 缺则拒绝无 tool 终稿并续轮(见 AgentRunner required-tools gate)。
+   */
+  requiredToolsOnce?: string[]
 }
 
 export interface AgentToolEvent {

+ 7 - 34
src/lib/changelog.spec.ts

@@ -2,43 +2,16 @@ import { describe, expect, it } from "vitest"
 import { allChangelog, currentVersionChangelog } from "./changelog"
 
 describe("changelog", () => {
-  it("shows the latest visible 2.2 releases before earlier releases", () => {
+  it("shows current releases first and keeps intentionally retained history", () => {
     const entries = allChangelog()
     const versions = entries.map((entry) => entry.version)
 
-    expect(versions.slice(0, 30)).toEqual([
-      "2.2.37",
-      "2.2.36",
-      "2.2.35",
-      "2.2.33",
-      "2.2.32",
-      "2.2.31",
-      "2.2.30",
-      "2.2.29",
-      "2.2.27",
-      "2.2.26",
-      "2.2.25",
-      "2.2.24",
-      "2.2.23",
-      "2.2.22",
-      "2.2.21",
-      "2.2.20",
-      "2.2.19",
-      "2.2.18",
-      "2.2.17",
-      "2.2.16",
-      "2.2.14",
-      "2.2.13",
-      "2.2.12",
-      "2.2.11",
-      "2.2.10",
-      "2.2.9",
-      "2.2.8",
-      "2.2.7",
-      "2.2.0",
-      "2.1.0",
-    ])
-    expect(versions[30]).toBe("2.0.0")
+    expect(versions.slice(0, 3)).toEqual(["3.1.2", "3.1.1", "3.1.0"])
+    expect(versions).toContain("3.0.9")
+    expect(versions).toContain("2.2.37")
+    expect(versions).toContain("2.1.0")
+    expect(versions).toContain("2.0.0")
+    expect(new Set(versions).size).toBe(versions.length)
 
     for (let patch = 1; patch <= 6; patch += 1) {
       expect(versions).not.toContain(`2.2.${patch}`)

+ 40 - 2
src/lib/changelog.ts

@@ -20,6 +20,34 @@ const THREE_POINT_ONE_TWO_CHANGELOG: ChangelogEntry = {
   },
 };
 
+const THREE_POINT_ONE_ONE_CHANGELOG: ChangelogEntry = {
+  version: "3.1.1",
+  date: "2026-08-06",
+  highlights: {
+    en: [
+      "[Outline Save Queue Fix] Pending outline save confirmations are now merged and queued without being overwritten by later generation rounds; normal and character save batches drain in order.",
+      "[Runtime Alignment] Updated frontend and Rust dependencies and aligned local and CI builds with Node.js 24 LTS.",
+    ],
+    zh: [
+      "【大纲保存队列修复】后续生成轮次不再覆盖尚未确认的大纲保存请求;普通大纲批次合并去重,普通与人物批次按顺序排队处理",
+      "【运行环境对齐】更新前端与 Rust 依赖,并将本地和 CI 构建环境统一到 Node.js 24 LTS",
+    ],
+  },
+};
+
+const THREE_POINT_ONE_ZERO_CHANGELOG: ChangelogEntry = {
+  version: "3.1.0",
+  date: "2026-08-06",
+  highlights: {
+    en: [
+      "[Save Flow Simplification] Removed automatic review interception before saving chapters and outlines; manual review in the Review Center remains available.",
+    ],
+    zh: [
+      "【保存流程简化】移除章节和大纲保存前的自动审稿拦截;审稿中心的手动审稿功能保持不变",
+    ],
+  },
+};
+
 const THREE_POINT_ZERO_NINE_CHANGELOG: ChangelogEntry = {
   version: "3.0.9",
   date: "2026-08-04",
@@ -1091,6 +1119,10 @@ export const CHANGELOG: ChangelogEntry[] = [
 export function currentVersionChangelog(version: string): ChangelogEntry[] {
   if (version === THREE_POINT_ONE_TWO_CHANGELOG.version)
     return [THREE_POINT_ONE_TWO_CHANGELOG];
+  if (version === THREE_POINT_ONE_ONE_CHANGELOG.version)
+    return [THREE_POINT_ONE_ONE_CHANGELOG];
+  if (version === THREE_POINT_ONE_ZERO_CHANGELOG.version)
+    return [THREE_POINT_ONE_ZERO_CHANGELOG];
   if (version === THREE_POINT_ZERO_NINE_CHANGELOG.version)
     return [THREE_POINT_ZERO_NINE_CHANGELOG];
   if (version === THREE_POINT_ZERO_EIGHT_CHANGELOG.version)
@@ -1180,6 +1212,9 @@ export function currentVersionChangelog(version: string): ChangelogEntry[] {
 
 export function allChangelog(): ChangelogEntry[] {
   return [
+    THREE_POINT_ONE_TWO_CHANGELOG,
+    THREE_POINT_ONE_ONE_CHANGELOG,
+    THREE_POINT_ONE_ZERO_CHANGELOG,
     THREE_POINT_ZERO_NINE_CHANGELOG,
     THREE_POINT_ZERO_EIGHT_CHANGELOG,
     THREE_POINT_ZERO_SEVEN_CHANGELOG,
@@ -1220,7 +1255,10 @@ export function allChangelog(): ChangelogEntry[] {
     TWO_POINT_TWO_ZERO_CHANGELOG,
     TWO_POINT_ONE_ZERO_CHANGELOG,
     TWO_POINT_ZERO_CHANGELOG,
-    ...CHANGELOG.filter((entry) => !isMergedOnePointRelease(entry.version)),
+    ...CHANGELOG.filter(
+      (entry) =>
+        entry !== THREE_POINT_ONE_TWO_CHANGELOG &&
+        !isMergedOnePointRelease(entry.version),
+    ),
   ];
 }
-

+ 58 - 0
src/lib/chapter-selection.spec.ts

@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest"
+import { extractDeAiChapterText, replaceWholeChapterBody } from "./chapter-selection"
+
+describe("extractDeAiChapterText", () => {
+  it("strips YAML frontmatter and chapter heading so compare uses body prose only", () => {
+    const markdown = [
+      "---",
+      "type: chapter",
+      "title: 第一章",
+      "chapter_number: 1",
+      "tags: [开篇, 主角]",
+      "---",
+      "",
+      "# 第一章",
+      "",
+      "雨忽然停了。",
+      "他抬起头。",
+    ].join("\n")
+
+    expect(extractDeAiChapterText(markdown)).toBe("雨忽然停了。\n他抬起头。")
+  })
+
+  it("strips wrapping markdown fences from model output", () => {
+    expect(extractDeAiChapterText("```markdown\n改写后的正文\n```")).toBe("改写后的正文")
+  })
+
+  it("keeps plain prose unchanged", () => {
+    expect(extractDeAiChapterText("只有正文")).toBe("只有正文")
+  })
+
+  it("matches the prose that replaceWholeChapterBody will write back", () => {
+    const current = [
+      "---",
+      "type: chapter",
+      "title: 第一章",
+      "---",
+      "# 第一章",
+      "",
+      "旧正文",
+    ].join("\n")
+    const candidate = [
+      "---",
+      "title: 不该写回",
+      "---",
+      "# 假标题",
+      "",
+      "新正文",
+    ].join("\n")
+
+    const written = replaceWholeChapterBody(current, candidate)
+    expect(extractDeAiChapterText(current)).toBe("旧正文")
+    expect(extractDeAiChapterText(candidate)).toBe("新正文")
+    expect(written).not.toContain("旧正文")
+    expect(written).toContain("新正文")
+    expect(written).toContain("# 第一章")
+    expect(written).not.toContain("不该写回")
+  })
+})

+ 15 - 0
src/lib/chapter-selection.ts

@@ -37,6 +37,21 @@ export function replaceWholeChapterBody(currentMarkdown: string, replacementMark
   return rawBlock + rebuildChapterBody(heading, replacementBody)
 }
 
+/**
+ * 去 AI 味对比 / 送模只用“会被写回替换”的正文:去掉 frontmatter、章节标题行、代码围栏包装。
+ * 写回仍走 replaceWholeChapterBody,frontmatter 与原标题会保留。
+ */
+export function extractDeAiChapterText(markdown: string): string {
+  const { body } = parseFrontmatter(markdown)
+  const { body: prose } = splitChapterHeading(body)
+  let normalized = prose.replace(/\r\n?/g, "\n").trim()
+  const fenceMatch = normalized.match(/^```[a-zA-Z]*\s*\n([\s\S]*?)\n?```\s*$/)
+  if (fenceMatch?.[1]) {
+    normalized = fenceMatch[1].replace(/\r\n?/g, "\n").trim()
+  }
+  return normalized
+}
+
 export function buildPolishSelectionMessages(content: string): ChatMessage[] {
   if (!content.trim()) throw new Error("润色内容为空,无法处理")
   return [

+ 168 - 1
src/lib/chat-copy-content.test.ts

@@ -1,5 +1,32 @@
 import { expect, test } from "vitest"
-import { getCopyableAssistantContent } from "./chat-copy-content"
+import {
+  extractChapterBodyFromToolCalls,
+  extractWorkflowFinalContent,
+  getCopyableAssistantContent,
+  isThinChapterAssistantContent,
+  shouldPreferWorkflowChapterBody,
+} from "./chat-copy-content"
+
+function buildWorkflowResult(body: string): string {
+  return [
+    "章节工作流完成。",
+    "是否返修:是",
+    "任务书:outline",
+    "",
+    "最终正文:",
+    body,
+  ].join("\n")
+}
+
+const LONG_CHAPTER_BODY = [
+  "# 第32章 查分夜",
+  "",
+  "六月下旬的晚上,长泰广场八楼的灯亮到很晚。".repeat(20),
+  "",
+  "陈远坐在评审位前,屏幕上是云记3.0的代码评审页面。".repeat(15),
+  "",
+  "苏晴把手机递过来,两人继续改搜索层级。".repeat(15),
+].join("\n")
 
 test("copies generated chapter edit content instead of surrounding context", () => {
   const content = [
@@ -23,3 +50,143 @@ test("copies generated chapter edit content instead of surrounding context", ()
   expect(copied).not.toContain("Outline context")
   expect(copied).not.toContain("<file_edit")
 })
+
+test("strips trailing 出错 suffix when copying assistant content", () => {
+  const content = [
+    "# 第22章 大学同学局",
+    "",
+    "周六下午,陈远到了 NEAT。",
+    "",
+    "出错:HTTP 503: Service Unavailable — upstream connect error",
+  ].join("\n")
+
+  expect(getCopyableAssistantContent(content)).toBe(
+    "# 第22章 大学同学局\n\n周六下午,陈远到了 NEAT。",
+  )
+})
+
+test("extracts final chapter body from run_chapter_workflow tool result", () => {
+  const workflowResult = [
+    "章节工作流完成。",
+    "是否返修:是",
+    "任务书:# 第22章-大学同学局",
+    "",
+    "最终正文:",
+    "# 第22章 大学同学局",
+    "",
+    "周六下午一点四十五,陈远把车停在安福路。",
+    "",
+    "产品要活下来,得先学会在真实世界里呼吸。",
+  ].join("\n")
+
+  expect(extractWorkflowFinalContent(workflowResult)).toContain("第22章 大学同学局")
+  expect(extractWorkflowFinalContent(workflowResult)).toContain("真实世界里呼吸")
+  expect(extractChapterBodyFromToolCalls([
+    {
+      name: "run_chapter_workflow",
+      status: "done",
+      result: workflowResult,
+    },
+  ])).toContain("真实世界里呼吸")
+})
+
+test("recovers chapter body from tool calls when message content is only an error", () => {
+  const workflowResult = [
+    "章节工作流完成。",
+    "是否返修:是",
+    "任务书:outline",
+    "",
+    "最终正文:",
+    "# 第22章 大学同学局",
+    "",
+    "周六下午,陈远推开 NEAT 的门。",
+  ].join("\n")
+
+  const copied = getCopyableAssistantContent(
+    "出错:HTTP 503: Service Unavailable — upstream connect error or disconnect/reset before headers. retried and the latest reset reason: remote connection failure, transport failure reason: delayed connect error: Connection refused",
+    {
+      toolCalls: [
+        {
+          name: "chapter_final_polish",
+          status: "done",
+          result: "简单审查与去AI味完成,最终正文约 4506 字。",
+        },
+        {
+          name: "run_chapter_workflow",
+          status: "done",
+          result: workflowResult,
+        },
+        {
+          name: "chapter_execution_repair",
+          status: "error",
+          result: "HTTP 503: Service Unavailable",
+        },
+      ],
+    },
+  )
+
+  expect(copied).toContain("# 第22章 大学同学局")
+  expect(copied).toContain("推开 NEAT 的门")
+  expect(copied).not.toContain("出错:")
+  expect(copied).not.toContain("HTTP 503")
+  expect(copied).not.toContain("任务书:")
+})
+
+test("isThinChapterAssistantContent detects completion notices and short text", () => {
+  expect(isThinChapterAssistantContent("第 32 章正文已按章纲重写完成。")).toBe(true)
+  expect(isThinChapterAssistantContent("短文本")).toBe(true)
+  expect(isThinChapterAssistantContent(LONG_CHAPTER_BODY)).toBe(false)
+})
+
+test("shouldPreferWorkflowChapterBody only when assistant is thin and workflow is long", () => {
+  expect(
+    shouldPreferWorkflowChapterBody("第 32 章正文已按章纲重写完成。", LONG_CHAPTER_BODY),
+  ).toBe(true)
+  expect(shouldPreferWorkflowChapterBody(LONG_CHAPTER_BODY, LONG_CHAPTER_BODY)).toBe(false)
+  expect(shouldPreferWorkflowChapterBody("第 32 章正文已按章纲重写完成。", "短草稿")).toBe(false)
+})
+
+test("falls back to workflow final body when assistant content is a completion notice", () => {
+  const copied = getCopyableAssistantContent("第 32 章正文已按章纲重写完成。", {
+    toolCalls: [
+      {
+        name: "run_chapter_workflow",
+        status: "done",
+        result: buildWorkflowResult(LONG_CHAPTER_BODY),
+      },
+    ],
+  })
+
+  expect(copied).toContain("# 第32章 查分夜")
+  expect(copied).toContain("长泰广场八楼")
+  expect(copied).not.toContain("已按章纲重写完成")
+})
+
+test("keeps full assistant chapter body even when workflow also has final content", () => {
+  const assistantBody = [
+    "# 第32章 查分夜",
+    "",
+    "模型按章纲重写后的完整正文从这里开始。".repeat(40),
+    "",
+    "夜宵后苏晴发来周末见。".repeat(20),
+  ].join("\n")
+
+  const copied = getCopyableAssistantContent(assistantBody, {
+    toolCalls: [
+      {
+        name: "run_chapter_workflow",
+        status: "done",
+        result: buildWorkflowResult(LONG_CHAPTER_BODY),
+      },
+    ],
+  })
+
+  expect(copied).toContain("模型按章纲重写后的完整正文")
+  expect(copied).not.toContain("六月下旬的晚上")
+})
+
+test("keeps short assistant content when workflow body is unavailable", () => {
+  expect(getCopyableAssistantContent("第 32 章正文已按章纲重写完成。")).toBe(
+    "第 32 章正文已按章纲重写完成。",
+  )
+})

+ 91 - 2
src/lib/chat-copy-content.ts

@@ -1,6 +1,25 @@
 import { parseAgentResponse } from "@/lib/novel/agent-parser"
 import { cleanGeneratedChapterContentForSave } from "@/lib/novel/chapter-content-cleanup"
 
+const WORKFLOW_FINAL_CONTENT_MARKER = "最终正文:"
+const ASSISTANT_ERROR_SUFFIX_RE = /(?:^|\n{1,2})出错:[\s\S]*$/
+
+/** 助手可见内容短于此(去空白)时,视为不足以作为章节正文。 */
+export const THIN_CHAPTER_CONTENT_CHAR_LIMIT = 80
+/** workflow 正文至少这么长(去空白)才值得回退。 */
+export const WORKFLOW_BODY_MIN_CHAR_COUNT = 500
+/** workflow 相对助手内容的最小倍数,避免用略长草稿覆盖短但有效的改写。 */
+export const WORKFLOW_BODY_MIN_RATIO = 5
+
+const COMPLETION_NOTICE_RE =
+  /(?:已按章纲|重写完成|章节工作流完成|正文已.{0,12}完成|已(?:生成|重写|完成).{0,8}正文)/
+
+export type CopyableToolCall = {
+  name: string
+  result?: string
+  status?: string
+}
+
 function stripHiddenAssistantBlocks(content: string): string {
   let result = content
     .replace(/<!--.*?-->/gs, "")
@@ -30,7 +49,64 @@ function isChapterEditPath(filePath: string): boolean {
   return normalized.startsWith("wiki/chapters/") && normalized.endsWith(".md")
 }
 
-export function getCopyableAssistantContent(content: string): string {
+function stripAssistantErrorSuffix(content: string): string {
+  return content.replace(ASSISTANT_ERROR_SUFFIX_RE, "").trim()
+}
+
+function countCompactChars(text: string): number {
+  return text.replace(/\s/g, "").length
+}
+
+/** 助手可见内容是否像短完成通知或过短伪正文。 */
+export function isThinChapterAssistantContent(content: string): boolean {
+  const trimmed = content.trim()
+  if (!trimmed) return true
+  const compact = countCompactChars(trimmed)
+  if (compact <= THIN_CHAPTER_CONTENT_CHAR_LIMIT) return true
+  if (compact <= 200 && COMPLETION_NOTICE_RE.test(trimmed)) return true
+  return false
+}
+
+/** 是否应优先采用 workflow「最终正文」而非助手可见短文。 */
+export function shouldPreferWorkflowChapterBody(
+  assistantContent: string,
+  workflowBody: string,
+): boolean {
+  if (!workflowBody.trim()) return false
+  if (!isThinChapterAssistantContent(assistantContent)) return false
+  const workflowChars = countCompactChars(workflowBody)
+  const assistantChars = Math.max(countCompactChars(assistantContent), 1)
+  if (workflowChars < WORKFLOW_BODY_MIN_CHAR_COUNT) return false
+  return workflowChars >= assistantChars * WORKFLOW_BODY_MIN_RATIO
+}
+
+/** 从 run_chapter_workflow 工具结果中提取「最终正文」段。 */
+export function extractWorkflowFinalContent(result: string | undefined): string {
+  if (!result?.trim()) return ""
+  const markerIndex = result.lastIndexOf(WORKFLOW_FINAL_CONTENT_MARKER)
+  if (markerIndex < 0) return ""
+  return result.slice(markerIndex + WORKFLOW_FINAL_CONTENT_MARKER.length).replace(/^\n+/, "").trim()
+}
+
+/** 从已完成的章节工作流工具调用中取最新一份最终正文。 */
+export function extractChapterBodyFromToolCalls(
+  toolCalls: CopyableToolCall[] | undefined,
+): string {
+  if (!toolCalls?.length) return ""
+  for (let i = toolCalls.length - 1; i >= 0; i -= 1) {
+    const call = toolCalls[i]
+    if (call.name !== "run_chapter_workflow") continue
+    if (call.status === "error" || call.status === "cancelled") continue
+    const body = extractWorkflowFinalContent(call.result)
+    if (body) return body
+  }
+  return ""
+}
+
+export function getCopyableAssistantContent(
+  content: string,
+  options?: { toolCalls?: CopyableToolCall[] },
+): string {
   const parsed = parseAgentResponse(content)
   const chapterEditReplacements = parsed.edits
     .filter((edit) => isChapterEditPath(edit.filePath) && edit.replace.trim())
@@ -41,5 +117,18 @@ export function getCopyableAssistantContent(content: string): string {
     return chapterEditReplacements.join("\n\n").trim()
   }
 
-  return stripHiddenAssistantBlocks(parsed.textContent || content)
+  const fromContent = stripAssistantErrorSuffix(
+    stripHiddenAssistantBlocks(parsed.textContent || content),
+  )
+  const fromWorkflow = extractChapterBodyFromToolCalls(options?.toolCalls)
+
+  if (fromContent && shouldPreferWorkflowChapterBody(fromContent, fromWorkflow)) {
+    return fromWorkflow
+  }
+  if (fromContent) {
+    return fromContent
+  }
+  if (fromWorkflow) return fromWorkflow
+
+  return ""
 }

+ 11 - 9
src/lib/context-hub/composer.spec.ts

@@ -4,6 +4,8 @@ import type { ContextPack } from "@/lib/novel/context-engine"
 import { composeContext } from "./composer"
 import { estimateContextTokens } from "./token-estimator"
 
+const dependencyStamp = { fingerprint: "test", sourceCount: 0, kinds: [] }
+
 function pack(overrides: Partial<ContextPack> = {}): ContextPack {
   return {
     task: "续写第二章",
@@ -33,7 +35,7 @@ function pack(overrides: Partial<ContextPack> = {}): ContextPack {
 
 describe("composeContext", () => {
   it("keeps the stable core byte-identical with fixed field ordering", () => {
-    const input = { contextPack: pack(), dependencies: { outline: 1 } }
+    const input = { contextPack: pack(), dependencyStamp }
     const first = composeContext(input)
     const second = composeContext(input)
 
@@ -45,7 +47,7 @@ describe("composeContext", () => {
   it("places explicit references ahead of automatically selected dynamic context", () => {
     const result = composeContext({
       contextPack: pack(),
-      dependencies: {},
+      dependencyStamp,
       referenceContext: ["@引用:人物/林默.md\n林默怕水"],
     })
 
@@ -55,7 +57,7 @@ describe("composeContext", () => {
   it("expands to chapter originals when confidence is low", () => {
     const result = composeContext({
       contextPack: pack({ recentChapterContents: ["第一章原文"], searchResults: "补充检索" }),
-      dependencies: {},
+      dependencyStamp,
       confidence: 0.4,
     })
 
@@ -67,7 +69,7 @@ describe("composeContext", () => {
   it("trims low-priority search content before required task facts", () => {
     const result = composeContext({
       contextPack: pack({ searchResults: "低相关背景".repeat(500) }),
-      dependencies: {},
+      dependencyStamp,
       tokenBudget: 180,
       confidence: 0.9,
     })
@@ -85,7 +87,7 @@ describe("composeContext", () => {
         searchResults: "候选检索".repeat(500),
       }),
       sessionSummary: "当前会话已确认:继续第二章,不揭露凶手。",
-      dependencies: {},
+      dependencyStamp,
       confidence: 0.9,
       tokenBudget: 6000,
     })
@@ -99,7 +101,7 @@ describe("composeContext", () => {
         recentChapterContents: ["章节原文".repeat(1000)],
         searchResults: "低优先级检索".repeat(100),
       }),
-      dependencies: {},
+      dependencyStamp,
       confidence: 0.9,
       tokenBudget: 100_000,
     }
@@ -131,7 +133,7 @@ describe("composeContext", () => {
         mustDo: "必须做到".repeat(500),
       }),
       sessionSummary: "会话摘要".repeat(1000),
-      dependencies: {},
+      dependencyStamp,
       tokenBudget: 800,
     })
 
@@ -146,13 +148,13 @@ describe("composeContext", () => {
   it("无显式预算时按模型上下文窗口安全比例计算,而不是写死上限", () => {
     const large = composeContext({
       contextPack: pack(),
-      dependencies: {},
+      dependencyStamp,
       maxContextSize: 204_800,
       tokenBudget: 0,
     })
     const small = composeContext({
       contextPack: pack(),
-      dependencies: {},
+      dependencyStamp,
       maxContextSize: 32_000,
       tokenBudget: 0,
     })

+ 7 - 4
src/lib/context-hub/composer.ts

@@ -1,12 +1,12 @@
 import { resolveContextPackTokenBudget } from "@/lib/context-budget"
 import { contextPackToPrompt, type ContextPack } from "@/lib/novel/context-engine"
 import { estimateContextTokens } from "./token-estimator"
-import type { ContextHubStats } from "./types"
+import type { ContextHubStats, DependencyStamp } from "./types"
 
 export interface ComposeContextInput {
   contextPack: ContextPack
   sessionSummary?: string
-  dependencies: Record<string, number>
+  dependencyStamp: DependencyStamp
   referenceContext?: string[]
   confidence?: number
   /** Explicit token budget; 0 / undefined = window-derived safe cap. */
@@ -18,7 +18,7 @@ export interface ComposedContext {
   stableCore: string
   sessionSummary: string
   dynamicContext: string
-  dependencies: Record<string, number>
+  dependencyStamp: DependencyStamp
   stats: ContextHubStats
 }
 
@@ -196,7 +196,10 @@ export function composeContext(input: ComposeContextInput): ComposedContext {
     stableCore,
     sessionSummary,
     dynamicContext,
-    dependencies: { ...input.dependencies },
+    dependencyStamp: {
+      ...input.dependencyStamp,
+      kinds: [...input.dependencyStamp.kinds],
+    },
     stats: {
       hits: 0,
       refreshed: 0,

+ 42 - 21
src/lib/context-hub/context-hub.spec.ts

@@ -1,7 +1,21 @@
 import { describe, expect, it, vi } from "vitest"
 import type { ContextPack } from "@/lib/novel/context-engine"
 import { ContextHubController } from "./context-hub"
-import type { CachedArtifact, ContextHubSnapshot, StableBundle } from "./types"
+import type {
+  CachedArtifact,
+  ContextHubSnapshot,
+  ContextSourceKind,
+  DependencyStamp,
+  StableBundle,
+} from "./types"
+
+function stamp(
+  fingerprint = "project-v1",
+  kinds: ContextSourceKind[] = ["outline"],
+  sourceCount = 1,
+): DependencyStamp {
+  return { fingerprint, kinds, sourceCount }
+}
 
 function pack(): ContextPack {
   return {
@@ -35,7 +49,11 @@ function createHarness() {
   const snapshots = new Map<string, ContextHubSnapshot>()
   const registry = {
     refresh: vi.fn(async () => ({ versions: {}, changedPaths: [] as string[] })),
-    getDependencies: vi.fn(() => ({ "E:/Novel/wiki/outlines/main.md": 1 })),
+    getDependencyStamp: vi.fn(async (kinds?: ContextSourceKind[]) => stamp(
+      kinds ? "stable-v1" : "project-v1",
+      kinds ?? ["outline"],
+    )),
+    getDependencyPreview: vi.fn(() => ["E:/Novel/wiki/outlines/main.md"]),
     markDirty: vi.fn(),
     dispose: vi.fn(),
   }
@@ -47,6 +65,7 @@ function createHarness() {
     readSnapshot: vi.fn(async (_surface: string, id: string) => snapshots.get(id) ?? null),
     writeSnapshot: vi.fn(async (value: ContextHubSnapshot) => { snapshots.set(value.id, value) }),
     pruneSnapshots: vi.fn(async () => {}),
+    dispose: vi.fn(),
   }
   const buildContextPack = vi.fn(async () => pack())
   const readFile = vi.fn(async (path: string) => `内容:${path}:${readFile.mock.calls.length}`)
@@ -82,13 +101,13 @@ describe("ContextHubController", () => {
     const harness = createHarness()
     const chat = await harness.controller.prepare({
       ...request,
-      existingSummary: { text: "AI 对话摘要", dependencies: { "E:/Novel/wiki/outlines/main.md": 1 }, updatedAt: 1 },
+      existingSummary: { text: "AI 对话摘要", dependencyFingerprint: "project-v1", updatedAt: 1 },
     })
     const outline = await harness.controller.prepare({
       ...request,
       surface: "ai-outline",
       sessionId: "outline-1",
-      existingSummary: { text: "AI 大纲摘要", dependencies: { "E:/Novel/wiki/outlines/main.md": 1 }, updatedAt: 1 },
+      existingSummary: { text: "AI 大纲摘要", dependencyFingerprint: "project-v1", updatedAt: 1 },
     })
 
     expect(chat?.sessionSummary).toBe("AI 对话摘要")
@@ -101,7 +120,7 @@ describe("ContextHubController", () => {
     const result = await harness.controller.prepare({
       ...request,
       forceRefresh: true,
-      existingSummary: { text: "旧摘要", dependencies: { "E:/Novel/wiki/outlines/main.md": 1 }, updatedAt: 1 },
+      existingSummary: { text: "旧摘要", dependencyFingerprint: "project-v1", updatedAt: 1 },
     })
 
     expect(result?.sessionSummary).toBe("")
@@ -143,13 +162,8 @@ describe("ContextHubController", () => {
   it("keeps the stable core cached when only an unrelated chapter changes", async () => {
     const harness = createHarness()
     let chapterRevision = 1
-    harness.registry.getDependencies.mockImplementation((kinds?: string[]) => (
-      kinds
-        ? { "E:/Novel/wiki/outlines/main.md": 1 }
-        : {
-            "E:/Novel/wiki/outlines/main.md": 1,
-            "E:/Novel/wiki/chapters/chapter-1.md": chapterRevision,
-          }
+    harness.registry.getDependencyStamp.mockImplementation(async (kinds?: ContextSourceKind[]) => (
+      kinds ? stamp("stable-v1", kinds) : stamp(`project-${chapterRevision}`, ["outline", "chapter"], 2)
     ))
 
     await harness.controller.prepare(request)
@@ -163,15 +177,13 @@ describe("ContextHubController", () => {
     }))
   })
 
-  it("keeps the stable core cached when source revisions change but its bytes stay identical", async () => {
+  it("keeps the stable core cached when a refresh leaves its content fingerprint unchanged", async () => {
     const harness = createHarness()
-    let outlineRevision = 1
-    harness.registry.getDependencies.mockImplementation(() => ({
-      "E:/Novel/wiki/outlines/main.md": outlineRevision,
-    }))
+    harness.registry.getDependencyStamp.mockImplementation(async (kinds?: ContextSourceKind[]) => (
+      stamp("outline-content-hash", kinds ?? ["outline"])
+    ))
 
     await harness.controller.prepare(request)
-    outlineRevision = 2
     const second = await harness.controller.prepare({ ...request, task: "继续生成大纲" })
 
     expect(second?.cacheItems).toContainEqual(expect.objectContaining({
@@ -182,9 +194,7 @@ describe("ContextHubController", () => {
 
   it("removes a Windows project root from dependency paths case-insensitively", async () => {
     const harness = createHarness()
-    harness.registry.getDependencies.mockReturnValue({
-      "e:/Novel/wiki/outlines/main.md": 1,
-    })
+    harness.registry.getDependencyPreview.mockReturnValue(["e:/Novel/wiki/outlines/main.md"])
 
     const result = await harness.controller.prepare(request)
 
@@ -271,4 +281,15 @@ describe("ContextHubController", () => {
     expect(result).toBeNull()
     expect(harness.buildContextPack).not.toHaveBeenCalled()
   })
+
+  it("releases storage and refuses new prepares after disposal", async () => {
+    const harness = createHarness()
+
+    harness.controller.dispose()
+
+    await expect(harness.controller.prepare(request)).resolves.toBeNull()
+    expect(harness.registry.dispose).toHaveBeenCalledOnce()
+    expect(harness.storage.dispose).toHaveBeenCalledOnce()
+    expect(harness.buildContextPack).not.toHaveBeenCalled()
+  })
 })

+ 57 - 9
src/lib/context-hub/context-hub.ts

@@ -1,4 +1,6 @@
 import {
+  deleteFile,
+  fileExists,
   readFile as readProjectFile,
   subscribeProjectFileMutations,
   type ProjectFileMutation,
@@ -23,17 +25,21 @@ import {
   type ContextHubSnapshot,
   type ContextHubSnapshotRef,
   type ContextSourceKind,
+  type DependencyStamp,
   type StableBundle,
 } from "./types"
 
 interface HubRegistry {
   refresh(): Promise<SourceRefreshResult>
-  getDependencies(kinds?: ContextSourceKind[]): Record<string, number>
+  getDependencyStamp(kinds?: ContextSourceKind[]): Promise<DependencyStamp>
+  getDependencyPreview(kinds?: ContextSourceKind[], limit?: number): string[]
   markDirty(path: string): void
   dispose(): void
 }
 
 interface HubStorage {
+  initialize?(): Promise<void>
+  dispose?(): void
   readArtifact<T>(key: string): Promise<CachedArtifact<T> | null>
   writeArtifact<T>(key: string, artifact: CachedArtifact<T>): Promise<void>
   readStableBundle(surface: ContextHubRequest["surface"]): Promise<StableBundle | null>
@@ -100,6 +106,7 @@ function withRelativeDependencyPaths(
 ): ContextCacheItemTrace[] {
   return items.map((item) => ({
     ...item,
+    dependencyStamp: { ...item.dependencyStamp, kinds: [...item.dependencyStamp.kinds] },
     dependencyPaths: item.dependencyPaths.map((path) => toProjectRelativePath(projectPath, path)),
   }))
 }
@@ -113,6 +120,7 @@ export class ContextHubController implements ContextHub {
   private readonly unsubscribe: () => void
   private readonly fileCache = new Map<string, string>()
   private readonly pending = new Map<string, Promise<ContextHubResult | null>>()
+  private disposed = false
 
   constructor(projectPath: string, dependencies: ContextHubControllerDependencies = {}) {
     this.projectPath = normalizePath(projectPath)
@@ -128,6 +136,7 @@ export class ContextHubController implements ContextHub {
   }
 
   prepare(request: ContextHubRequest): Promise<ContextHubResult | null> {
+    if (this.disposed) return Promise.resolve(null)
     if (request.intent === "review" || request.intent === "lint") return Promise.resolve(null)
     const key = prepareKey(request)
     const pending = this.pending.get(key)
@@ -137,7 +146,12 @@ export class ContextHubController implements ContextHub {
     return operation
   }
 
+  initialize(): Promise<void> {
+    return this.storage.initialize?.() ?? Promise.resolve()
+  }
+
   async readFile(path: string): Promise<string> {
+    if (this.disposed) throw new Error("Context Hub 已释放")
     const normalized = normalizeContextPath(path)
     const cached = this.fileCache.get(normalized)
     if (cached !== undefined) return cached
@@ -156,6 +170,7 @@ export class ContextHubController implements ContextHub {
       stats: { ...result.stats },
       items: result.cacheItems.map((item) => ({
         ...item,
+        dependencyStamp: { ...item.dependencyStamp, kinds: [...item.dependencyStamp.kinds] },
         dependencyPaths: [...item.dependencyPaths],
       })),
       stableCore: result.stableCore,
@@ -185,14 +200,18 @@ export class ContextHubController implements ContextHub {
   }
 
   markDirty(path: string): void {
+    if (this.disposed) return
     const normalized = normalizeContextPath(path)
     this.fileCache.delete(normalized)
     this.registry.markDirty(normalized)
   }
 
   dispose(): void {
+    if (this.disposed) return
+    this.disposed = true
     this.unsubscribe()
     this.registry.dispose()
+    this.storage.dispose?.()
     this.fileCache.clear()
     this.pending.clear()
   }
@@ -208,8 +227,9 @@ export class ContextHubController implements ContextHub {
   private async prepareCached(request: ContextHubRequest): Promise<ContextHubResult> {
     const refresh = await this.registry.refresh()
     for (const path of refresh.changedPaths) this.fileCache.delete(normalizeContextPath(path))
-    const dependencies = this.registry.getDependencies()
-    const stableDependencies = this.registry.getDependencies(STABLE_SOURCE_KINDS)
+    const dependencyStamp = await this.registry.getDependencyStamp()
+    const stableDependencyStamp = await this.registry.getDependencyStamp(STABLE_SOURCE_KINDS)
+    const stableDependencyPaths = this.registry.getDependencyPreview(STABLE_SOURCE_KINDS, 20)
     const warnings: string[] = []
     const cacheAdapter = new DataSourceCacheAdapter({
       registry: this.registry,
@@ -226,14 +246,14 @@ export class ContextHubController implements ContextHub {
       },
     )
     const summaryFresh = !request.forceRefresh
-      && isSessionSummaryFresh(request.existingSummary, dependencies)
+      && isSessionSummaryFresh(request.existingSummary, dependencyStamp.fingerprint)
     if (request.existingSummary && !summaryFresh) {
       warnings.push("项目资料已更新,本轮未使用旧会话摘要。")
     }
     const composed = composeContext({
       contextPack,
       sessionSummary: summaryFresh ? request.existingSummary?.text : undefined,
-      dependencies,
+      dependencyStamp,
       referenceContext: request.references,
       confidence: confidenceFor(request, contextPack),
       tokenBudget: request.tokenBudget,
@@ -248,6 +268,7 @@ export class ContextHubController implements ContextHub {
       const existing = await this.storage.readStableBundle(request.surface)
       if (
         existing
+        && existing.dependencyStamp.fingerprint === stableDependencyStamp.fingerprint
         && existing.text === composed.stableCore
       ) {
         stableHits = 1
@@ -255,14 +276,16 @@ export class ContextHubController implements ContextHub {
           key: `stable-core:${request.surface}`,
           sourceName: "stableCore",
           status: "hit",
-          dependencyPaths: Object.keys(stableDependencies),
+          dependencyStamp: stableDependencyStamp,
+          dependencyPaths: stableDependencyPaths,
+          dependencyPathsTruncated: stableDependencyStamp.sourceCount > stableDependencyPaths.length,
         })
       } else {
         await this.storage.writeStableBundle(request.surface, {
           schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
           surface: request.surface,
           text: composed.stableCore,
-          dependencies: stableDependencies,
+          dependencyStamp: stableDependencyStamp,
           updatedAt: Date.now(),
         })
         stableRefreshes = 1
@@ -270,7 +293,9 @@ export class ContextHubController implements ContextHub {
           key: `stable-core:${request.surface}`,
           sourceName: "stableCore",
           status: "refreshed",
-          dependencyPaths: Object.keys(stableDependencies),
+          dependencyStamp: stableDependencyStamp,
+          dependencyPaths: stableDependencyPaths,
+          dependencyPathsTruncated: stableDependencyStamp.sourceCount > stableDependencyPaths.length,
         })
       }
     } catch {
@@ -279,7 +304,9 @@ export class ContextHubController implements ContextHub {
         key: `stable-core:${request.surface}`,
         sourceName: "stableCore",
         status: "failed",
-        dependencyPaths: Object.keys(stableDependencies),
+        dependencyStamp: stableDependencyStamp,
+        dependencyPaths: stableDependencyPaths,
+        dependencyPathsTruncated: stableDependencyStamp.sourceCount > stableDependencyPaths.length,
       })
       warnings.push("稳定上下文缓存写入失败,本轮已继续使用内存中的最新内容。")
     }
@@ -311,3 +338,24 @@ export function getContextHub(projectPath: string): ContextHubController {
   projectHubs.set(normalized, hub)
   return hub
 }
+
+export function disposeAllContextHubs(): void {
+  for (const hub of projectHubs.values()) hub.dispose()
+  projectHubs.clear()
+}
+
+export async function initializeProjectContextCache(projectPath: string): Promise<void> {
+  const normalized = normalizePath(projectPath)
+  try {
+    await getContextHub(normalized).initialize()
+  } catch (error) {
+    console.warn("[Context Hub] v2 缓存初始化失败,将在首次使用时重试:", error)
+  }
+
+  const legacyPath = `${normalized}/.qmai/context-cache/v1`
+  void fileExists(legacyPath)
+    .then((exists) => exists ? deleteFile(legacyPath) : undefined)
+    .catch((error) => {
+      console.warn("[Context Hub] 旧版缓存后台清理失败,下次打开项目时将重试:", error)
+    })
+}

+ 67 - 3
src/lib/context-hub/data-source-cache.spec.ts

@@ -1,7 +1,7 @@
 import { describe, expect, it, vi } from "vitest"
 import type { ContextLoadContext, DataSource } from "@/lib/novel/context-data-source"
 import { DataSourceCacheAdapter } from "./data-source-cache"
-import type { CachedArtifact, ContextSourceKind } from "./types"
+import type { CachedArtifact, ContextSourceKind, DependencyStamp } from "./types"
 
 const context: ContextLoadContext = {
   projectPath: "E:/Novel",
@@ -22,13 +22,25 @@ function createHarness() {
     outline: { "E:/Novel/wiki/outlines/main.md": 1 },
     setting: { "E:/Novel/wiki/settings/world.md": 1 },
     entity: {},
+    snapshot: {},
   }
   const registry = {
     refresh: vi.fn(async () => ({ versions: {}, changedPaths: [] })),
-    getDependencies: vi.fn((kinds?: ContextSourceKind[]) => Object.assign(
+    getDependencyStamp: vi.fn(async (kinds?: ContextSourceKind[]): Promise<DependencyStamp> => {
+      const dependencies = Object.assign(
+        {},
+        ...(kinds ?? []).map((kind) => revisions[kind] ?? {}),
+      ) as Record<string, number>
+      return {
+        fingerprint: JSON.stringify(dependencies),
+        sourceCount: Object.keys(dependencies).length,
+        kinds: [...(kinds ?? [])],
+      }
+    }),
+    getDependencyPreview: vi.fn((kinds?: ContextSourceKind[]) => Object.keys(Object.assign(
       {},
       ...(kinds ?? []).map((kind) => revisions[kind] ?? {}),
-    )),
+    ))),
   }
   const storage = {
     readArtifact: vi.fn(async (key: string) => artifacts.get(key) ?? null),
@@ -116,4 +128,56 @@ describe("DataSourceCacheAdapter", () => {
       "failed",
     ])
   })
+
+  it("uses chapter scope for retrieval and project scope for related settings", async () => {
+    const harness = createHarness()
+    const retrieval: DataSource<string> = { name: "retrieval", priority: 1, load: async () => "" }
+    const relatedSettings: DataSource<string> = { name: "relatedSettings", priority: 1, load: async () => "" }
+    const loadRetrieval = vi.fn(async () => "检索索引")
+    const loadSettings = vi.fn(async () => "设定")
+
+    await harness.adapter.load(retrieval, context, loadRetrieval)
+    await harness.adapter.load(retrieval, { ...context, task: "完全不同的提示词" }, loadRetrieval)
+    await harness.adapter.load(relatedSettings, context, loadSettings)
+    await harness.adapter.load(relatedSettings, { ...context, task: "另一个任务", chapterNumber: 99 }, loadSettings)
+
+    expect(loadRetrieval).toHaveBeenCalledOnce()
+    expect(loadSettings).toHaveBeenCalledOnce()
+  })
+
+  it("returns a deeply equal value on a cache hit and a forced rebuild", async () => {
+    const harness = createHarness()
+    const source: DataSource<{ outline: string; chapters: number[] }> = {
+      name: "outline",
+      priority: 1,
+      load: async () => ({ outline: "", chapters: [] }),
+    }
+    const expected = { outline: "第一卷", chapters: [1, 2, 3] }
+    const directLoad = vi.fn(async () => ({ ...expected, chapters: [...expected.chapters] }))
+
+    const refreshed = await harness.adapter.load(source, context, directLoad)
+    const hit = await harness.adapter.load(source, context, directLoad)
+    const forcedAdapter = new DataSourceCacheAdapter({
+      registry: harness.registry,
+      storage: harness.storage,
+      forceRefresh: true,
+    })
+    const forced = await forcedAdapter.load(source, context, directLoad)
+
+    expect(hit).toEqual(refreshed)
+    expect(forced).toEqual(refreshed)
+  })
+
+  it("invalidates search results when a snapshot or community-summary file is added", async () => {
+    const harness = createHarness()
+    const source: DataSource<string> = { name: "searchResults", priority: 1, load: async () => "" }
+    const directLoad = vi.fn(async () => `结果-${directLoad.mock.calls.length}`)
+
+    await harness.adapter.load(source, context, directLoad)
+    await harness.adapter.load(source, context, directLoad)
+    harness.revisions.snapshot!["E:/Novel/.novel/community-summaries/new.json"] = 1
+    await harness.adapter.load(source, context, directLoad)
+
+    expect(directLoad).toHaveBeenCalledTimes(2)
+  })
 })

+ 47 - 31
src/lib/context-hub/data-source-cache.ts

@@ -4,16 +4,20 @@ import type {
   DataSourceLoadAdapter,
 } from "@/lib/novel/context-data-source"
 import { getDataSourceKinds } from "./source-paths"
+import { sha256Text } from "./fingerprint"
 import {
   CONTEXT_CACHE_SCHEMA_VERSION,
   type CachedArtifact,
+  type ContextCacheScope,
   type ContextCacheItemTrace,
   type ContextSourceKind,
+  type DependencyStamp,
 } from "./types"
 
 interface DataSourceCacheRegistry {
   refresh(): Promise<unknown>
-  getDependencies(kinds?: ContextSourceKind[]): Record<string, number>
+  getDependencyStamp(kinds?: ContextSourceKind[]): Promise<DependencyStamp>
+  getDependencyPreview(kinds?: ContextSourceKind[], limit?: number): string[]
 }
 
 interface DataSourceCacheStorage {
@@ -39,6 +43,7 @@ const STATIC_SOURCES = new Set([
   "soulDoc",
   "characterAuras",
   "storyFrameworkBinding",
+  "relatedSettings",
 ])
 
 const CHAPTER_SCOPED_SOURCES = new Set([
@@ -55,6 +60,7 @@ const CHAPTER_SCOPED_SOURCES = new Set([
   "revisionFeedback",
   "cognitionText",
   "sectionBriefing",
+  "retrieval",
 ])
 
 function canonicalize(value: unknown): unknown {
@@ -67,32 +73,23 @@ function canonicalize(value: unknown): unknown {
   )
 }
 
-function hashText(value: string): string {
-  let hash = 0x811c9dc5
-  for (let index = 0; index < value.length; index += 1) {
-    hash ^= value.charCodeAt(index)
-    hash = Math.imul(hash, 0x01000193)
-  }
-  return (hash >>> 0).toString(16).padStart(8, "0")
-}
-
-function sourceRequestKey(sourceName: string, context: ContextLoadContext): string {
+async function sourceRequestKey(sourceName: string, context: ContextLoadContext): Promise<string> {
   const scope = STATIC_SOURCES.has(sourceName)
     ? {}
     : CHAPTER_SCOPED_SOURCES.has(sourceName)
       ? { chapterNumber: context.chapterNumber ?? null, config: context.config }
       : { task: context.task, chapterNumber: context.chapterNumber ?? null, config: context.config }
-  return `data-source:${sourceName}:${hashText(JSON.stringify(canonicalize(scope)))}`
+  return `data-source:${sourceName}:${await sha256Text(JSON.stringify(canonicalize(scope)))}`
+}
+
+function dependencyStampsMatch(cached: DependencyStamp, current: DependencyStamp): boolean {
+  return cached.fingerprint === current.fingerprint
 }
 
-function dependenciesMatch(
-  cached: Record<string, number>,
-  current: Record<string, number>,
-): boolean {
-  const cachedEntries = Object.entries(cached)
-  const currentEntries = Object.entries(current)
-  return cachedEntries.length === currentEntries.length
-    && cachedEntries.every(([path, revision]) => current[path] === revision)
+function cacheScopeFor(sourceName: string): ContextCacheScope {
+  if (STATIC_SOURCES.has(sourceName)) return "static"
+  if (CHAPTER_SCOPED_SOURCES.has(sourceName)) return "chapter"
+  return "task"
 }
 
 function hasCacheableValue(value: unknown): boolean {
@@ -115,12 +112,20 @@ export class DataSourceCacheAdapter implements DataSourceLoadAdapter {
     directLoad: () => Promise<T>,
   ): Promise<T> {
     await this.options.registry.refresh()
-    const dependencies = this.options.registry.getDependencies(getDataSourceKinds(source.name))
-    const key = sourceRequestKey(source.name, context)
+    const kinds = getDataSourceKinds(source.name)
+    const dependencyStamp = await this.options.registry.getDependencyStamp(kinds)
+    const dependencyPaths = this.options.registry.getDependencyPreview(kinds, 20)
+    const key = await sourceRequestKey(source.name, context)
     const pending = this.pending.get(key)
     if (pending) return pending as Promise<T>
 
-    const operation = this.loadInternal(key, source.name, dependencies, directLoad)
+    const operation = this.loadInternal(
+      key,
+      source.name,
+      dependencyStamp,
+      dependencyPaths,
+      directLoad,
+    )
       .finally(() => this.pending.delete(key))
     this.pending.set(key, operation)
     return operation
@@ -133,6 +138,7 @@ export class DataSourceCacheAdapter implements DataSourceLoadAdapter {
   getTraceItems(): ContextCacheItemTrace[] {
     return this.traceItems.map((item) => ({
       ...item,
+      dependencyStamp: { ...item.dependencyStamp, kinds: [...item.dependencyStamp.kinds] },
       dependencyPaths: [...item.dependencyPaths],
     }))
   }
@@ -140,40 +146,50 @@ export class DataSourceCacheAdapter implements DataSourceLoadAdapter {
   private async loadInternal<T>(
     key: string,
     sourceName: string,
-    dependencies: Record<string, number>,
+    dependencyStamp: DependencyStamp,
+    dependencyPaths: string[],
     directLoad: () => Promise<T>,
   ): Promise<T> {
-    const dependencyPaths = Object.keys(dependencies)
+    const trace = (status: ContextCacheItemTrace["status"]): ContextCacheItemTrace => ({
+      key,
+      sourceName,
+      status,
+      dependencyStamp,
+      dependencyPaths,
+      dependencyPathsTruncated: dependencyStamp.sourceCount > dependencyPaths.length,
+    })
     if (!this.options.forceRefresh) {
       try {
         const cached = await this.options.storage.readArtifact<T>(key)
-        if (cached && dependenciesMatch(cached.dependencies, dependencies)) {
+        if (cached && dependencyStampsMatch(cached.dependencyStamp, dependencyStamp)) {
           this.stats.hits += 1
-          this.traceItems.push({ key, sourceName, status: "hit", dependencyPaths })
+          this.traceItems.push(trace("hit"))
           return cached.value
         }
       } catch {
         this.stats.failures += 1
-        this.traceItems.push({ key, sourceName, status: "failed", dependencyPaths })
+        this.traceItems.push(trace("failed"))
       }
     }
 
     const value = await directLoad()
     this.stats.refreshed += 1
-    this.traceItems.push({ key, sourceName, status: "refreshed", dependencyPaths })
+    this.traceItems.push(trace("refreshed"))
     if (!hasCacheableValue(value)) return value
 
     try {
       await this.options.storage.writeArtifact(key, {
         schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
         key,
+        sourceName,
+        scope: cacheScopeFor(sourceName),
         value,
-        dependencies,
+        dependencyStamp,
         createdAt: Date.now(),
       })
     } catch {
       this.stats.failures += 1
-      this.traceItems.push({ key, sourceName, status: "failed", dependencyPaths })
+      this.traceItems.push(trace("failed"))
     }
     return value
   }

+ 5 - 0
src/lib/context-hub/fingerprint.ts

@@ -0,0 +1,5 @@
+export async function sha256Text(value: string): Promise<string> {
+  const bytes = new TextEncoder().encode(value)
+  const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes)
+  return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")
+}

+ 9 - 1
src/lib/context-hub/index.ts

@@ -1,6 +1,12 @@
-export { ContextHubController, getContextHub } from "./context-hub"
+export {
+  ContextHubController,
+  disposeAllContextHubs,
+  getContextHub,
+  initializeProjectContextCache,
+} from "./context-hub"
 export {
   buildSessionContextSummary,
+  isLegacySessionContextSummary,
   isSessionSummaryFresh,
   normalizeSessionContextSummary,
   selectContextHistoryMessages,
@@ -14,7 +20,9 @@ export type {
   ContextHubSnapshot,
   ContextHubSnapshotRef,
   ContextHubStats,
+  ContextCacheScope,
   ContextIntent,
   ContextSurface,
+  DependencyStamp,
   SessionContextSummary,
 } from "./types"

+ 52 - 0
src/lib/context-hub/migration.spec.ts

@@ -0,0 +1,52 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+const fsMocks = vi.hoisted(() => ({
+  createDirectory: vi.fn(async () => {}),
+  deleteFile: vi.fn(async () => {}),
+  fileExists: vi.fn(async (path: string) => path.endsWith("/context-cache/v1")),
+  getFileSize: vi.fn(async () => 0),
+  getFileMd5: vi.fn(async () => "hash"),
+  listDirectory: vi.fn(async () => []),
+  readFile: vi.fn(async () => { throw new Error("文件不存在") }),
+  subscribeProjectFileMutations: vi.fn(() => () => {}),
+  writeFileAtomic: vi.fn(async () => {}),
+}))
+
+vi.mock("@/commands/fs", () => fsMocks)
+
+import {
+  disposeAllContextHubs,
+  initializeProjectContextCache,
+} from "./context-hub"
+
+describe("context cache v1 migration", () => {
+  beforeEach(() => {
+    disposeAllContextHubs()
+    fsMocks.createDirectory.mockClear()
+    fsMocks.deleteFile.mockReset().mockResolvedValue(undefined)
+    fsMocks.fileExists.mockReset().mockImplementation(async (path: string) => path.endsWith("/context-cache/v1"))
+    fsMocks.listDirectory.mockReset().mockResolvedValue([])
+    fsMocks.readFile.mockReset().mockRejectedValue(new Error("文件不存在"))
+  })
+
+  it("initializes v2 and deletes v1 in the background without reading its manifest", async () => {
+    await initializeProjectContextCache("E:/Novel")
+
+    await vi.waitFor(() => {
+      expect(fsMocks.deleteFile).toHaveBeenCalledWith("E:/Novel/.qmai/context-cache/v1")
+    })
+    expect(fsMocks.createDirectory).toHaveBeenCalledWith("E:/Novel/.qmai/context-cache/v2")
+    expect(fsMocks.readFile).not.toHaveBeenCalledWith("E:/Novel/.qmai/context-cache/v1/manifest.json")
+  })
+
+  it("does not block opening when legacy deletion fails and retries on the next open", async () => {
+    fsMocks.deleteFile.mockRejectedValueOnce(new Error("文件占用"))
+
+    await expect(initializeProjectContextCache("E:/Novel")).resolves.toBeUndefined()
+    await vi.waitFor(() => expect(fsMocks.deleteFile).toHaveBeenCalledTimes(1))
+
+    fsMocks.deleteFile.mockResolvedValue(undefined)
+    await expect(initializeProjectContextCache("E:/Novel")).resolves.toBeUndefined()
+    await vi.waitFor(() => expect(fsMocks.deleteFile).toHaveBeenCalledTimes(2))
+  })
+})

+ 1 - 1
src/lib/context-hub/session-store-integration.spec.ts

@@ -4,7 +4,7 @@ import { useOutlineChatStore } from "@/stores/outline-chat-store"
 
 const summary = {
   text: "已确认主角不能提前知道真相。",
-  dependencies: { "E:/Novel/wiki/outlines/main.md": 2 },
+  dependencyFingerprint: "outline-v2",
   updatedAt: 100,
 }
 

+ 24 - 10
src/lib/context-hub/session-summary.spec.ts

@@ -1,7 +1,9 @@
 import { describe, expect, it } from "vitest"
 import {
   buildSessionContextSummary,
+  isLegacySessionContextSummary,
   isSessionSummaryFresh,
+  normalizeSessionContextSummary,
   selectContextHistoryMessages,
 } from "./session-summary"
 
@@ -12,7 +14,7 @@ describe("session context summary", () => {
         { role: "user", content: "主角不能提前知道真相。请继续第二章。" },
         { role: "assistant", content: "第二章将保留悬念,并让线索出现在旧车站。" },
       ],
-      dependencies: { "E:/Novel/wiki/outlines/main.md": 2 },
+      dependencyFingerprint: "outline-v2",
     }
 
     const first = buildSessionContextSummary(input)
@@ -21,28 +23,40 @@ describe("session context summary", () => {
     expect(first.text).toContain("用户:主角不能提前知道真相")
     expect(first.text).toContain("助手:第二章将保留悬念")
     expect(first.text).toBe(second.text)
-    expect(first.dependencies).toEqual(input.dependencies)
+    expect(first.dependencyFingerprint).toBe(input.dependencyFingerprint)
   })
 
   it("bounds long summaries deterministically", () => {
     const summary = buildSessionContextSummary({
       messages: [{ role: "user", content: "约束。".repeat(100) }],
-      dependencies: {},
+      dependencyFingerprint: "empty",
       maxChars: 80,
     })
 
     expect(summary.text.length).toBeLessThanOrEqual(80)
   })
 
-  it("invalidates only when a recorded dependency revision changes", () => {
+  it("invalidates whenever the project dependency fingerprint changes", () => {
     const summary = buildSessionContextSummary({
       messages: [],
-      dependencies: { outline: 2 },
+      dependencyFingerprint: "outline-v2",
     })
 
-    expect(isSessionSummaryFresh(summary, { outline: 2, unrelated: 9 })).toBe(true)
-    expect(isSessionSummaryFresh(summary, { outline: 3 })).toBe(false)
-    expect(isSessionSummaryFresh(undefined, { outline: 2 })).toBe(false)
+    expect(isSessionSummaryFresh(summary, "outline-v2")).toBe(true)
+    expect(isSessionSummaryFresh(summary, "outline-v3")).toBe(false)
+    expect(isSessionSummaryFresh(undefined, "outline-v2")).toBe(false)
+  })
+
+  it("preserves legacy summary text while dropping the full dependency table", () => {
+    const legacy = {
+      text: "旧摘要",
+      dependencies: { "E:/Novel/wiki/entities/one.md": 7 },
+      updatedAt: 10,
+    }
+
+    expect(isLegacySessionContextSummary(legacy)).toBe(true)
+    expect(normalizeSessionContextSummary(legacy)).toEqual({ text: "旧摘要", updatedAt: 10 })
+    expect(isSessionSummaryFresh(normalizeSessionContextSummary(legacy), "project-v2")).toBe(false)
   })
 
   it("keeps only the latest two messages when a summary is already in system context", () => {
@@ -68,7 +82,7 @@ describe("session context summary", () => {
       { role: "assistant", content: "最近进展:已经完成第十章。" },
     ]
 
-    const summary = buildSessionContextSummary({ messages, dependencies: {}, maxChars: 1000 })
+    const summary = buildSessionContextSummary({ messages, dependencyFingerprint: "test", maxChars: 1000 })
 
     expect(summary.text).toContain("初始任务")
     expect(summary.text).toContain("禁止让主角提前知道真相")
@@ -82,7 +96,7 @@ describe("session context summary", () => {
         { role: "assistant", content: "中间分析。".repeat(80) },
         { role: "assistant", content: "最新进展:已经完成关键冲突设计。" },
       ],
-      dependencies: {},
+      dependencyFingerprint: "test",
       maxChars: 120,
     })
 

+ 19 - 15
src/lib/context-hub/session-summary.ts

@@ -7,7 +7,7 @@ export interface SessionSummaryMessage {
 
 export interface BuildSessionContextSummaryInput {
   messages: SessionSummaryMessage[]
-  dependencies: Record<string, number>
+  dependencyFingerprint: string
   maxChars?: number
 }
 
@@ -87,38 +87,42 @@ export function buildSessionContextSummary(
 
   return {
     text,
-    dependencies: { ...input.dependencies },
+    dependencyFingerprint: input.dependencyFingerprint,
     updatedAt: Date.now(),
   }
 }
 
 export function isSessionSummaryFresh(
   summary: SessionContextSummary | undefined,
-  currentDependencies: Record<string, number>,
+  currentDependencyFingerprint: string,
 ): boolean {
-  if (!summary) return false
-  return Object.entries(summary.dependencies).every(
-    ([path, revision]) => currentDependencies[path] === revision,
+  return Boolean(
+    summary?.dependencyFingerprint
+    && summary.dependencyFingerprint === currentDependencyFingerprint,
   )
 }
 
+export function isLegacySessionContextSummary(value: unknown): boolean {
+  if (typeof value === "string") return true
+  if (!value || typeof value !== "object") return false
+  const candidate = value as { text?: unknown; dependencies?: unknown; dependencyFingerprint?: unknown }
+  return typeof candidate.text === "string"
+    && typeof candidate.dependencyFingerprint !== "string"
+    && candidate.dependencies !== undefined
+}
+
 export function normalizeSessionContextSummary(value: unknown): SessionContextSummary | undefined {
   if (typeof value === "string") {
-    return { text: value, dependencies: {}, updatedAt: 0 }
+    return { text: value, updatedAt: 0 }
   }
   if (!value || typeof value !== "object") return undefined
   const candidate = value as Partial<SessionContextSummary>
   if (typeof candidate.text !== "string") return undefined
-  const dependencies = candidate.dependencies && typeof candidate.dependencies === "object"
-    ? Object.fromEntries(
-        Object.entries(candidate.dependencies).filter((entry): entry is [string, number] => (
-          typeof entry[1] === "number" && Number.isFinite(entry[1])
-        )),
-      )
-    : {}
   return {
     text: candidate.text,
-    dependencies,
+    ...(typeof candidate.dependencyFingerprint === "string" && candidate.dependencyFingerprint
+      ? { dependencyFingerprint: candidate.dependencyFingerprint }
+      : {}),
     updatedAt: typeof candidate.updatedAt === "number" && Number.isFinite(candidate.updatedAt)
       ? candidate.updatedAt
       : 0,

+ 4 - 0
src/lib/context-hub/source-paths.spec.ts

@@ -22,6 +22,7 @@ describe("context source paths", () => {
     ["E:/Novel/.qmai/book-analysis-context.json", "book-analysis"],
     ["E:/Novel/.qmai/character-aura.json", "entity"],
     ["E:/Novel/.qmai/simulations/latest.json", "deduction"],
+    ["E:/Novel/retrieval/index.md", "retrieval"],
     ["E:/Novel/.qmai/context-cache/v1/manifest.json", "ignored"],
   ] as const)("classifies %s as %s", (path, expected) => {
     expect(classifyContextSourcePath(projectPath, path)).toBe(expected)
@@ -43,5 +44,8 @@ describe("context source paths", () => {
     expect(getDataSourceKinds("recentChapterContents")).toEqual(["chapter"])
     expect(getDataSourceKinds("storyFrameworkBinding")).toEqual(["outline", "setting", "deduction"])
     expect(getDataSourceKinds("bookAnalysisReferences")).toEqual(["book-analysis"])
+    expect(getDataSourceKinds("retrieval")).toEqual(["retrieval"])
+    expect(getDataSourceKinds("searchResults")).toContain("snapshot")
+    expect(getDataSourceKinds("graphSearchResults")).toContain("snapshot")
   })
 })

+ 4 - 3
src/lib/context-hub/source-paths.ts

@@ -15,15 +15,15 @@ const DATA_SOURCE_KINDS: Record<string, ContextSourceKind[]> = {
   canonRules: ["setting"],
   writingStyle: ["setting"],
   bookAnalysisReferences: ["book-analysis"],
-  searchResults: ["chapter", "outline", "memory", "setting", "entity"],
-  graphSearchResults: ["chapter", "outline", "memory", "setting", "entity"],
+  searchResults: ["chapter", "outline", "memory", "setting", "entity", "snapshot"],
+  graphSearchResults: ["chapter", "outline", "memory", "setting", "entity", "snapshot"],
   revisionFeedback: ["chapter", "snapshot"],
   cognitionText: ["entity"],
   soulDoc: ["soul"],
   characterAuras: ["entity"],
   sectionBriefing: ["outline", "snapshot"],
   storyFrameworkBinding: ["outline", "setting", "deduction"],
-  retrieval: ["chapter", "outline", "memory", "setting", "entity", "snapshot"],
+  retrieval: ["retrieval"],
 }
 
 export function normalizeContextPath(path: string): string {
@@ -51,6 +51,7 @@ export function classifyContextSourcePath(projectPath: string, path: string): Co
   if (relative === ".novel/timeline.json") return "memory"
   if (relative.startsWith(".novel/snapshots/") || relative.startsWith(".novel/community-summaries/")) return "snapshot"
   if (relative.startsWith(".qmai/simulations/")) return "deduction"
+  if (relative.startsWith("retrieval/")) return "retrieval"
   return "other"
 }
 

+ 52 - 0
src/lib/context-hub/source-registry.spec.ts

@@ -61,6 +61,7 @@ describe("ContextSourceRegistry", () => {
     expect(result).toEqual([writingStyle, simulation])
     expect(calls).toContainEqual(["E:/Novel/.qmai", { includeHidden: true, maxDepth: 1 }])
     expect(calls).toContainEqual(["E:/Novel/.qmai/simulations", { includeHidden: true, maxDepth: 30 }])
+    expect(calls).toContainEqual(["E:/Novel/retrieval", { maxDepth: 30 }])
   })
 
   it("propagates a scan error when an existing directory is unreadable", async () => {
@@ -136,4 +137,55 @@ describe("ContextSourceRegistry", () => {
 
     expect(harness.scanFiles).toHaveBeenCalledTimes(1)
   })
+
+  it("changes a collection fingerprint for additions, deletions, and delete-then-recreate", async () => {
+    const firstPath = "E:/Novel/wiki/entities/one.md"
+    const secondPath = "E:/Novel/wiki/entities/two.md"
+    const harness = createHarness([file(firstPath, 1)])
+    harness.hashes.set(firstPath, "one-v1")
+    await harness.registry.refresh()
+    const initial = await harness.registry.getDependencyStamp(["entity"])
+
+    harness.hashes.set(secondPath, "two-v1")
+    harness.setFiles([file(firstPath, 1), file(secondPath, 1)])
+    await harness.registry.refresh()
+    const added = await harness.registry.getDependencyStamp(["entity"])
+    expect(added.sourceCount).toBe(2)
+    expect(added.fingerprint).not.toBe(initial.fingerprint)
+
+    harness.setFiles([])
+    await harness.registry.refresh()
+    const deleted = await harness.registry.getDependencyStamp(["entity"])
+    expect(deleted.sourceCount).toBe(0)
+    expect(deleted.fingerprint).not.toBe(initial.fingerprint)
+
+    harness.hashes.set(firstPath, "one-v2")
+    harness.setFiles([file(firstPath, 2)])
+    await harness.registry.refresh()
+    const recreated = await harness.registry.getDependencyStamp(["entity"])
+    expect(recreated.sourceCount).toBe(1)
+    expect(recreated.fingerprint).not.toBe(initial.fingerprint)
+  })
+
+  it("isolates retrieval fingerprints from unrelated entity changes", async () => {
+    const retrieval = "E:/Novel/retrieval/index.md"
+    const entity = "E:/Novel/wiki/entities/one.md"
+    const harness = createHarness([file(retrieval, 1), file(entity, 1)])
+    harness.hashes.set(retrieval, "retrieval-v1")
+    harness.hashes.set(entity, "entity-v1")
+    await harness.registry.refresh()
+    const initial = await harness.registry.getDependencyStamp(["retrieval"])
+
+    harness.hashes.set(entity, "entity-v2")
+    harness.setFiles([file(retrieval, 1), file(entity, 2)])
+    await harness.registry.refresh()
+    const unchanged = await harness.registry.getDependencyStamp(["retrieval"])
+    expect(unchanged.fingerprint).toBe(initial.fingerprint)
+
+    harness.hashes.set(retrieval, "retrieval-v2")
+    harness.setFiles([file(retrieval, 2), file(entity, 2)])
+    await harness.registry.refresh()
+    const changed = await harness.registry.getDependencyStamp(["retrieval"])
+    expect(changed.fingerprint).not.toBe(initial.fingerprint)
+  })
 })

+ 76 - 11
src/lib/context-hub/source-registry.ts

@@ -9,8 +9,9 @@ import {
 import { normalizePath } from "@/lib/path-utils"
 import type { FileNode } from "@/types/wiki"
 import { classifyContextSourcePath, normalizeContextPath, sortContextSourcePaths } from "./source-paths"
+import { sha256Text } from "./fingerprint"
 import { ContextHubStorage } from "./storage"
-import type { ContextCacheManifest, ContextSourceKind, SourceVersion } from "./types"
+import type { ContextCacheManifest, ContextSourceKind, DependencyStamp, SourceVersion } from "./types"
 
 interface SourceRegistryStorage {
   loadManifest(): Promise<ContextCacheManifest>
@@ -70,6 +71,7 @@ export async function scanProjectContextFiles(
     safeList(`${projectPath}/.novel`, { includeHidden: true, maxDepth: 30 }, io),
     safeList(`${projectPath}/.qmai`, { includeHidden: true, maxDepth: 1 }, io),
     safeList(`${projectPath}/.qmai/simulations`, { includeHidden: true, maxDepth: 30 }, io),
+    safeList(`${projectPath}/retrieval`, { maxDepth: 30 }, io),
   ])
   return flattenFiles(roots.flat())
 }
@@ -78,8 +80,15 @@ function metadataMatches(left: SourceVersion, right: FileNode): boolean {
   return left.mtimeMs === right.mtimeMs && left.size === right.size
 }
 
-function manifestsEqual(left: ContextCacheManifest, right: ContextCacheManifest): boolean {
-  return JSON.stringify(left) === JSON.stringify(right)
+function toFingerprintPath(projectPath: string, path: string): string {
+  const normalizedProject = normalizeContextPath(projectPath).replace(/\/$/, "")
+  const normalizedPath = normalizeContextPath(path)
+  const prefix = `${normalizedProject}/`
+  const windowsPath = /^[A-Za-z]:\//.test(prefix) && /^[A-Za-z]:\//.test(normalizedPath)
+  const matchesProject = windowsPath
+    ? normalizedPath.toLowerCase().startsWith(prefix.toLowerCase())
+    : normalizedPath.startsWith(prefix)
+  return matchesProject ? normalizedPath.slice(prefix.length) : normalizedPath
 }
 
 export class ContextSourceRegistry {
@@ -91,6 +100,8 @@ export class ContextSourceRegistry {
   private readonly dirtyPaths = new Set<string>()
   private pendingRefresh: Promise<SourceRefreshResult> | null = null
   private versions: Record<string, SourceVersion> = {}
+  private readonly kindStampCache = new Map<ContextSourceKind, Promise<DependencyStamp>>()
+  private readonly combinedStampCache = new Map<string, Promise<DependencyStamp>>()
 
   constructor(projectPath: string, options: ContextSourceRegistryOptions = {}) {
     this.projectPath = normalizePath(projectPath)
@@ -115,18 +126,29 @@ export class ContextSourceRegistry {
     if (kind !== "ignored" && kind !== "other") this.dirtyPaths.add(normalized)
   }
 
-  getDependencies(kinds?: ContextSourceKind[]): Record<string, number> {
-    const allowed = kinds ? new Set(kinds) : null
-    return Object.fromEntries(
-      sortContextSourcePaths(Object.keys(this.versions))
-        .filter((path) => !allowed || allowed.has(this.versions[path].kind))
-        .map((path) => [path, this.versions[path].revision]),
-    )
+  getDependencyStamp(kinds?: ContextSourceKind[]): Promise<DependencyStamp> {
+    const selectedKinds = this.normalizeKinds(kinds)
+    const key = selectedKinds.join("|")
+    const cached = this.combinedStampCache.get(key)
+    if (cached) return cached
+    const pending = this.buildCombinedStamp(selectedKinds)
+    this.combinedStampCache.set(key, pending)
+    return pending
+  }
+
+  getDependencyPreview(kinds?: ContextSourceKind[], limit = 20): string[] {
+    const allowed = new Set(this.normalizeKinds(kinds))
+    return sortContextSourcePaths(Object.keys(this.versions))
+      .filter((path) => allowed.has(this.versions[path].kind))
+      .slice(0, Math.max(0, limit))
   }
 
   dispose(): void {
     this.unsubscribe()
     this.dirtyPaths.clear()
+    this.versions = {}
+    this.kindStampCache.clear()
+    this.combinedStampCache.clear()
   }
 
   private async refreshInternal(): Promise<SourceRefreshResult> {
@@ -142,6 +164,7 @@ export class ContextSourceRegistry {
     const byPath = new Map(relevant.map((node) => [node.path, node]))
     const next: Record<string, SourceVersion> = {}
     const changedPaths: string[] = []
+    let manifestChanged = Object.keys(previous).length !== byPath.size
 
     for (const path of sortContextSourcePaths([...byPath.keys()])) {
       const node = byPath.get(path)!
@@ -162,6 +185,7 @@ export class ContextSourceRegistry {
         hash,
         revision: oldVersion ? oldVersion.revision + (contentChanged ? 1 : 0) : 1,
       }
+      manifestChanged = true
       if (contentChanged) changedPaths.push(path)
     }
 
@@ -170,8 +194,10 @@ export class ContextSourceRegistry {
     }
 
     const nextManifest: ContextCacheManifest = { ...manifest, sources: next }
-    if (!manifestsEqual(manifest, nextManifest)) await this.storage.saveManifest(nextManifest)
+    if (manifestChanged) await this.storage.saveManifest(nextManifest)
     this.versions = next
+    this.kindStampCache.clear()
+    this.combinedStampCache.clear()
     this.dirtyPaths.clear()
 
     return {
@@ -179,4 +205,43 @@ export class ContextSourceRegistry {
       changedPaths: sortContextSourcePaths([...new Set(changedPaths)]),
     }
   }
+
+  private normalizeKinds(kinds?: ContextSourceKind[]): ContextSourceKind[] {
+    const values = kinds ?? Object.values(this.versions).map((version) => version.kind)
+    return [...new Set(values)]
+      .filter((kind) => kind !== "ignored" && kind !== "other")
+      .sort()
+  }
+
+  private getKindStamp(kind: ContextSourceKind): Promise<DependencyStamp> {
+    const cached = this.kindStampCache.get(kind)
+    if (cached) return cached
+    const pending = (async () => {
+      const paths = sortContextSourcePaths(Object.keys(this.versions))
+        .filter((path) => this.versions[path].kind === kind)
+      const canonical = paths.map((path) => {
+        const version = this.versions[path]
+        const relative = toFingerprintPath(this.projectPath, path)
+        return `${relative}\u0000${version.hash ?? `revision:${version.revision}`}`
+      }).join("\n")
+      return {
+        fingerprint: await sha256Text(canonical),
+        sourceCount: paths.length,
+        kinds: [kind],
+      }
+    })()
+    this.kindStampCache.set(kind, pending)
+    return pending
+  }
+
+  private async buildCombinedStamp(kinds: ContextSourceKind[]): Promise<DependencyStamp> {
+    const stamps = await Promise.all(kinds.map((kind) => this.getKindStamp(kind)))
+    return {
+      fingerprint: await sha256Text(
+        stamps.map((stamp) => `${stamp.kinds[0]}:${stamp.sourceCount}:${stamp.fingerprint}`).join("\n"),
+      ),
+      sourceCount: stamps.reduce((sum, stamp) => sum + stamp.sourceCount, 0),
+      kinds: [...kinds],
+    }
+  }
 }

+ 218 - 5
src/lib/context-hub/storage.spec.ts

@@ -3,9 +3,17 @@ import { ContextHubStorage, type ContextHubStorageIo } from "./storage"
 import {
   CONTEXT_CACHE_SCHEMA_VERSION,
   type CachedArtifact,
+  type ContextCacheManifest,
   type ContextHubSnapshot,
+  type DependencyStamp,
 } from "./types"
 
+const dependencyStamp: DependencyStamp = {
+  fingerprint: "outline-v1",
+  sourceCount: 1,
+  kinds: ["outline"],
+}
+
 function createMemoryIo() {
   const files = new Map<string, string>()
   const directories = new Set<string>()
@@ -30,7 +38,15 @@ function createMemoryIo() {
     deleteFile: async (path) => {
       deletedPaths.push(path)
       files.delete(path)
+      for (const filePath of [...files.keys()]) {
+        if (filePath.startsWith(`${path}/`)) files.delete(filePath)
+      }
+      for (const directory of [...directories]) {
+        if (directory === path || directory.startsWith(`${path}/`)) directories.delete(directory)
+      }
     },
+    fileExists: async (path) => files.has(path) || directories.has(path),
+    getFileSize: async (path) => new TextEncoder().encode(files.get(path) ?? "").byteLength,
   }
   return { files, directories, deletedPaths, io, setFailWrite: (value?: (path: string) => boolean) => { failWrite = value } }
 }
@@ -39,8 +55,10 @@ function artifact(value: string, key = "outline:main"): CachedArtifact<string> {
   return {
     schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
     key,
+    sourceName: "outline",
+    scope: "static",
     value,
-    dependencies: { "E:/Novel/wiki/outlines/main.md": 1 },
+    dependencyStamp,
     createdAt: 1,
   }
 }
@@ -68,7 +86,9 @@ function snapshot(id = "assistant:1"): ContextHubSnapshot {
       key: "data-source:outline",
       sourceName: "outline",
       status: "hit",
+      dependencyStamp,
       dependencyPaths: ["wiki/outlines/main.md"],
+      dependencyPathsTruncated: false,
     }],
     stableCore: "稳定核心正文",
     sessionSummary: "会话摘要正文",
@@ -143,7 +163,7 @@ describe("ContextHubStorage", () => {
   it("treats a different schema as an empty cache", async () => {
     const memory = createMemoryIo()
     memory.files.set(
-      "E:/Novel/.qmai/context-cache/v1/manifest.json",
+      "E:/Novel/.qmai/context-cache/v2/manifest.json",
       JSON.stringify({ schemaVersion: 999, sources: { stale: {} }, artifacts: {} }),
     )
 
@@ -166,14 +186,20 @@ describe("ContextHubStorage", () => {
   it("uses one fixed stable bundle file per surface", async () => {
     const memory = createMemoryIo()
     const storage = new ContextHubStorage("E:/Novel", memory.io)
-    const first = { schemaVersion: 1, surface: "ai-chat" as const, text: "一", dependencies: {}, updatedAt: 1 }
+    const first = {
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      surface: "ai-chat" as const,
+      text: "一",
+      dependencyStamp,
+      updatedAt: 1,
+    }
     const second = { ...first, text: "二", updatedAt: 2 }
 
     await storage.writeStableBundle("ai-chat", first)
     await storage.writeStableBundle("ai-chat", second)
 
     expect([...memory.files.keys()].filter((path) => path.includes("stable-bundles"))).toEqual([
-      "E:/Novel/.qmai/context-cache/v1/stable-bundles/ai-chat.json",
+      "E:/Novel/.qmai/context-cache/v2/stable-bundles/ai-chat.json",
     ])
     await expect(storage.readStableBundle("ai-chat")).resolves.toMatchObject({ text: "二" })
   })
@@ -228,7 +254,7 @@ describe("ContextHubStorage", () => {
 
   it("never deletes a path returned from outside the selected snapshot directory", async () => {
     const memory = createMemoryIo()
-    const outsidePath = "E:/Novel/.qmai/context-cache/v1/outside.json"
+    const outsidePath = "E:/Novel/.qmai/context-cache/v2/outside.json"
     memory.files.set(outsidePath, JSON.stringify({ createdAt: 1 }))
     memory.io.listDirectory = async () => [{
       name: "outside.json",
@@ -242,4 +268,191 @@ describe("ContextHubStorage", () => {
     expect(memory.deletedPaths).toEqual([])
     expect(memory.files.has(outsidePath)).toBe(true)
   })
+
+  it("keeps a 10,000-source and 2,000-artifact v2 manifest below 10 MiB", async () => {
+    const memory = createMemoryIo()
+    const manifestPath = "E:/Novel/.qmai/context-cache/v2/manifest.json"
+    const sources = Object.fromEntries(Array.from({ length: 10_000 }, (_, index) => {
+      const path = `E:/Novel/wiki/entities/entity-${String(index).padStart(5, "0")}.md`
+      return [path, {
+        path,
+        kind: "entity" as const,
+        mtimeMs: index,
+        size: 100,
+        hash: `hash-${index}`,
+        revision: 1,
+      }]
+    }))
+    const artifacts = Object.fromEntries(Array.from({ length: 2_000 }, (_, index) => [
+      `data-source:searchResults:${index}`,
+      {
+        path: `E:/Novel/.qmai/context-cache/v2/artifacts/${index}.json`,
+        sourceName: `searchResults-${index % 16}`,
+        scope: "task" as const,
+        dependencyStamp: { fingerprint: `fingerprint-${index}`, sourceCount: 10_000, kinds: ["entity" as const] },
+        createdAt: index,
+        byteSize: 100,
+      },
+    ]))
+    const manifest: ContextCacheManifest = {
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      sources,
+      artifacts,
+    }
+    const serialized = JSON.stringify(manifest)
+    memory.files.set(manifestPath, serialized)
+
+    expect(new TextEncoder().encode(serialized).byteLength).toBeLessThan(10 * 1024 * 1024)
+    const loaded = await new ContextHubStorage("E:/Novel", memory.io).loadManifest()
+    expect(Object.keys(loaded.sources)).toHaveLength(10_000)
+    expect(Object.keys(loaded.artifacts)).toHaveLength(2_000)
+  })
+
+  it("keeps dependency metadata bounded and does not serialize source paths into artifacts", async () => {
+    const serialized = JSON.stringify(artifact("短值"))
+
+    expect(new TextEncoder().encode(serialized).byteLength).toBeLessThan(1024)
+    expect(serialized).not.toContain("wiki/outlines/main.md")
+  })
+
+  it("prunes task-scoped artifacts beyond the per-source limit", async () => {
+    const memory = createMemoryIo()
+    const storage = new ContextHubStorage("E:/Novel", memory.io)
+    for (let index = 0; index < 129; index += 1) {
+      await storage.writeArtifact(
+        `search:${index}`,
+        { ...artifact(`结果${index}`, `search:${index}`), sourceName: "searchResults", scope: "task", createdAt: index },
+      )
+    }
+
+    const manifest = await storage.loadManifest()
+    expect(Object.keys(manifest.artifacts)).toHaveLength(128)
+    expect(manifest.artifacts["search:0"]).toBeUndefined()
+    expect(memory.deletedPaths.some((path) => path.includes("/artifacts/"))).toBe(true)
+  })
+
+  it("enforces the global artifact count limit during initialization", async () => {
+    const memory = createMemoryIo()
+    const manifestPath = "E:/Novel/.qmai/context-cache/v2/manifest.json"
+    const artifacts = Object.fromEntries(Array.from({ length: 2_050 }, (_, index) => [
+      `chapter:${index}`,
+      {
+        path: `E:/Novel/.qmai/context-cache/v2/artifacts/${index}.json`,
+        sourceName: "chapterOutline",
+        scope: "chapter" as const,
+        dependencyStamp,
+        createdAt: index,
+        byteSize: 1,
+      },
+    ]))
+    memory.files.set(manifestPath, JSON.stringify({
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      sources: {},
+      artifacts,
+    }))
+
+    const manifest = await new ContextHubStorage("E:/Novel", memory.io).loadManifest()
+
+    expect(Object.keys(manifest.artifacts)).toHaveLength(2_048)
+    expect(manifest.artifacts["chapter:0"]).toBeUndefined()
+  })
+
+  it("enforces the global artifact byte limit during initialization", async () => {
+    const memory = createMemoryIo()
+    const manifestPath = "E:/Novel/.qmai/context-cache/v2/manifest.json"
+    const artifacts = Object.fromEntries(Array.from({ length: 130 }, (_, index) => [
+      `chapter:${index}`,
+      {
+        path: `E:/Novel/.qmai/context-cache/v2/artifacts/${index}.json`,
+        sourceName: "chapterOutline",
+        scope: "chapter" as const,
+        dependencyStamp,
+        createdAt: index,
+        byteSize: 1024 * 1024,
+      },
+    ]))
+    memory.files.set(manifestPath, JSON.stringify({
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      sources: {},
+      artifacts,
+    }))
+
+    const manifest = await new ContextHubStorage("E:/Novel", memory.io).loadManifest()
+    const totalBytes = Object.values(manifest.artifacts).reduce((sum, entry) => sum + entry.byteSize, 0)
+
+    expect(totalBytes).toBeLessThanOrEqual(128 * 1024 * 1024)
+    expect(Object.keys(manifest.artifacts)).toHaveLength(128)
+  })
+
+  it("removes a newly written orphan when manifest persistence fails", async () => {
+    const memory = createMemoryIo()
+    const storage = new ContextHubStorage("E:/Novel", memory.io)
+    await storage.initialize()
+    memory.setFailWrite((path) => path.endsWith("/manifest.json"))
+
+    await expect(storage.writeArtifact("outline:orphan", artifact("大纲", "outline:orphan")))
+      .rejects.toThrow("写入失败")
+
+    expect([...memory.files.keys()].filter((path) => path.includes("/artifacts/"))).toEqual([])
+  })
+
+  it("keeps the previous artifact when replacing it fails at manifest persistence", async () => {
+    const memory = createMemoryIo()
+    const storage = new ContextHubStorage("E:/Novel", memory.io)
+    await storage.writeArtifact("outline:main", artifact("旧大纲"))
+    memory.setFailWrite((path) => path.endsWith("/manifest.json"))
+
+    await expect(storage.writeArtifact("outline:main", artifact("新大纲"))).rejects.toThrow("写入失败")
+    memory.setFailWrite()
+
+    await expect(new ContextHubStorage("E:/Novel", memory.io).readArtifact<string>("outline:main"))
+      .resolves.toMatchObject({ value: "旧大纲" })
+    expect([...memory.files.keys()].filter((path) => path.includes("/artifacts/"))).toHaveLength(1)
+  })
+
+  it("does not cache an artifact larger than 8 MiB", async () => {
+    const memory = createMemoryIo()
+    const storage = new ContextHubStorage("E:/Novel", memory.io)
+
+    await storage.writeArtifact("outline:huge", artifact("x".repeat(8 * 1024 * 1024), "outline:huge"))
+
+    expect((await storage.loadManifest()).artifacts).toEqual({})
+    expect([...memory.files.keys()].some((path) => path.includes("/artifacts/"))).toBe(false)
+  })
+
+  it("resets an oversized v2 manifest without parsing it", async () => {
+    const memory = createMemoryIo()
+    const manifestPath = "E:/Novel/.qmai/context-cache/v2/manifest.json"
+    memory.files.set(manifestPath, "x".repeat(32 * 1024 * 1024 + 1))
+
+    const manifest = await new ContextHubStorage("E:/Novel", memory.io).loadManifest()
+
+    expect(manifest).toEqual({ schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION, sources: {}, artifacts: {} })
+    expect(memory.deletedPaths).toContain("E:/Novel/.qmai/context-cache/v2")
+  })
+
+  it("resets a structurally invalid v2 manifest", async () => {
+    const memory = createMemoryIo()
+    const manifestPath = "E:/Novel/.qmai/context-cache/v2/manifest.json"
+    memory.files.set(manifestPath, JSON.stringify({
+      schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
+      sources: {},
+      artifacts: [{ path: "E:/outside.json" }],
+    }))
+
+    const manifest = await new ContextHubStorage("E:/Novel", memory.io).loadManifest()
+
+    expect(manifest).toEqual({ schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION, sources: {}, artifacts: {} })
+    expect(memory.deletedPaths).toContain("E:/Novel/.qmai/context-cache/v2")
+  })
+
+  it("cleans an unindexed artifact during initialization", async () => {
+    const memory = createMemoryIo()
+    const orphan = "E:/Novel/.qmai/context-cache/v2/artifacts/orphan.json"
+    memory.files.set(orphan, "{}")
+
+    await new ContextHubStorage("E:/Novel", memory.io).initialize()
+
+    expect(memory.deletedPaths).toContain(orphan)
+  })
 })

+ 268 - 87
src/lib/context-hub/storage.ts

@@ -1,8 +1,18 @@
-import { createDirectory, deleteFile, listDirectory, readFile, writeFileAtomic } from "@/commands/fs"
+import {
+  createDirectory,
+  deleteFile,
+  fileExists,
+  getFileSize,
+  listDirectory,
+  readFile,
+  writeFileAtomic,
+} from "@/commands/fs"
 import { normalizePath } from "@/lib/path-utils"
+import { sha256Text } from "./fingerprint"
 import {
   CONTEXT_CACHE_SCHEMA_VERSION,
   type CachedArtifact,
+  type ContextCacheArtifactEntry,
   type ContextCacheManifest,
   type ContextHubSnapshot,
   type ContextSurface,
@@ -15,6 +25,8 @@ export interface ContextHubStorageIo {
   createDirectory(path: string): Promise<void>
   listDirectory(path: string): Promise<Array<{ name: string; path: string; is_dir: boolean; mtimeMs?: number }>>
   deleteFile(path: string): Promise<void>
+  fileExists(path: string): Promise<boolean>
+  getFileSize(path: string): Promise<number>
 }
 
 const defaultIo: ContextHubStorageIo = {
@@ -23,29 +35,19 @@ const defaultIo: ContextHubStorageIo = {
   createDirectory,
   listDirectory: (path) => listDirectory(path, { includeHidden: true, maxDepth: 1 }),
   deleteFile,
+  fileExists,
+  getFileSize,
 }
 
 const SNAPSHOT_CLEANUP_GRACE_MS = 60_000
+const MAX_MANIFEST_BYTES = 32 * 1024 * 1024
+const MAX_ARTIFACT_BYTES = 8 * 1024 * 1024
+const MAX_TOTAL_ARTIFACT_BYTES = 128 * 1024 * 1024
+const MAX_ARTIFACT_COUNT = 2048
+const MAX_TASK_ARTIFACTS_PER_SOURCE = 128
 
 function emptyManifest(): ContextCacheManifest {
-  return {
-    schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
-    sources: {},
-    artifacts: {},
-  }
-}
-
-function cloneManifest(manifest: ContextCacheManifest): ContextCacheManifest {
-  return JSON.parse(JSON.stringify(manifest)) as ContextCacheManifest
-}
-
-function artifactFileName(key: string): string {
-  let hash = 0x811c9dc5
-  for (let index = 0; index < key.length; index += 1) {
-    hash ^= key.charCodeAt(index)
-    hash = Math.imul(hash, 0x01000193)
-  }
-  return `${(hash >>> 0).toString(16).padStart(8, "0")}.json`
+  return { schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION, sources: {}, artifacts: {} }
 }
 
 function parseObject(value: string): Record<string, unknown> | null {
@@ -59,42 +61,134 @@ function parseObject(value: string): Record<string, unknown> | null {
   }
 }
 
+async function hashedFileName(key: string): Promise<string> {
+  return `${await sha256Text(key)}.json`
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return Boolean(value) && typeof value === "object" && !Array.isArray(value)
+}
+
+function isDependencyStamp(value: unknown): boolean {
+  if (!isRecord(value)) return false
+  return typeof value.fingerprint === "string"
+    && value.fingerprint.length > 0
+    && Number.isInteger(value.sourceCount)
+    && (value.sourceCount as number) >= 0
+    && Array.isArray(value.kinds)
+    && value.kinds.every((kind) => typeof kind === "string")
+}
+
+function cloneManifest(manifest: ContextCacheManifest): ContextCacheManifest {
+  return {
+    schemaVersion: manifest.schemaVersion,
+    sources: { ...manifest.sources },
+    artifacts: { ...manifest.artifacts },
+  }
+}
+
+function artifactSort(left: ContextCacheArtifactEntry, right: ContextCacheArtifactEntry): number {
+  if (left.scope === "static" && right.scope !== "static") return 1
+  if (right.scope === "static" && left.scope !== "static") return -1
+  return left.createdAt - right.createdAt
+}
+
+function pruneArtifactEntries(
+  entries: Record<string, ContextCacheArtifactEntry>,
+): { artifacts: Record<string, ContextCacheArtifactEntry>; removed: ContextCacheArtifactEntry[] } {
+  const artifacts = { ...entries }
+  const removed: ContextCacheArtifactEntry[] = []
+  const removeKey = (key: string) => {
+    const entry = artifacts[key]
+    if (!entry) return
+    removed.push(entry)
+    delete artifacts[key]
+  }
+
+  const taskGroups = new Map<string, Array<[string, ContextCacheArtifactEntry]>>()
+  for (const pair of Object.entries(artifacts)) {
+    const [key, entry] = pair
+    if (entry.scope !== "task") continue
+    const group = taskGroups.get(entry.sourceName) ?? []
+    group.push([key, entry])
+    taskGroups.set(entry.sourceName, group)
+  }
+  for (const group of taskGroups.values()) {
+    group.sort((left, right) => left[1].createdAt - right[1].createdAt)
+    for (const [key] of group.slice(0, Math.max(0, group.length - MAX_TASK_ARTIFACTS_PER_SOURCE))) {
+      removeKey(key)
+    }
+  }
+
+  const remaining = () => Object.entries(artifacts)
+  let totalBytes = remaining().reduce((sum, [, entry]) => sum + entry.byteSize, 0)
+  const candidates = remaining().sort((left, right) => artifactSort(left[1], right[1]))
+  while (
+    candidates.length > 0
+    && (Object.keys(artifacts).length > MAX_ARTIFACT_COUNT || totalBytes > MAX_TOTAL_ARTIFACT_BYTES)
+  ) {
+    const [key, entry] = candidates.shift()!
+    if (!artifacts[key]) continue
+    totalBytes -= entry.byteSize
+    removeKey(key)
+  }
+  return { artifacts, removed }
+}
+
 export class ContextHubStorage {
   private readonly basePath: string
   private readonly manifestPath: string
   private manifest: ContextCacheManifest | null = null
+  private initialization: Promise<void> | null = null
   private manifestWriteQueue: Promise<void> = Promise.resolve()
   private snapshotOperationQueue: Promise<void> = Promise.resolve()
+  private disposed = false
 
   constructor(
     projectPath: string,
     private readonly io: ContextHubStorageIo = defaultIo,
   ) {
-    this.basePath = `${normalizePath(projectPath)}/.qmai/context-cache/v1`
+    this.basePath = `${normalizePath(projectPath)}/.qmai/context-cache/v2`
     this.manifestPath = `${this.basePath}/manifest.json`
   }
 
+  initialize(): Promise<void> {
+    if (this.disposed) return Promise.reject(new Error("Context Hub storage 已释放"))
+    if (!this.initialization) {
+      this.initialization = this.initializeInternal().catch((error) => {
+        this.initialization = null
+        throw error
+      })
+    }
+    return this.initialization
+  }
+
+  dispose(): void {
+    this.disposed = true
+    this.manifest = null
+    this.initialization = null
+  }
+
   async loadManifest(): Promise<ContextCacheManifest> {
-    return cloneManifest(await this.getManifest())
+    await this.initialize()
+    return cloneManifest(this.manifest ?? emptyManifest())
   }
 
   async saveManifest(manifest: ContextCacheManifest): Promise<void> {
+    await this.initialize()
     await this.enqueueManifestWrite(async () => {
-      const current = await this.getManifest()
-      const next = cloneManifest({
-        ...manifest,
+      const current = this.manifest ?? emptyManifest()
+      await this.persistManifest({
         schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
-        artifacts: {
-          ...manifest.artifacts,
-          ...current.artifacts,
-        },
+        sources: { ...manifest.sources },
+        artifacts: { ...current.artifacts },
       })
-      await this.persistManifest(next)
     })
   }
 
   async readArtifact<T>(key: string): Promise<CachedArtifact<T> | null> {
-    const entry = (await this.getManifest()).artifacts[key]
+    await this.initialize()
+    const entry = this.manifest?.artifacts[key]
     if (!entry) return null
     try {
       const raw = parseObject(await this.io.readFile(entry.path))
@@ -103,6 +197,7 @@ export class ContextHubStorage {
         || raw.schemaVersion !== CONTEXT_CACHE_SCHEMA_VERSION
         || raw.key !== key
         || !("value" in raw)
+        || !isDependencyStamp(raw.dependencyStamp)
       ) return null
       return raw as unknown as CachedArtifact<T>
     } catch {
@@ -111,32 +206,47 @@ export class ContextHubStorage {
   }
 
   async writeArtifact<T>(key: string, artifact: CachedArtifact<T>): Promise<void> {
-    await this.ensureBaseDirectories()
-    const artifactPath = `${this.basePath}/artifacts/${artifactFileName(key)}`
+    await this.initialize()
     const value: CachedArtifact<T> = {
       ...artifact,
       schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
       key,
     }
-    await this.io.writeFileAtomic(artifactPath, JSON.stringify(value, null, 2))
+    const serialized = JSON.stringify(value)
+    const byteSize = new TextEncoder().encode(serialized).byteLength
+    if (byteSize > MAX_ARTIFACT_BYTES) return
+    const artifactPath = `${this.basePath}/artifacts/${await hashedFileName(`${key}:${await sha256Text(serialized)}`)}`
 
-    await this.enqueueManifestWrite(async () => {
-      const current = await this.getManifest()
-      const next: ContextCacheManifest = {
-        ...cloneManifest(current),
-        artifacts: {
+    const previous = this.manifest?.artifacts[key]
+    await this.io.writeFileAtomic(artifactPath, serialized)
+    try {
+      await this.enqueueManifestWrite(async () => {
+        const current = this.manifest ?? emptyManifest()
+        const candidate = {
           ...current.artifacts,
           [key]: {
             path: artifactPath,
-            dependencies: { ...artifact.dependencies },
+            sourceName: artifact.sourceName,
+            scope: artifact.scope,
+            dependencyStamp: artifact.dependencyStamp,
+            createdAt: artifact.createdAt,
+            byteSize,
           },
-        },
-      }
-      await this.persistManifest(next)
-    })
+        }
+        const pruned = pruneArtifactEntries(candidate)
+        await this.persistManifest({ ...current, artifacts: pruned.artifacts })
+        const obsoletePaths = pruned.removed.map((entry) => entry.path)
+        if (previous?.path && previous.path !== artifactPath) obsoletePaths.push(previous.path)
+        await Promise.all(obsoletePaths.map((path) => this.safeDelete(path)))
+      })
+    } catch (error) {
+      if (previous?.path !== artifactPath) await this.safeDelete(artifactPath)
+      throw error
+    }
   }
 
   async readStableBundle(surface: ContextSurface): Promise<StableBundle | null> {
+    await this.initialize()
     try {
       const raw = parseObject(await this.io.readFile(this.stableBundlePath(surface)))
       if (
@@ -144,6 +254,7 @@ export class ContextHubStorage {
         || raw.schemaVersion !== CONTEXT_CACHE_SCHEMA_VERSION
         || raw.surface !== surface
         || typeof raw.text !== "string"
+        || !isDependencyStamp(raw.dependencyStamp)
       ) return null
       return raw as unknown as StableBundle
     } catch {
@@ -152,18 +263,19 @@ export class ContextHubStorage {
   }
 
   async writeStableBundle(surface: ContextSurface, bundle: StableBundle): Promise<void> {
-    await this.ensureBaseDirectories()
+    await this.initialize()
     const value: StableBundle = {
       ...bundle,
       schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
       surface,
     }
-    await this.io.writeFileAtomic(this.stableBundlePath(surface), JSON.stringify(value, null, 2))
+    await this.io.writeFileAtomic(this.stableBundlePath(surface), JSON.stringify(value))
   }
 
   async readSnapshot(surface: ContextSurface, id: string): Promise<ContextHubSnapshot | null> {
+    await this.initialize()
     try {
-      const raw = parseObject(await this.io.readFile(this.snapshotPath(surface, id)))
+      const raw = parseObject(await this.io.readFile(await this.snapshotPath(surface, id)))
       if (
         !raw
         || raw.schemaVersion !== CONTEXT_CACHE_SCHEMA_VERSION
@@ -183,20 +295,21 @@ export class ContextHubStorage {
   }
 
   async writeSnapshot(snapshot: ContextHubSnapshot): Promise<void> {
+    await this.initialize()
     await this.enqueueSnapshotOperation(async () => {
-      await this.ensureBaseDirectories()
       const value: ContextHubSnapshot = {
         ...snapshot,
         schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
       }
       await this.io.writeFileAtomic(
-        this.snapshotPath(snapshot.surface, snapshot.id),
-        JSON.stringify(value, null, 2),
+        await this.snapshotPath(snapshot.surface, snapshot.id),
+        JSON.stringify(value),
       )
     })
   }
 
   async pruneSnapshots(surface: ContextSurface, referencedIds: string[]): Promise<void> {
+    await this.initialize()
     await this.enqueueSnapshotOperation(async () => {
       const directory = this.snapshotSurfacePath(surface)
       let nodes: Array<{ name: string; path: string; is_dir: boolean; mtimeMs?: number }>
@@ -206,7 +319,8 @@ export class ContextHubStorage {
         return
       }
       const referencedPaths = new Set(
-        referencedIds.map((id) => this.snapshotPath(surface, id).toLowerCase()),
+        (await Promise.all(referencedIds.map((id) => this.snapshotPath(surface, id))))
+          .map((path) => path.toLowerCase()),
       )
       const cutoff = Date.now() - SNAPSHOT_CLEANUP_GRACE_MS
       for (const node of nodes) {
@@ -222,32 +336,70 @@ export class ContextHubStorage {
         } catch {
         }
         if (createdAt === undefined || createdAt > cutoff) continue
-        try {
-          await this.io.deleteFile(candidate)
-        } catch {
-        }
+        await this.safeDelete(candidate)
       }
     })
   }
 
-  private async getManifest(): Promise<ContextCacheManifest> {
-    if (this.manifest) return this.manifest
+  private async initializeInternal(): Promise<void> {
+    await this.ensureBaseDirectories()
+    const exists = await this.io.fileExists(this.manifestPath).catch(() => false)
+    if (!exists) {
+      this.manifest = emptyManifest()
+      await this.cleanupOrphanArtifacts()
+      return
+    }
+    const size = await this.io.getFileSize(this.manifestPath).catch(() => MAX_MANIFEST_BYTES + 1)
+    if (size > MAX_MANIFEST_BYTES) {
+      await this.resetCache()
+      return
+    }
     try {
       const raw = parseObject(await this.io.readFile(this.manifestPath))
-      if (
-        !raw
-        || raw.schemaVersion !== CONTEXT_CACHE_SCHEMA_VERSION
-        || !raw.sources
-        || !raw.artifacts
-      ) {
-        this.manifest = emptyManifest()
-      } else {
-        this.manifest = raw as unknown as ContextCacheManifest
+      if (!raw || !this.isValidManifest(raw)) {
+        await this.resetCache()
+        return
       }
+      this.manifest = raw as unknown as ContextCacheManifest
     } catch {
-      this.manifest = emptyManifest()
+      await this.resetCache()
+      return
     }
-    return this.manifest
+
+    const current = this.manifest ?? emptyManifest()
+    const pruned = pruneArtifactEntries(current.artifacts)
+    if (pruned.removed.length > 0) {
+      await this.persistManifest({ ...current, artifacts: pruned.artifacts })
+      await Promise.all(pruned.removed.map((entry) => this.safeDelete(entry.path)))
+    }
+    await this.cleanupOrphanArtifacts()
+  }
+
+  private async resetCache(): Promise<void> {
+    await this.safeDelete(this.basePath)
+    this.manifest = emptyManifest()
+    await this.ensureBaseDirectories()
+    await this.cleanupOrphanArtifacts()
+  }
+
+  private async cleanupOrphanArtifacts(): Promise<void> {
+    const directory = `${this.basePath}/artifacts`
+    let nodes: Array<{ name: string; path: string; is_dir: boolean }>
+    try {
+      nodes = await this.io.listDirectory(directory)
+    } catch {
+      return
+    }
+    const referenced = new Set(
+      Object.values(this.manifest?.artifacts ?? {}).map((entry) => normalizePath(entry.path).toLowerCase()),
+    )
+    await Promise.all(nodes
+      .filter((node) => !node.is_dir && node.name.toLowerCase().endsWith(".json"))
+      .map(async (node) => {
+        const path = normalizePath(node.path)
+        if (!this.isDirectSnapshotFile(directory, path)) return
+        if (!referenced.has(path.toLowerCase())) await this.safeDelete(path)
+      }))
   }
 
   private async ensureBaseDirectories(): Promise<void> {
@@ -260,23 +412,51 @@ export class ContextHubStorage {
   }
 
   private enqueueManifestWrite<T>(operation: () => Promise<T>): Promise<T> {
-    const result = this.manifestWriteQueue.then(
-      () => operation(),
-      () => operation(),
-    )
-    this.manifestWriteQueue = result.then(
-      () => undefined,
-      () => undefined,
-    )
+    const result = this.manifestWriteQueue.then(operation, operation)
+    this.manifestWriteQueue = result.then(() => undefined, () => undefined)
     return result
   }
 
   private async persistManifest(manifest: ContextCacheManifest): Promise<void> {
-    await this.ensureBaseDirectories()
-    await this.io.writeFileAtomic(this.manifestPath, JSON.stringify(manifest, null, 2))
+    if (this.disposed) throw new Error("Context Hub storage 已释放")
+    await this.io.writeFileAtomic(this.manifestPath, JSON.stringify(manifest))
     this.manifest = manifest
   }
 
+  private isValidManifest(raw: Record<string, unknown>): boolean {
+    if (
+      raw.schemaVersion !== CONTEXT_CACHE_SCHEMA_VERSION
+      || !isRecord(raw.sources)
+      || !isRecord(raw.artifacts)
+    ) return false
+    for (const source of Object.values(raw.sources)) {
+      if (
+        !isRecord(source)
+        || typeof source.path !== "string"
+        || typeof source.kind !== "string"
+        || !Number.isInteger(source.revision)
+        || (source.revision as number) < 0
+      ) return false
+    }
+    const artifactDirectory = `${this.basePath}/artifacts`
+    for (const artifact of Object.values(raw.artifacts)) {
+      if (
+        !isRecord(artifact)
+        || typeof artifact.path !== "string"
+        || !this.isDirectSnapshotFile(artifactDirectory, normalizePath(artifact.path))
+        || typeof artifact.sourceName !== "string"
+        || !["static", "chapter", "task"].includes(String(artifact.scope))
+        || !isDependencyStamp(artifact.dependencyStamp)
+        || typeof artifact.createdAt !== "number"
+        || !Number.isFinite(artifact.createdAt)
+        || typeof artifact.byteSize !== "number"
+        || !Number.isFinite(artifact.byteSize)
+        || artifact.byteSize < 0
+      ) return false
+    }
+    return true
+  }
+
   private stableBundlePath(surface: ContextSurface): string {
     return `${this.basePath}/stable-bundles/${surface}.json`
   }
@@ -285,8 +465,8 @@ export class ContextHubStorage {
     return `${this.basePath}/snapshots/${surface}`
   }
 
-  private snapshotPath(surface: ContextSurface, id: string): string {
-    return `${this.snapshotSurfacePath(surface)}/${artifactFileName(`snapshot:${id}`)}`
+  private async snapshotPath(surface: ContextSurface, id: string): Promise<string> {
+    return `${this.snapshotSurfacePath(surface)}/${await hashedFileName(`snapshot:${id}`)}`
   }
 
   private isDirectSnapshotFile(directory: string, candidate: string): boolean {
@@ -301,14 +481,15 @@ export class ContextHubStorage {
   }
 
   private enqueueSnapshotOperation<T>(operation: () => Promise<T>): Promise<T> {
-    const result = this.snapshotOperationQueue.then(
-      () => operation(),
-      () => operation(),
-    )
-    this.snapshotOperationQueue = result.then(
-      () => undefined,
-      () => undefined,
-    )
+    const result = this.snapshotOperationQueue.then(operation, operation)
+    this.snapshotOperationQueue = result.then(() => undefined, () => undefined)
     return result
   }
+
+  private async safeDelete(path: string): Promise<void> {
+    try {
+      await this.io.deleteFile(path)
+    } catch {
+    }
+  }
 }

+ 28 - 6
src/lib/context-hub/types.ts

@@ -2,7 +2,7 @@ import type { AgentMessage } from "@/lib/agent/types"
 import type { DataSourceCategory } from "@/lib/novel/classification"
 import type { ContextPack } from "@/lib/novel/context-engine"
 
-export const CONTEXT_CACHE_SCHEMA_VERSION = 1
+export const CONTEXT_CACHE_SCHEMA_VERSION = 2
 
 export type ContextSurface = "ai-chat" | "ai-outline"
 export type ContextIntent = "generate" | "question" | "review" | "lint"
@@ -16,9 +16,18 @@ export type ContextSourceKind =
   | "deduction"
   | "soul"
   | "book-analysis"
+  | "retrieval"
   | "other"
   | "ignored"
 
+export interface DependencyStamp {
+  fingerprint: string
+  sourceCount: number
+  kinds: ContextSourceKind[]
+}
+
+export type ContextCacheScope = "static" | "chapter" | "task"
+
 export interface SourceVersion {
   path: string
   kind: ContextSourceKind
@@ -31,8 +40,10 @@ export interface SourceVersion {
 export interface CachedArtifact<T = unknown> {
   schemaVersion: number
   key: string
+  sourceName: string
+  scope: ContextCacheScope
   value: T
-  dependencies: Record<string, number>
+  dependencyStamp: DependencyStamp
   createdAt: number
 }
 
@@ -40,19 +51,28 @@ export interface StableBundle {
   schemaVersion: number
   surface: ContextSurface
   text: string
-  dependencies: Record<string, number>
+  dependencyStamp: DependencyStamp
   updatedAt: number
 }
 
+export interface ContextCacheArtifactEntry {
+  path: string
+  sourceName: string
+  scope: ContextCacheScope
+  dependencyStamp: DependencyStamp
+  createdAt: number
+  byteSize: number
+}
+
 export interface ContextCacheManifest {
   schemaVersion: number
   sources: Record<string, SourceVersion>
-  artifacts: Record<string, { path: string; dependencies: Record<string, number> }>
+  artifacts: Record<string, ContextCacheArtifactEntry>
 }
 
 export interface SessionContextSummary {
   text: string
-  dependencies: Record<string, number>
+  dependencyFingerprint?: string
   updatedAt: number
 }
 
@@ -88,7 +108,9 @@ export interface ContextCacheItemTrace {
   key: string
   sourceName: string
   status: ContextCacheItemStatus
+  dependencyStamp: DependencyStamp
   dependencyPaths: string[]
+  dependencyPathsTruncated: boolean
 }
 
 export interface ContextHubSnapshotRef {
@@ -130,7 +152,7 @@ export interface ContextHubResult {
   sessionSummary: string
   dynamicContext: string
   contextPack: ContextPack
-  dependencies: Record<string, number>
+  dependencyStamp: DependencyStamp
   stats: ContextHubStats
   cacheItems: ContextCacheItemTrace[]
   warnings: string[]

+ 3 - 0
src/lib/export-center/export-service.spec.ts

@@ -103,6 +103,7 @@ it("Rust 临时文件使用同一可写句柄 write_all 和 sync_all,关闭后
 it("默认二进制写入桥接在 Rust 后端实现并注册", () => {
   const fsSource = readFileSync(resolve(process.cwd(), "src-tauri/src/commands/fs.rs"), "utf8")
   const libSource = readFileSync(resolve(process.cwd(), "src-tauri/src/lib.rs"), "utf8")
+  const mainSource = readFileSync(resolve(process.cwd(), "src-tauri/src/main.rs"), "utf8")
 
   expect(fsSource).toContain("pub async fn write_export_file")
   expect(fsSource).toContain("file.write_all(bytes)")
@@ -124,6 +125,8 @@ it("默认二进制写入桥接在 Rust 后端实现并注册", () => {
   expect(fsSource).toContain("validate_export_base64_length")
   expect(fsSource).toContain("write_export_file_enforces_64_mib_boundary")
   expect(libSource).toContain("commands::fs::write_export_file")
+  // default-run is main.rs; registration only in lib.rs leaves the command dead in the real binary.
+  expect(mainSource).toContain("commands::fs::write_export_file")
   const serviceSource = readFileSync(resolve(process.cwd(), "src/lib/export-center/export-service.ts"), "utf8")
   expect(serviceSource).toContain("contentsBase64: encodeExportBytesBase64(bytes)")
 })

+ 1 - 1
src/lib/hardcoded-ui-chinese-i18n.test.ts

@@ -171,7 +171,7 @@ describe("user-facing hardcoded chinese strings", () => {
     expect(maintenanceSection).toContain('t("settings.sections.maintenance.title", { defaultValue: "维护工具" })')
     expect(maintenanceSection).toContain('t("settings.sections.maintenance.description", {')
     expect(maintenanceSection).toContain(
-      'defaultValue:\n              "用于清理资料库的工具:检测并合并那些在多次重新摄取后被大模型以不同名称创建出来的重复实体或概念。",'
+      'defaultValue:\n              "用于清理资料库的工具:检测并合并重复实体/概念,以及清理伏笔追踪器中的重复、噪声与失效条目。",'
     )
     expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.title", {')
     expect(maintenanceSection).toContain('defaultValue: "检测重复实体 / 概念"')

+ 16 - 1
src/lib/llm-model-keys.spec.ts

@@ -1,6 +1,10 @@
 import { describe, expect, it } from "vitest"
 
-import { getStableAvailableModelKey } from "@/lib/llm-model-keys"
+import {
+  getStableAvailableModelKey,
+  hasAvailableModels,
+  isProviderAvailable,
+} from "@/lib/llm-model-keys"
 import type { ProviderConfigs } from "@/stores/wiki-store"
 
 function saved(id: string, name = id) {
@@ -8,6 +12,17 @@ function saved(id: string, name = id) {
 }
 
 describe("stable available model keys", () => {
+  it("hard-disables explicit false while keeping configured legacy providers usable", () => {
+    const disabled = { enabled: false, apiKey: "old-key", savedModels: [saved("disabled-model")] }
+    const legacy = { apiKey: "legacy-key", savedModels: [saved("legacy-model")] }
+    const configs: ProviderConfigs = { openai: disabled, anthropic: legacy }
+
+    expect(isProviderAvailable("openai", disabled)).toBe(false)
+    expect(isProviderAvailable("anthropic", legacy)).toBe(true)
+    expect(hasAvailableModels(configs)).toBe(true)
+    expect(getStableAvailableModelKey("legacy-model", configs)).toBe("anthropic/legacy-model")
+  })
+
   it("treats a slash-containing legacy model id as a whole when its prefix is not a provider", () => {
     const configs: ProviderConfigs = {
       openai: { enabled: true, apiKey: "key", savedModels: [saved("vendor/family/model-v1")] },

+ 3 - 2
src/lib/llm-model-keys.ts

@@ -1,8 +1,9 @@
 import type { ProviderConfigs, ProviderOverride, SavedModel } from "@/stores/wiki-store"
 
-function isProviderAvailable(providerId: string, config: ProviderOverride): boolean {
+export function isProviderAvailable(providerId: string, config: ProviderOverride): boolean {
+  if (config.enabled === false) return false
   if (providerId.startsWith("custom-")) {
-    return config.enabled !== false
+    return true
   }
   // 内置预设:已启用,或有有效配置(apiKey + model/savedModels)
   return config.enabled === true

+ 21 - 0
src/lib/novel/chapter-content-cleanup.spec.ts

@@ -3,8 +3,21 @@ import { describe, expect, it } from "vitest"
 import {
   cleanGeneratedChapterContentForSave,
   cleanGeneratedChapterContentWithTitle,
+  isPlausibleChapterTitleLine,
 } from "./chapter-content-cleanup"
 
+describe("isPlausibleChapterTitleLine", () => {
+  it("接受真实章名", () => {
+    expect(isPlausibleChapterTitleLine("# 第32章 查分夜")).toBe(true)
+    expect(isPlausibleChapterTitleLine("第13章 风雪来客")).toBe(true)
+  })
+
+  it("拒绝完成通知伪标题", () => {
+    expect(isPlausibleChapterTitleLine("第 32 章正文已按章纲重写完成。")).toBe(false)
+    expect(isPlausibleChapterTitleLine("# 第32章正文已重写完成")).toBe(false)
+  })
+})
+
 describe("cleanGeneratedChapterContentWithTitle", () => {
   it("提取 Markdown 章节标题,但不把标题重复保存在正文中", () => {
     expect(cleanGeneratedChapterContentWithTitle("# 第12章 夜雨归人\n\n雨落在旧宅门前。\n\n他推门而入。"))
@@ -29,6 +42,14 @@ describe("cleanGeneratedChapterContentWithTitle", () => {
         content: "雨落在旧宅门前。\n\n他推门而入。",
       })
   })
+
+  it("不把完成通知当作章节标题提取", () => {
+    expect(cleanGeneratedChapterContentWithTitle("第 32 章正文已按章纲重写完成。"))
+      .toEqual({
+        title: null,
+        content: "第 32 章正文已按章纲重写完成。",
+      })
+  })
 })
 
 describe("cleanGeneratedChapterContentForSave", () => {

+ 20 - 0
src/lib/novel/chapter-content-cleanup.ts

@@ -20,6 +20,23 @@ function stripThinkingBlocks(content: string): string {
   return result
 }
 
+/** 完成通知类伪标题(如「第 32 章正文已按章纲重写完成。」)不得当作章名。 */
+const CHAPTER_TITLE_STATUS_RE = /(?:完成|重写|已按|生成|工作流)/
+
+/**
+ * 判断一行是否像真实章节标题(「第N章 查分夜」),而不是完成通知。
+ * 要求「第N章」后有分隔与短标题名,且不含完成态动词簇。
+ */
+export function isPlausibleChapterTitleLine(line: string): boolean {
+  const trimmed = line.trim().replace(/^#{1,6}\s*/, "")
+  const match = trimmed.match(/^第\s*\d+\s*章(?:\s*[::\-—–]?\s*|\s+)(.+)$/)
+  if (!match?.[1]) return false
+  const name = match[1].trim()
+  if (!name || name.length > 40) return false
+  if (CHAPTER_TITLE_STATUS_RE.test(name)) return false
+  return true
+}
+
 /**
  * 从内容开头提取章节标题,并返回清理后的行数组和提取到的标题。
  * 标题格式:# 第X章 标题名 或 第X章 标题名
@@ -32,6 +49,9 @@ function extractLeadingTitle(lines: string[]): { lines: string[]; title: string
   while (index < lines.length && !lines[index].trim()) index += 1
 
   const firstLine = lines[index]?.trim() ?? ""
+  if (!isPlausibleChapterTitleLine(firstLine)) {
+    return { lines, title: null }
+  }
 
   // 匹配 # 第X章 标题 格式
   const headingMatch = firstLine.match(/^#{1,6}\s*(第\s*\d+\s*章.*)$/)

+ 10 - 3
src/lib/novel/outline-find-protocol.spec.ts

@@ -11,11 +11,18 @@ describe("outline-find-protocol", () => {
     const text = buildOutlineFindProtocol(167)
     expect(text).toContain("本次写作目标:第 167 章")
     expect(text).toContain("list_outlines")
-    expect(text).toContain("overview(高优先级入口)")
-    expect(text).toContain("concept / 设定类(写作硬约束)")
-    expect(text).toContain("禁止在未查看 overview/concept")
+    expect(text).toContain("优先扫 folder")
+    expect(text).toContain("按文件夹分流(主路径")
+    expect(text).toContain("章纲:本章主候选")
+    expect(text).toContain("设定:写作硬约束")
+    expect(text).toContain("兼容(非强制)")
+    expect(text).toContain("overview≈大纲")
     expect(text).toContain("read_outline")
     expect(text).toContain("禁止只凭文件名")
+    expect(text).toContain("必须调用 run_chapter_workflow")
+    expect(text).toContain("禁止直接输出终稿正文")
+    expect(text).not.toContain("再写作或调用")
+    expect(text).not.toContain("先扫一遍有哪些 overview / concept / outline")
   })
 
   it("asks model to resolve chapter number when missing", () => {

+ 12 - 10
src/lib/novel/outline-find-protocol.ts

@@ -1,5 +1,5 @@
 /**
- * 写作前找大纲协议:先钉目标章号,再按 type 分流,最后读正文判断归属。
+ * 写作前找大纲协议:先钉目标章号,再按文件夹分流(主路径),旧 type 仅作兼容,最后读正文判断归属。
  * 不依赖文件名规范,也不要求大纲正文写法统一。
  */
 
@@ -27,15 +27,17 @@ export function buildOutlineFindProtocol(targetChapterNumber?: number): string {
     "## 大纲定位协议(写章节前必须遵守)",
     "",
     targetLine,
-    "1. 调用 list_outlines 查看全部大纲候选及其 type / outline_type;先扫一遍有哪些 overview / concept / outline,不要只盯卷纲文件名。",
-    "2. 按 type 分流处理(有 type 用 type;无 type 则读正文判断用途):",
-    "   - overview(高优先级入口):应优先 read_outline 读索引,按 related / 文档表发现必须遵守的规则文档与卷纲入口;不要跳过 overview 直接瞎点卷纲。overview 本身不是本章剧情大纲。",
-    "   - concept / 设定类(写作硬约束):列表中出现的 concept 默认视为全书机制/叙述禁则;写正文前应至少读与本次任务相关的 concept(有 overview 指引时按指引读;无指引时对列出的 concept 做必要性判断并读取关键项)。不要用章号去「匹配」concept,也不要把它们当成卷纲。",
-    "   - outline(及 outline_type 为 story/volume/chapter-outline 等):主候选;必须 read_outline 读正文,判断是否对应该章 / 当前阶段。",
-    "   - 未知 type:必须读正文判断是卷纲、章纲、设定还是清单。",
-    "3. 同为 outline 也可能不是卷纲(如资产明细、人物表);禁止只看 type 或文件名就选定。",
-    "4. 确认对应该章的大纲,并已知相关 overview/concept 约束后,再写作或调用 run_chapter_workflow。",
-    "禁止只凭文件名猜测分卷;禁止把 concept/overview 当成章节剧情大纲;禁止在未查看 overview/concept 的情况下只读一份卷纲就开写。",
+    "1. 调用 list_outlines 查看全部大纲候选;优先扫 folder(大纲 / 设定 / 章纲 / 卷纲 / 人物小传等),不要只盯卷纲文件名。",
+    "2. 按文件夹分流(主路径;新项目默认无 type):",
+    "   - 大纲:索引/总纲入口;应优先 read_outline 读索引,发现必须遵守的规则文档与卷纲入口;本身通常不是本章剧情细纲。",
+    "   - 设定:写作硬约束/机制;写正文前应至少读与本次任务相关的设定。不要用章号去「匹配」设定,也不要把它们当成卷纲。",
+    "   - 章纲:本章主候选;必须 read_outline 读正文,按目标章号定位对应章纲。",
+    "   - 卷纲:卷级候选;确认章号落在该卷后再读。",
+    "   - 人物小传 / 伏笔 / 组织:辅助资料,不当本章剧情大纲。",
+    "3. 兼容(非强制):若几乎无标准文件夹、却有旧 frontmatter type,可参考 overview≈大纲、concept≈设定、outline≈卷纲/章纲;不要求新项目补 type。",
+    "4. 文件夹与 type 都缺失时,必须读正文判断是卷纲、章纲、设定还是清单;禁止只看文件名就选定。",
+    "5. 确认对应该章的大纲,并已知相关 大纲/设定(或旧 overview/concept)约束后,必须调用 run_chapter_workflow;禁止直接输出终稿正文。",
+    "禁止只凭文件名猜测分卷;禁止把设定/人物小传(或旧 concept/overview)当成章节剧情大纲;禁止在未查看 大纲/设定 的情况下只读一份卷纲就开写。",
   ].join("\n")
 }
 

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

@@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({
   finalizeProjectMemoryRebuildMock: vi.fn(),
   refreshProjectStateMock: vi.fn(),
   hasUsableLlmMock: vi.fn(() => true),
+  listDirectoryMock: vi.fn(),
+  outlineSnapshotExistsMock: vi.fn(),
   outlineStore: {
     tasks: [] as Array<{ id: string; projectPath: string; outlinePath: string | null; status: string; message: string; error: string | null; updatedAt: number }>,
     createTask: vi.fn((input: { projectPath: string; outlinePath?: string | null }) => {
@@ -61,6 +63,22 @@ vi.mock("@/lib/has-usable-llm", () => ({
   hasUsableLlm: mocks.hasUsableLlmMock,
 }))
 
+vi.mock("@/commands/fs", async () => {
+  const actual = await vi.importActual<typeof import("@/commands/fs")>("@/commands/fs")
+  return {
+    ...actual,
+    listDirectory: mocks.listDirectoryMock,
+  }
+})
+
+vi.mock("./outline-ingest-utils", async () => {
+  const actual = await vi.importActual<typeof import("./outline-ingest-utils")>("./outline-ingest-utils")
+  return {
+    ...actual,
+    outlineSnapshotExists: mocks.outlineSnapshotExistsMock,
+  }
+})
+
 vi.mock("@/stores/wiki-store", () => ({
   useWikiStore: {
     getState: () => ({
@@ -89,6 +107,7 @@ import {
   buildOutlineRefinementContext,
   formatBulkOutlineIngestResult,
   OutlineIngestNotReadyError,
+  runBulkOutlineIngest,
   runOutlineIngestPaths,
 } from "./outline-generation"
 
@@ -145,6 +164,8 @@ describe("bulk outline ingest", () => {
     mocks.refreshProjectStateMock.mockReset()
     mocks.hasUsableLlmMock.mockReset()
     mocks.hasUsableLlmMock.mockReturnValue(true)
+    mocks.listDirectoryMock.mockReset()
+    mocks.outlineSnapshotExistsMock.mockReset()
     mocks.outlineStore.tasks = []
     mocks.importProgressStore.tasks = []
     mocks.importProgressStore.startTask.mockClear()
@@ -234,4 +255,58 @@ describe("bulk outline ingest", () => {
     expect(message).toContain("b")
     expect(message).toContain("JSON 解析失败")
   })
+
+  it("formats already-extracted empty result", () => {
+    const message = formatBulkOutlineIngestResult({
+      total: 0,
+      succeeded: 0,
+      failed: 0,
+      failures: [],
+      emptyReason: "already_extracted",
+    })
+
+    expect(message).toContain("已提取")
+  })
+
+  it("pending mode skips outlines that already have snapshots", async () => {
+    mocks.listDirectoryMock.mockResolvedValueOnce([
+      { path: "E:/Novel/wiki/outlines/a.md", name: "a.md", is_dir: false },
+      { path: "E:/Novel/wiki/outlines/b.md", name: "b.md", is_dir: false },
+    ])
+    mocks.outlineSnapshotExistsMock
+      .mockResolvedValueOnce(true)
+      .mockResolvedValueOnce(false)
+    mocks.ingestOutlineMock.mockResolvedValueOnce({
+      snapshot: { chapterId: "outline-b", chapterNumber: -2 },
+      truncated: false,
+      originalLength: 100,
+      bodyLength: 100,
+      bodyBudget: 1000,
+      failureReason: null,
+    })
+
+    const result = await runBulkOutlineIngest("E:/Novel", { mode: "pending" })
+
+    expect(result).toMatchObject({ total: 1, succeeded: 1, failed: 0 })
+    expect(mocks.ingestOutlineMock).toHaveBeenCalledTimes(1)
+    expect(mocks.ingestOutlineMock.mock.calls[0]?.[1]).toContain("b.md")
+  })
+
+  it("pending mode returns already_extracted when nothing is left", async () => {
+    mocks.listDirectoryMock.mockResolvedValueOnce([
+      { path: "E:/Novel/wiki/outlines/a.md", name: "a.md", is_dir: false },
+    ])
+    mocks.outlineSnapshotExistsMock.mockResolvedValueOnce(true)
+
+    const result = await runBulkOutlineIngest("E:/Novel", { mode: "pending" })
+
+    expect(result).toEqual({
+      total: 0,
+      succeeded: 0,
+      failed: 0,
+      failures: [],
+      emptyReason: "already_extracted",
+    })
+    expect(mocks.ingestOutlineMock).not.toHaveBeenCalled()
+  })
 })

+ 35 - 3
src/lib/novel/outline-generation.ts

@@ -663,12 +663,20 @@ export interface OutlineIngestFailure {
   reason: string
 }
 
+export type BulkOutlineIngestMode = "all" | "pending"
+
 export interface BulkOutlineIngestResult {
   total: number
   succeeded: number
   failed: number
   cancelled?: boolean
   failures: OutlineIngestFailure[]
+  /** Present when total is 0 and the caller asked for a scoped bulk run. */
+  emptyReason?: "no_outlines" | "already_extracted"
+}
+
+export interface RunBulkOutlineIngestOptions {
+  mode?: BulkOutlineIngestMode
 }
 
 export interface RunOutlineIngestPathsOptions {
@@ -692,7 +700,7 @@ export interface RunOutlineIngestTaskOptions {
   manageProgress?: boolean
 }
 
-import { getOutlineFileName } from "./outline-ingest-utils"
+import { getOutlineFileName, outlineSnapshotExists } from "./outline-ingest-utils"
 
 function buildOutlineIngestFailureReason(err: unknown): string {
   if (err instanceof Error) return err.message
@@ -763,6 +771,9 @@ function buildBulkIngestProgressMessage(result: BulkOutlineIngestResult): string
 
 export function formatBulkOutlineIngestResult(result: BulkOutlineIngestResult): string {
   if (result.total === 0) {
+    if (result.emptyReason === "already_extracted") {
+      return i18n.t("novel.outlineGenerator.bulkIngestAlreadyExtracted")
+    }
     return i18n.t("novel.outlineGenerator.bulkIngestEmpty")
   }
   if (result.cancelled) {
@@ -1054,8 +1065,12 @@ function collectOutlineMarkdownPaths(
   return paths
 }
 
-export async function runBulkOutlineIngest(projectPath: string): Promise<BulkOutlineIngestResult> {
+export async function runBulkOutlineIngest(
+  projectPath: string,
+  options?: RunBulkOutlineIngestOptions,
+): Promise<BulkOutlineIngestResult> {
   const pp = normalizePath(projectPath)
+  const mode: BulkOutlineIngestMode = options?.mode ?? "all"
   let outlinePaths: string[] = []
 
   try {
@@ -1063,7 +1078,24 @@ export async function runBulkOutlineIngest(projectPath: string): Promise<BulkOut
     outlinePaths = collectOutlineMarkdownPaths(tree as Array<{ path: string; name: string; is_dir: boolean; children?: Array<{ path: string; name: string; is_dir: boolean; children?: unknown[] }> }>)
       .sort((a, b) => a.localeCompare(b, "zh-CN"))
   } catch {
-    return { total: 0, succeeded: 0, failed: 0, failures: [] }
+    return { total: 0, succeeded: 0, failed: 0, failures: [], emptyReason: "no_outlines" }
+  }
+
+  if (outlinePaths.length === 0) {
+    return { total: 0, succeeded: 0, failed: 0, failures: [], emptyReason: "no_outlines" }
+  }
+
+  if (mode === "pending") {
+    const pending: string[] = []
+    for (const outlinePath of outlinePaths) {
+      if (!(await outlineSnapshotExists(pp, outlinePath))) {
+        pending.push(outlinePath)
+      }
+    }
+    if (pending.length === 0) {
+      return { total: 0, succeeded: 0, failed: 0, failures: [], emptyReason: "already_extracted" }
+    }
+    outlinePaths = pending
   }
 
   return runOutlineIngestPaths(pp, outlinePaths)

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

@@ -1,8 +1,6 @@
 import { describe, expect, it } from "vitest"
 import {
-  buildOutlineGenerationQualityFeedback,
   extractChapterOutlineStatus,
-  formatChapterOutlineQualityReport,
   isLikelyChapterOutline,
   runChapterOutlineQualityCheck,
   summarizeChapterOutlineQuality,
@@ -73,43 +71,4 @@ describe("章纲质量检查", () => {
     expect(extractChapterOutlineStatus(content.replace("当前状态:草稿", "当前状态:已确认"))).toBe("已确认")
   })
 
-  it("质量报告应给出可执行的章纲补全建议", () => {
-    const summary = summarizeChapterOutlineQuality("# 章纲-第001章\n\n## 核心事件\n\n- 事件1:只有一个事件")
-    const report = formatChapterOutlineQualityReport(summary, {
-      maxIssues: 3,
-      includeWarnings: true,
-    })
-
-    expect(report).toContain("章纲质量检查未通过")
-    expect(report).toContain("项错误")
-    expect(report).toContain("主要缺失")
-    expect(report).toContain("另有")
-    expect(report).toContain("请让 AI 按章纲标准补齐后重新输出完整章纲,再保存")
-  })
-
-  it("通过但存在提醒时应提示继续完善提醒项", () => {
-    const report = formatChapterOutlineQualityReport({
-      valid: true,
-      errors: [],
-      warnings: ["人物状态缺少「关键配角状态」字段"],
-      items: [],
-    })
-
-    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")
-  })
 })

+ 9 - 2
src/lib/novel/result-parser.spec.ts

@@ -48,10 +48,17 @@ title: "第三章 初遇"
       expect(result.warnings).toContain("缺少 frontmatter 元数据")
     })
 
-    it("字数过少应该返回警告", () => {
+    it("字数过少应该返回 invalid", () => {
       const content = "短文本"
       const result = validateChapterContent(content)
-      expect(result.warnings.some((w) => w.includes("字数过少"))).toBe(true)
+      expect(result.valid).toBe(false)
+      expect(result.errors.some((e) => e.includes("字数过少"))).toBe(true)
+    })
+
+    it("完成通知短句应该返回 invalid", () => {
+      const result = validateChapterContent("第 32 章正文已按章纲重写完成。")
+      expect(result.valid).toBe(false)
+      expect(result.errors.some((e) => e.includes("字数过少"))).toBe(true)
     })
   })
 

+ 1 - 1
src/lib/novel/result-parser.ts

@@ -92,7 +92,7 @@ export function validateChapterContent(content: string): ChapterValidationResult
     warnings.push("未检测到章节标题")
   }
   if (wordCount < 50) {
-    warnings.push(`字数过少(${wordCount} 字)`)
+    errors.push(`字数过少(${wordCount} 字)`)
   }
   if (wordCount > 20000) {
     warnings.push(`字数超过上限(${wordCount} 字 / 20000 字上限)`)

+ 8 - 0
src/lib/novel/result-save-guard.spec.ts

@@ -16,4 +16,12 @@ describe("result save guard", () => {
     expect(result.ok).toBe(true)
     expect(result.trace.valid).toBe(true)
   })
+
+  it("blocks completion-notice short text before confirming a draft save", () => {
+    const result = validateChapterBeforeSave("第 32 章正文已按章纲重写完成。")
+
+    expect(result.ok).toBe(false)
+    expect(result.trace.valid).toBe(false)
+    expect(result.message).toContain("字数过少")
+  })
 })

+ 1 - 1
src/lib/novel/task-router.ts

@@ -466,7 +466,7 @@ export function buildTaskDirective(route: TaskRouteResult): string {
   ) {
     lines.push(`本次写作目标:第 ${route.chapterNumber} 章。`)
     lines.push(
-      "写正文前先 list_outlines(可传 chapterNumber),再按 type 分流并用 read_outline 读正文,确认对应该章的大纲后再写。",
+      "写正文前先 list_outlines(可传 chapterNumber),再按文件夹分流(优先;旧 type 仅兼容)并用 read_outline 读正文,确认对应该章的大纲后再写。",
     )
   }
 

+ 45 - 3
src/lib/persist.spec.ts

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
 
 const fsMocks = vi.hoisted(() => ({
   writeFile: vi.fn(),
+  writeFileAtomic: vi.fn(),
   readFile: vi.fn(),
   createDirectory: vi.fn(),
 }))
@@ -21,6 +22,7 @@ import { loadChatHistory, saveChatHistory } from "./persist"
 describe("chat context summary persistence", () => {
   beforeEach(() => {
     fsMocks.writeFile.mockReset().mockResolvedValue(undefined)
+    fsMocks.writeFileAtomic.mockReset().mockResolvedValue(undefined)
     fsMocks.createDirectory.mockReset().mockResolvedValue(undefined)
     fsMocks.readFile.mockReset()
     contextHubMocks.pruneSnapshots.mockReset().mockResolvedValue(undefined)
@@ -29,8 +31,8 @@ describe("chat context summary persistence", () => {
     })
   })
 
-  it("saves dependency revisions in the conversation manifest", async () => {
-    const contextSummary = { text: "摘要", dependencies: { outline: 3 }, updatedAt: 10 }
+  it("saves the dependency fingerprint in the conversation manifest", async () => {
+    const contextSummary = { text: "摘要", dependencyFingerprint: "outline-v3", updatedAt: 10 }
     await saveChatHistory("E:/Novel", [{
       id: "chat-1",
       title: "会话",
@@ -63,9 +65,49 @@ describe("chat context summary persistence", () => {
 
     expect(loaded.conversations[0].contextSummary).toEqual({
       text: "旧摘要",
-      dependencies: {},
       updatedAt: 0,
     })
+    expect(fsMocks.writeFileAtomic).toHaveBeenCalledWith(
+      "E:/Novel/.qmai/conversations.json",
+      expect.not.stringContaining("dependencies"),
+    )
+  })
+
+  it("migrates a legacy combined chat file and removes its dependency table", async () => {
+    fsMocks.readFile.mockImplementation(async (path: string) => {
+      if (path.endsWith("/.qmai/chat-history.json")) {
+        return JSON.stringify({
+          conversations: [{
+            id: "chat-legacy",
+            title: "旧会话",
+            createdAt: 1,
+            updatedAt: 2,
+            deAiMode: false,
+            contextSummary: {
+              text: "保留的旧摘要",
+              updatedAt: 2,
+              dependencies: { "E:/Novel/wiki/entities/a.md": 1 },
+            },
+          }],
+          messages: [{
+            id: "message-1",
+            role: "assistant",
+            content: "旧消息",
+            timestamp: 2,
+            conversationId: "chat-legacy",
+          }],
+        })
+      }
+      throw new Error("文件不存在")
+    })
+
+    const loaded = await loadChatHistory("E:/Novel")
+
+    expect(loaded.conversations[0].contextSummary).toEqual({ text: "保留的旧摘要", updatedAt: 2 })
+    const manifestCall = fsMocks.writeFile.mock.calls.find(([path]) => path.endsWith("/.qmai/conversations.json"))
+    expect(manifestCall).toBeDefined()
+    expect(manifestCall![1]).not.toContain("dependencies")
+    expect(manifestCall![1]).toContain("保留的旧摘要")
   })
 
   it("persists the context snapshot reference with an assistant message", async () => {

+ 34 - 5
src/lib/persist.ts

@@ -1,9 +1,12 @@
-import { writeFile, readFile, createDirectory } from "@/commands/fs"
+import { writeFile, writeFileAtomic, readFile, createDirectory } from "@/commands/fs"
 import type { ReviewItem } from "@/stores/review-store"
 import type { DisplayMessage, Conversation } from "@/stores/chat-store"
 import { normalizeLoadedRunStates, type ConversationRunStates } from "@/lib/conversation-run-state"
 import { normalizePath } from "@/lib/path-utils"
-import { normalizeSessionContextSummary } from "@/lib/context-hub/session-summary"
+import {
+  isLegacySessionContextSummary,
+  normalizeSessionContextSummary,
+} from "@/lib/context-hub/session-summary"
 import { getContextHub } from "@/lib/context-hub/context-hub"
 
 const MAX_RETRIES = 3
@@ -201,6 +204,9 @@ export async function loadChatHistory(projectPath: string): Promise<PersistedCha
       : Array.isArray(parsedManifest?.conversations)
         ? parsedManifest.conversations
         : []
+    const hasLegacyContextSummary = rawConversations.some((conversation) => (
+      isLegacySessionContextSummary(conversation.contextSummary)
+    ))
     const conversations = rawConversations.map(normalizeConversation)
     const rawRunStates = Array.isArray(parsedManifest) ? {} : parsedManifest.runStates
     const runStates = normalizeConversationRunStates(conversations, rawRunStates)
@@ -216,6 +222,17 @@ export async function loadChatHistory(projectPath: string): Promise<PersistedCha
       }
     }
 
+    if (hasLegacyContextSummary) {
+      try {
+        await writeFileAtomic(
+          `${pp}/.qmai/conversations.json`,
+          JSON.stringify({ conversations, runStates }, null, 2),
+        )
+      } catch (error) {
+        console.warn("persist: 旧版上下文摘要自动瘦身失败,下次加载时将重试", error)
+      }
+    }
+
     return { conversations, messages: allMessages, runStates }
   } catch {
     // Fall back to old format
@@ -244,14 +261,26 @@ export async function loadChatHistory(projectPath: string): Promise<PersistedCha
       // Old combined format
       if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
         const data = parsed as PersistedChatData
-        const conversations = Array.isArray(data.conversations)
-          ? data.conversations.map(normalizeConversation)
+        const rawConversations = Array.isArray(data.conversations) ? data.conversations : []
+        const hasLegacyContextSummary = rawConversations.some((conversation) => (
+          isLegacySessionContextSummary(conversation.contextSummary)
+        ))
+        const conversations = rawConversations.length > 0
+          ? rawConversations.map(normalizeConversation)
           : []
-        return {
+        const migrated = {
           conversations,
           messages: Array.isArray(data.messages) ? data.messages : [],
           runStates: normalizeConversationRunStates(conversations, data.runStates),
         }
+        if (hasLegacyContextSummary) {
+          try {
+            await saveChatHistory(pp, migrated.conversations, migrated.messages, undefined, migrated.runStates)
+          } catch (error) {
+            console.warn("persist: 旧版合并聊天记录自动迁移失败,下次加载时将重试", error)
+          }
+        }
+        return migrated
       }
       console.warn("persist: 聊天历史数据格式无效")
       return { conversations: [], messages: [], runStates: {} }

+ 8 - 0
src/lib/reset-project-state.spec.ts

@@ -1,8 +1,14 @@
 import { beforeEach, describe, expect, it, vi } from "vitest"
 
+const contextHubMocks = vi.hoisted(() => ({
+  disposeAllContextHubs: vi.fn(),
+  getContextHub: vi.fn(() => ({ pruneSnapshots: vi.fn(async () => {}) })),
+}))
+
 vi.mock("@/lib/ingest-queue", () => ({
   pauseQueue: vi.fn().mockResolvedValue(undefined),
 }))
+vi.mock("@/lib/context-hub/context-hub", () => contextHubMocks)
 
 import { resetProjectState, resetProjectStores } from "./reset-project-state"
 import { useActivityStore } from "@/stores/activity-store"
@@ -11,6 +17,7 @@ import { useOutlineChatStore } from "@/stores/outline-chat-store"
 import { useReviewStore } from "@/stores/review-store"
 
 beforeEach(() => {
+  contextHubMocks.disposeAllContextHubs.mockClear()
   useChatStore.setState({
     conversations: [{ id: "chat-a", title: "chat-a", createdAt: 1, updatedAt: 1, deAiMode: false }],
     messages: [{ id: "m1", role: "user", content: "hi", timestamp: 1, conversationId: "chat-a" }],
@@ -69,6 +76,7 @@ describe("resetProjectState", () => {
     await resetProjectState()
 
     expect(sessionStorage.getItem("lk-last-chapter-path")).toBeNull()
+    expect(contextHubMocks.disposeAllContextHubs).toHaveBeenCalledOnce()
     vi.unstubAllGlobals()
   })
 })

+ 2 - 0
src/lib/reset-project-state.ts

@@ -14,6 +14,7 @@ import { useChatStore } from "@/stores/chat-store"
 import { useFavoriteSkillStore } from "@/stores/favorite-skill-store"
 import { useOutlineChatStore } from "@/stores/outline-chat-store"
 import { useReviewStore } from "@/stores/review-store"
+import { disposeAllContextHubs } from "@/lib/context-hub/context-hub"
 
 export function resetProjectStores(): void {
   useChatStore.setState({
@@ -51,6 +52,7 @@ export function resetProjectStores(): void {
 }
 
 export async function resetProjectState(): Promise<void> {
+  disposeAllContextHubs()
   resetProjectStores()
 
   // View-switch restore key is process-global; clear so the next project cannot

+ 5 - 2
src/stores/outline-chat-store.spec.ts

@@ -38,7 +38,7 @@ beforeEach(() => {
 afterEach(() => { vi.clearAllTimers(); vi.useRealTimers() })
 
 describe("outline-chat-store", () => {
-  it("加载时把旧字符串上下文摘要迁移为带依赖的结构", async () => {
+  it("加载时保留旧字符串摘要并自动写回瘦身结构", async () => {
     useWikiStore.setState({ project: { id: "p", name: "Novel", path: "E:/Novel" } })
     fsMocks.readFile.mockResolvedValue(JSON.stringify({
       conversations: [{ ...conversation("legacy-summary"), contextSummary: "旧大纲摘要" }],
@@ -49,9 +49,12 @@ describe("outline-chat-store", () => {
 
     expect(useOutlineChatStore.getState().conversations[0].contextSummary).toEqual({
       text: "旧大纲摘要",
-      dependencies: {},
       updatedAt: 0,
     })
+    expect(fsMocks.writeFile).toHaveBeenCalledWith(
+      "E:/Novel/.qmai/outline-chats.json",
+      expect.not.stringContaining("dependencies"),
+    )
   })
 
   it("按会话隔离流式内容,并支持追加、读取和单独清理", () => {

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

@@ -4,7 +4,10 @@ import { normalizePath } from "@/lib/path-utils"
 import type { AgentRunRecord } from "@/lib/agent/types"
 import type { ReferenceToken } from "@/lib/reference/types"
 import type { ContextHubSnapshotRef, SessionContextSummary } from "@/lib/context-hub/types"
-import { normalizeSessionContextSummary } from "@/lib/context-hub/session-summary"
+import {
+  isLegacySessionContextSummary,
+  normalizeSessionContextSummary,
+} from "@/lib/context-hub/session-summary"
 import { useWikiStore } from "@/stores/wiki-store"
 import type { IntentClarityResult } from "@/lib/novel/outline-intent-clarity"
 import type { NextStepRecommendation } from "@/lib/novel/outline-next-step"
@@ -391,6 +394,9 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => {
         activeConversationId: string | null
         runStates?: ConversationRunStates
       }
+      const hasLegacyContextSummary = (data.conversations ?? []).some((conversation) => (
+        isLegacySessionContextSummary(conversation.contextSummary)
+      ))
       const conversations = (data.conversations ?? []).map((conversation) => ({
         ...conversation,
         contextSummary: normalizeSessionContextSummary(conversation.contextSummary),
@@ -430,6 +436,9 @@ export const useOutlineChatStore = create<OutlineChatState>((set, get) => {
         pendingReferenceTokens: [],
         loaded: true,
       })
+      if (hasLegacyContextSummary && generation === loadGeneration && getStoragePath() === path) {
+        await doSave(path, { conversations, activeConversationId, runStates })
+      }
     } catch {
       if (generation !== loadGeneration || getStoragePath() !== path) return
       set({

+ 1 - 0
src/test/chat-panel-mount.ts

@@ -132,6 +132,7 @@ vi.mock("@/commands/fs", () => ({
   deleteFile: vi.fn(async () => {}),
   readFile: vi.fn(async () => ""),
   fileExists: vi.fn(async () => false),
+  getFileSize: vi.fn(async () => 0),
   writeFileAtomic: vi.fn(async () => {}),
   listDirectory: vi.fn(async () => []),
 }))

+ 2 - 2
vite.config.ts

@@ -8,14 +8,14 @@ const host = process.env.TAURI_DEV_HOST
 
 // Read version from package.json at config-load time so the Settings
 // UI can show the running app version without duplicating the string.
-const pkgJson = JSON.parse(readFileSync(path.resolve(__dirname, "package.json"), "utf-8"))
+const pkgJson = JSON.parse(readFileSync(path.resolve(import.meta.dirname, "package.json"), "utf-8"))
 
 // https://vitejs.dev/config/
 export default defineConfig(async () => ({
   plugins: [react(), tailwindcss()],
 
   resolve: {
-    alias: { "@": path.resolve(__dirname, "./src") },
+    alias: { "@": path.resolve(import.meta.dirname, "./src") },
   },
 
   define: {