Forráskód Böngészése

fix: 修复章节工作流与项目数据可靠性问题

Mochocyang 2 hónapja
szülő
commit
e732d0b0ac
49 módosított fájl, 3902 hozzáadás és 495 törlés
  1. 9 0
      .gitignore
  2. 2 2
      package-lock.json
  3. 1 1
      package.json
  4. 1 1
      src-tauri/Cargo.lock
  5. 1 1
      src-tauri/Cargo.toml
  6. 57 0
      src-tauri/src/commands/fs.rs
  7. 1 0
      src-tauri/src/lib.rs
  8. 1 0
      src-tauri/src/main.rs
  9. 1 1
      src-tauri/tauri.conf.json
  10. 21 0
      src/commands/fs.spec.ts
  11. 6 0
      src/commands/fs.ts
  12. 57 0
      src/components/layout/chat-layout.test.ts
  13. 16 4
      src/components/layout/content-area.tsx
  14. 4 4
      src/components/layout/knowledge-tree.tsx
  15. 12 7
      src/components/layout/preview-panel.test.tsx
  16. 73 43
      src/components/layout/preview-panel.tsx
  17. 14 0
      src/components/layout/sidebar-panel.skill-library.spec.ts
  18. 44 10
      src/components/layout/sidebar-panel.tsx
  19. 1 1
      src/components/layout/workspace-top-bars.spec.ts
  20. 14 4
      src/components/novel/de-ai-batch-entry.spec.ts
  21. 10 0
      src/components/novel/de-ai-batch-review-dialog.spec.tsx
  22. 13 0
      src/components/novel/de-ai-batch-review-dialog.tsx
  23. 10 7
      src/components/sources/outline-workbench-integration.spec.ts
  24. 3 0
      src/i18n/en.json
  25. 288 0
      src/i18n/i18n-parity.test.ts
  26. 3 0
      src/i18n/zh.json
  27. 37 17
      src/lib/agent/tools/run-chapter-workflow.ts
  28. 62 0
      src/lib/bundle-boundaries.spec.ts
  29. 15 2
      src/lib/changelog.spec.ts
  30. 54 0
      src/lib/changelog.ts
  31. 5 1
      src/lib/export-center/export-service.spec.ts
  32. 207 0
      src/lib/hardcoded-ui-chinese-i18n.test.ts
  33. 11 1
      src/lib/novel/character-aura.spec.ts
  34. 1 1
      src/lib/novel/character-aura.ts
  35. 1 1
      src/lib/novel/context-engine.ts
  36. 65 0
      src/lib/novel/de-ai-draft.spec.ts
  37. 55 0
      src/lib/novel/de-ai-draft.ts
  38. 321 24
      src/lib/novel/deep-chapter-generation.spec.ts
  39. 1460 296
      src/lib/novel/deep-chapter-generation.ts
  40. 1 1
      src/lib/novel/outline-generation.spec.ts
  41. 89 2
      src/lib/novel/review-adapter.spec.ts
  42. 37 5
      src/lib/novel/review-adapter.ts
  43. 53 0
      src/lib/persist.spec.ts
  44. 17 19
      src/lib/persist.ts
  45. 362 0
      src/lib/project-store.integration.test.ts
  46. 131 0
      src/stores/de-ai-task-store.spec.ts
  47. 102 29
      src/stores/de-ai-task-store.ts
  48. 138 0
      src/test-helpers/fs-temp.ts
  49. 15 10
      vite.config.ts

+ 9 - 0
.gitignore

@@ -29,6 +29,7 @@ tests/
 src/test-helpers/*
 !src/test-helpers/
 !src/test-helpers/load-test-env.ts
+!src/test-helpers/fs-temp.ts
 **/*.test.ts
 **/*.test.tsx
 !src/lib/chat-copy-content.test.ts
@@ -40,6 +41,10 @@ src/test-helpers/*
 !src/lib/chat-request-budget.test.ts
 !src/lib/context-budget.test.ts
 !src/lib/ingest.prompt.test.ts
+!src/components/layout/chat-layout.test.ts
+!src/i18n/i18n-parity.test.ts
+!src/lib/hardcoded-ui-chinese-i18n.test.ts
+!src/lib/project-store.integration.test.ts
 
 # === 根目录文档(仅本地保留)===
 /docs/
@@ -81,9 +86,13 @@ De-AI-Prompt-Enhancer-Writer-Booster-SKILL-main.zip
 .codex-*.log
 .trae/
 .vscode/
+/.zed/
 
 # === 杂项 ===
 /1.txt
+/nul
+/xiufuzonghebug-分支说明.md
+/zed-design-standard.html
 
 # === 开发调试产物(勿提交根目录)===
 /test-mocks-*-report.json

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "qmai",
-  "version": "2.2.35",
+  "version": "2.2.36",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "qmai",
-      "version": "2.2.35",
+      "version": "2.2.36",
       "license": "GPL-3.0-or-later",
       "dependencies": {
         "@base-ui/react": "^1.6.0",

+ 1 - 1
package.json

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

+ 1 - 1
src-tauri/Cargo.lock

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

+ 1 - 1
src-tauri/Cargo.toml

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

+ 57 - 0
src-tauri/src/commands/fs.rs

@@ -1134,6 +1134,42 @@ pub async fn write_file(path: String, contents: String) -> Result<(), String> {
         .map_err(|e| format!("write_file blocking task join error: {e}"))?
 }
 
+/// Create a file only when the destination does not already exist.
+pub fn do_write_file_if_absent(path: &str, contents: &str) -> Result<bool, String> {
+    run_guarded("write_file_if_absent", || {
+        let path = resolve_project_storage_path(path);
+        let p = Path::new(&path);
+        if let Some(parent) = p.parent() {
+            fs::create_dir_all(parent)
+                .map_err(|e| format!("Failed to create parent dirs for '{}': {}", path, e))?;
+        }
+
+        let mut file = match fs::OpenOptions::new()
+            .write(true)
+            .create_new(true)
+            .open(p)
+        {
+            Ok(file) => file,
+            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => return Ok(false),
+            Err(error) => return Err(format!("Failed to create file '{}': {}", path, error)),
+        };
+        file_sync::mark_app_write_path(p);
+        file.write_all(contents.as_bytes())
+            .map_err(|e| format!("Failed to write file '{}': {}", path, e))?;
+        file.sync_all()
+            .map_err(|e| format!("Failed to sync file '{}': {}", path, e))?;
+        file_sync::mark_app_write_path(p);
+        Ok(true)
+    })
+}
+
+#[tauri::command]
+pub async fn write_file_if_absent(path: String, contents: String) -> Result<bool, String> {
+    tauri::async_runtime::spawn_blocking(move || do_write_file_if_absent(&path, &contents))
+        .await
+        .map_err(|e| format!("write_file_if_absent blocking task join error: {e}"))?
+}
+
 /// Atomically replace the destination with a sibling temporary file.
 #[cfg(windows)]
 fn replace_export_file_atomically(temp: &Path, destination: &Path) -> Result<(), String> {
@@ -1919,6 +1955,27 @@ mod tests {
         let _ = fs::remove_dir_all(root);
     }
 
+    #[test]
+    fn write_file_if_absent_never_overwrites_an_existing_file() {
+        let root = std::env::temp_dir().join(format!(
+            "qmai-write-if-absent-{}",
+            std::time::SystemTime::now()
+                .duration_since(std::time::UNIX_EPOCH)
+                .unwrap()
+                .as_nanos()
+        ));
+        fs::create_dir_all(&root).unwrap();
+        let path = root.join("draft.md");
+
+        assert!(do_write_file_if_absent(&path.to_string_lossy(), "first").unwrap());
+        assert!(
+            !do_write_file_if_absent(&path.to_string_lossy(), "second").unwrap()
+        );
+        assert_eq!(fs::read_to_string(&path).unwrap(), "first");
+
+        let _ = fs::remove_dir_all(root);
+    }
+
     /// Write `bytes` to a fresh tmp path with `.pdf` suffix and return
     /// the path (the OS tmpdir is NOT cleaned up — acceptable for tests).
     fn tmp_pdf_with_bytes(bytes: &[u8]) -> String {

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

@@ -47,6 +47,7 @@ pub fn run() {
         .invoke_handler(tauri::generate_handler![
             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,

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

@@ -60,6 +60,7 @@ fn main() {
         .invoke_handler(tauri::generate_handler![
             commands::fs::read_file,
             commands::fs::write_file,
+            commands::fs::write_file_if_absent,
             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": "2.2.35",
+  "version": "2.2.36",
   "identifier": "com.qingmuai.writer",
   "build": {
     "beforeDevCommand": "npm run dev",

+ 21 - 0
src/commands/fs.spec.ts

@@ -9,6 +9,7 @@ import {
   subscribeProjectFileMutations,
   writeFile,
   writeFileAtomic,
+  writeFileIfAbsent,
 } from "./fs"
 
 describe("project file mutation notifications", () => {
@@ -42,4 +43,24 @@ describe("project file mutation notifications", () => {
     expect(listener).not.toHaveBeenCalled()
     unsubscribe()
   })
+
+  it("notifies only when an absent file is actually created", async () => {
+    const listener = vi.fn()
+    const unsubscribe = subscribeProjectFileMutations(listener)
+    invokeMock.mockResolvedValueOnce(false).mockResolvedValueOnce(true)
+
+    await expect(writeFileIfAbsent("E:/Novel/wiki/chapters/draft.md", "一")).resolves.toBe(false)
+    await expect(writeFileIfAbsent("E:/Novel/wiki/chapters/draft-2.md", "二")).resolves.toBe(true)
+
+    expect(invokeMock).toHaveBeenNthCalledWith(1, "write_file_if_absent", {
+      path: "E:/Novel/wiki/chapters/draft.md",
+      contents: "一",
+    })
+    expect(listener).toHaveBeenCalledOnce()
+    expect(listener).toHaveBeenCalledWith({
+      type: "write",
+      path: "E:/Novel/wiki/chapters/draft-2.md",
+    })
+    unsubscribe()
+  })
 })

+ 6 - 0
src/commands/fs.ts

@@ -45,6 +45,12 @@ export async function writeFileAtomic(path: string, contents: string): Promise<v
   notifyProjectFileMutation({ type: "write", path })
 }
 
+export async function writeFileIfAbsent(path: string, contents: string): Promise<boolean> {
+  const created = await invoke<boolean>("write_file_if_absent", { path, contents })
+  if (created) notifyProjectFileMutation({ type: "write", path })
+  return created
+}
+
 /**
  * List a directory tree. Dot-prefixed entries (`.claude`, `.env`,
  * `.qmai`, …) are hidden by default; pass `includeHidden: true`

+ 57 - 0
src/components/layout/chat-layout.test.ts

@@ -0,0 +1,57 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+import { getNextChatExpanded } from "./chat-layout"
+
+const chatPanelSource = readFileSync(resolve(__dirname, "..", "chat", "chat-panel.tsx"), "utf8")
+const previewPanelSource = readFileSync(resolve(__dirname, "preview-panel.tsx"), "utf8")
+
+describe("chat layout", () => {
+  it("左侧主导航 AI会话按钮可以切换打开和关闭", () => {
+    expect(getNextChatExpanded(false)).toBe(true)
+    expect(getNextChatExpanded(true)).toBe(false)
+  })
+
+  it("预览面板源碼包含 handleDeAiSaveDraft 表明另存草稿存在", () => {
+    expect(previewPanelSource).toContain("handleDeAiSaveDraft")
+  })
+
+  it("发送消息时接入去AI味默认模式", () => {
+    expect(chatPanelSource).toContain("injectDeAiDirective")
+    expect(chatPanelSource).toContain("deAiMode")
+  })
+
+  it("章节生成类对话会接入 QM-QUAI skill", () => {
+    expect(chatPanelSource).toContain("buildQmQuaiSystemPrompt")
+    expect(chatPanelSource).toContain("routeTask")
+    expect(chatPanelSource).toContain("effectiveTaskRoute")
+    expect(chatPanelSource).toContain('effectiveTaskRoute.intent === "write_chapter"')
+    expect(chatPanelSource).toContain('effectiveTaskRoute.intent === "continue_chapter"')
+    expect(chatPanelSource).toContain('effectiveTaskRoute.intent === "rewrite_chapter"')
+  })
+
+  it("小说写作不再弹出角色灵魂确认窗口", () => {
+    expect(chatPanelSource).not.toContain("pendingSoulDialog")
+    expect(chatPanelSource).not.toContain("本次写作将注入角色灵魂上下文")
+    expect(chatPanelSource).not.toContain("requestSoulDialog")
+    expect(chatPanelSource).toContain("pendingChapterPlan")
+    expect(chatPanelSource).toContain("ChapterPlanConfirmDialog")
+    expect(chatPanelSource).not.toContain("window.confirm")
+  })
+
+  it("预览面板中去AI味按钮出现在AI会话按钮后面", () => {
+    const aiSessionIdx = previewPanelSource.indexOf("AI会话")
+    const deAiIdx = previewPanelSource.lastIndexOf("去AI味")
+    expect(aiSessionIdx).toBeGreaterThan(-1)
+    expect(deAiIdx).toBeGreaterThan(-1)
+    expect(deAiIdx).toBeGreaterThan(aiSessionIdx)
+  })
+
+  it("去AI味按钮点击后打开 Skill 选择,不再显示旧下拉菜单项", () => {
+    expect(previewPanelSource).toContain("openDeAiSkillPicker")
+    expect(previewPanelSource).toContain("openDeAiSkillPicker(null, e.currentTarget)")
+    expect(previewPanelSource).toContain("选择去AI味技能")
+    expect(previewPanelSource).not.toContain("处理当前内容")
+    expect(previewPanelSource).not.toContain("设为默认")
+  })
+})

+ 16 - 4
src/components/layout/content-area.tsx

@@ -1,8 +1,16 @@
 import { Suspense, lazy } from "react";
 import { useWikiStore } from "@/stores/wiki-store";
 import { WritingWorkspace } from "./writing-workspace";
-import { SearchView } from "@/components/search/search-view";
-import { UnifiedSkillLibraryView } from "@/components/skill-library/unified-skill-library-view";
+
+const SearchView = lazy(async () => {
+  const mod = await import("@/components/search/search-view");
+  return { default: mod.SearchView };
+});
+
+const UnifiedSkillLibraryView = lazy(async () => {
+  const mod = await import("@/components/skill-library/unified-skill-library-view");
+  return { default: mod.UnifiedSkillLibraryView };
+});
 
 const AIChatTabContainer = lazy(async () => {
   const mod = await import("@/components/chat/ai-chat-tab-container");
@@ -58,7 +66,7 @@ const StorySimulationView = lazy(async () => {
 function LoadingView() {
   return (
     <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
-      Loading...
+      加载中...
     </div>
   );
 }
@@ -88,7 +96,11 @@ export function ContentArea() {
         );
         break;
       case "search":
-        content = <SearchView />;
+        content = (
+          <Suspense fallback={<LoadingView />}>
+            <SearchView />
+          </Suspense>
+        );
         break;
       case "soul":
         content = (

+ 4 - 4
src/components/layout/knowledge-tree.tsx

@@ -931,7 +931,7 @@ export function KnowledgeTree({
 
     const targetFolderPath = `${getDirName(renamingFolderPath)}/${makeSafeFileSlug(newName, "folder")}`
     if (targetFolderPath !== renamingFolderPath && await fileExists(targetFolderPath)) {
-      window.alert("目标文件夹已存在,请换一个名称。")
+      window.alert(t("knowledgeTree.renameTargetFolderExists"))
       return
     }
 
@@ -945,7 +945,7 @@ export function KnowledgeTree({
         const destPath = `${targetFolderPath}/${relativePath}`
         await createDirectory(getDirName(destPath)).catch(() => {})
         if (await fileExists(destPath)) {
-          window.alert("目标文件已存在,请先改名或选择其他文件夹。")
+          window.alert(t("knowledgeTree.renameTargetFileExists"))
           return
         }
         await copyFile(sourcePath, destPath)
@@ -963,13 +963,13 @@ export function KnowledgeTree({
       }
     } catch (error) {
       console.error("[KnowledgeTree] folder rename failed:", error)
-      window.alert("重命名文件夹失败,请稍后重试。")
+      window.alert(t("knowledgeTree.renameFolderFailed"))
     } finally {
       setRenamingFolderBusy(false)
       setRenamingFolderPath(null)
       setRenameFolderValue("")
     }
-  }, [project, renamingFolderPath, renamingFolderBusy, renameFolderValue, sectionNodes, refreshCurrentTree, selectedFile, setSelectedFile, onRemovePendingPage])
+  }, [project, renamingFolderPath, renamingFolderBusy, renameFolderValue, sectionNodes, refreshCurrentTree, selectedFile, setSelectedFile, onRemovePendingPage, t])
 
   const cancelRenameFolder = useCallback(() => {
     if (renamingFolderBusy) return

+ 12 - 7
src/components/layout/preview-panel.test.tsx

@@ -34,15 +34,15 @@ describe("preview-panel final chapter save state", () => {
   })
 
   it("shows the actual skill used by de-AI actions", () => {
-    expect(source).toContain("deAiSkillName")
+    expect(source).toContain("chapterDeAiSkillName")
     expect(source).toContain("selectionTransformSkillName")
+    expect(source).toContain("当前去AI味 Skill")
     expect(source).toContain("本次使用 Skill")
-    expect(source).toContain("去AI味处理中,使用 Skill")
   })
 
   it("keeps the chapter de-AI toolbar button label short while preserving the skill picker state", () => {
     expect(source).toContain("chapterDeAiOptions.effectiveName")
-    expect(source).toContain('const chapterDeAiButtonLabel = deAiProcessing ? "处理中" : "去AI味"')
+    expect(source).toContain('const chapterDeAiButtonLabel = currentChapterDeAiProcessing ? "处理中" : "去AI味"')
     expect(source).not.toContain("`去AI味:${chapterDeAiSkillName}`")
     expect(source).toContain("currentSkillId={chapterDeAiOptions.currentSkillId}")
     expect(source).toContain("defaultSkillId={chapterDeAiOptions.defaultSkillId}")
@@ -73,10 +73,10 @@ describe("preview-panel final chapter save state", () => {
     expect(source).toContain("}, [project?.path])")
   })
 
-  it("keeps the remember-skill warning visible during de-AI processing", () => {
-    expect(source).toContain("deAiSkillMemoryWarning")
-    expect(source).toContain("formatDeAiStatus")
-    expect(source).toContain("setDeAiSkillMemoryWarning(\"未能记住本次去AI味 Skill 选择,本次处理仍会继续\")")
+  it("reports remember-skill failures without replacing de-AI processing state", () => {
+    expect(source).toContain('toast.error("未能记住本次去AI味 Skill 选择,本次处理仍会继续")')
+    expect(source).not.toContain("deAiSkillMemoryWarning")
+    expect(source).not.toContain("setDeAiSkillMemoryWarning")
   })
 
   it("closes the chapter de-AI skill picker when clicking outside it", () => {
@@ -136,6 +136,11 @@ describe("preview-panel final chapter save state", () => {
     expect(source).not.toContain('t("novel.chapter.archiveDraft")')
   })
 
+  it("does not apply a saved draft tree after the active project changes", () => {
+    expect(source).toContain("const draftProjectId = project.id")
+    expect(source).toContain("useWikiStore.getState().project?.id === draftProjectId")
+  })
+
   it("does not mount a newly selected markdown file before it loads", () => {
     expect(source).toContain("loadedFilePath")
     expect(source).toContain("loadedFilePath !== selectedFile")

+ 73 - 43
src/components/layout/preview-panel.tsx

@@ -5,7 +5,7 @@ import { useWikiStore } from "@/stores/wiki-store"
 import { resolveDefaultModel, resolveNovelModel, formatResolvedModelLabel } from "@/lib/novel/model-resolver"
 import type { FinalChapterSavePhase } from "@/stores/wiki-store"
 import { useReviewStore } from "@/stores/review-store"
-import { deleteFile, fileExists, readFile, writeFile, writeFileAtomic, listDirectory } from "@/commands/fs"
+import { deleteFile, fileExists, readFile, writeFileAtomic, writeFileIfAbsent, listDirectory } from "@/commands/fs"
 import { normalizePath } from "@/lib/path-utils"
 import { getFileCategory, isBinary } from "@/lib/file-types"
 import { WikiEditor, type WikiEditorHandle } from "@/components/editor/wiki-editor"
@@ -54,11 +54,12 @@ import { shouldApplyDiskToEditor } from "@/lib/editor-disk-sync"
 import { registerEditorDiskSyncHandler } from "@/lib/editor-disk-sync-session"
 import { registerEditorExternalUpdateHandler } from "@/lib/editor-external-update-session"
 import { createChapterExternalUpdateCoordinator } from "@/lib/chapter-external-update-coordinator"
-import { applyOpenChapterBodyUpdate } from "@/lib/novel/de-ai-batch/chapter-apply"
+import { applyOpenChapterBodyUpdate, createDeAiBatchChapterApplier } from "@/lib/novel/de-ai-batch/chapter-apply"
 import { toast } from "@/lib/toast"
-import { useDeAiTaskStore } from "@/stores/de-ai-task-store"
+import { selectProjectDeAiReview, selectProjectDeAiTasks, useDeAiTaskStore } from "@/stores/de-ai-task-store"
 import { DeAiBatchReviewDialog } from "@/components/novel/de-ai-batch-review-dialog"
 import type { DeAiBatchChapter, DeAiBatchTaskRecord } from "@/lib/novel/de-ai-batch/types"
+import { saveDeAiDraftWithoutOverwrite } from "@/lib/novel/de-ai-draft"
 
 const SnapshotViewer = lazy(async () => {
   const mod = await import("@/components/novel/snapshot-viewer")
@@ -187,6 +188,7 @@ export function PreviewPanel() {
   const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
   const saveGenerationRef = useRef(0)
   const chapterExternalUpdateCoordinator = useMemo(() => createChapterExternalUpdateCoordinator(), [])
+  const applyDeAiBatchChapter = useMemo(() => createDeAiBatchChapterApplier(), [])
   const wikiEditorRef = useRef<WikiEditorHandle>(null)
   const [isSavingFinal, setIsSavingFinal] = useState(false)
   const [saveStatus, setSaveStatus] = useState<string>("")
@@ -198,10 +200,12 @@ export function PreviewPanel() {
   const currentChapterDeAiProcessing = useDeAiTaskStore((s) =>
     selectedFile ? s.isChapterProcessing(selectedFile) : false
   )
-  const deAiReviewOpen = useDeAiTaskStore((s) => s.reviewOpen)
-  const deAiReviewChapterId = useDeAiTaskStore((s) => s.reviewChapterId)
+  const deAiReviewOpen = useDeAiTaskStore((s) => selectProjectDeAiReview(s, project?.path).open)
+  const deAiReviewChapterId = useDeAiTaskStore((s) => selectProjectDeAiReview(s, project?.path).chapterId)
   const deAiTasks = useDeAiTaskStore((s) => s.tasks)
   const [selectionTransformOpen, setSelectionTransformOpen] = useState(false)
+  const [deAiDraftSaving, setDeAiDraftSaving] = useState(false)
+  const deAiDraftSavingRef = useRef(false)
   const [selectionTransformAction, setSelectionTransformAction] = useState<ChapterSelectionAction | null>(null)
   const [selectionTransformSelection, setSelectionTransformSelection] = useState<ChapterBodySelection | null>(null)
   const [selectionTransformSourceContent, setSelectionTransformSourceContent] = useState("")
@@ -1031,6 +1035,7 @@ export function PreviewPanel() {
       chapterTitle,
       skillId: chapterDeAiOptions.currentSkillId ?? null,
       skillName,
+      skillContent,
       modelName: modelLabel,
       sourceContent: source,
     })
@@ -1180,6 +1185,41 @@ export function PreviewPanel() {
     await runWholeChapterDeAi(skill.content, skill.name)
   }, [bumpDataVersion, chapterDeAiOptions.skills, pendingSelectionForDeAi, project, runSelectionTransform, runWholeChapterDeAi])
 
+  const handleDeAiSaveDraft = useCallback(async (chapterPath: string, candidateContent: string) => {
+    if (!project || !candidateContent || deAiDraftSavingRef.current) return
+    const draftProjectId = project.id
+    const draftProjectPath = normalizePath(project.path)
+    deAiDraftSavingRef.current = true
+    setDeAiDraftSaving(true)
+    try {
+      const draftPath = await saveDeAiDraftWithoutOverwrite(
+        chapterPath,
+        candidateContent,
+        writeFileIfAbsent,
+      )
+
+      if (useWikiStore.getState().project?.id === draftProjectId) {
+        try {
+          const tree = await listDirectory(draftProjectPath)
+          if (useWikiStore.getState().project?.id === draftProjectId) {
+            setFileTree(tree)
+          }
+        } catch (err) {
+          console.error("另存去AI味草稿后刷新文件树失败:", err)
+        }
+        bumpDataVersion()
+      }
+
+      toast.success(`已另存草稿:${draftPath.split("/").pop() ?? draftPath}`)
+    } catch (err) {
+      console.error("另存去AI味草稿失败:", err)
+      toast.error(`另存草稿失败:${err instanceof Error ? err.message : String(err)}`)
+    } finally {
+      deAiDraftSavingRef.current = false
+      setDeAiDraftSaving(false)
+    }
+  }, [bumpDataVersion, project, setFileTree])
+
   const handleSelectionAction = useCallback((action: ChapterSelectionAction, selection: ChapterBodySelection) => {
     if (action === "de-ai") {
       void openDeAiSkillPicker(selection)
@@ -1675,10 +1715,14 @@ export function PreviewPanel() {
         </div>
       ) : null}
       {(() => {
-        const readyTasks = deAiTasks.filter(
+        const projectDeAiTasks = selectProjectDeAiTasks(deAiTasks, project?.path)
+        const readyTasks = projectDeAiTasks.filter(
           (t) => t.status === "ready" || t.status === "confirmed" || t.status === "cancelled"
         )
-        if (readyTasks.length === 0 && !deAiReviewOpen) return null
+        if (!project || readyTasks.length === 0) return null
+        const currentReviewChapterId = readyTasks.some((task) => task.id === deAiReviewChapterId)
+          ? deAiReviewChapterId
+          : readyTasks[0]?.id ?? null
         const now = Date.now()
         const reviewChapters = readyTasks.map((t, index) => ({
           version: 1 as const,
@@ -1718,46 +1762,32 @@ export function PreviewPanel() {
           chapters: reviewChapters as unknown as DeAiBatchChapter[],
         }
         return (
-          <DeAiBatchReviewDialog
-            open={deAiReviewOpen}
-            record={reviewRecord}
-            currentChapterId={deAiReviewChapterId}
-            onSelectChapter={(id) => useDeAiTaskStore.getState().setReviewChapter(id)}
-            onConfirm={async (_taskId, chapterId) => {
-              const task = deAiTasks.find((t) => t.id === chapterId)
-              if (!task || !project) return
+           <DeAiBatchReviewDialog
+             open={deAiReviewOpen && readyTasks.length > 0}
+             record={reviewRecord}
+             currentChapterId={currentReviewChapterId}
+             pending={deAiDraftSaving}
+            onSelectChapter={(id) => useDeAiTaskStore.getState().setReviewChapter(project.path, id)}
+             onConfirm={async (_taskId, chapterId) => {
+              const task = readyTasks.find((t) => t.id === chapterId)
+              if (!task) return
               try {
-                await applyOpenChapterBodyUpdate({
-                  path: task.chapterPath,
-                  candidateContent: task.candidateContent,
-                  currentOpenPath: () => selectedFile,
-                  currentMarkdown: () => wikiEditorRef.current?.getCurrentMarkdown() ?? fileContentRef.current,
-                  invalidatePendingSave: () => { /* handled by runExternalUpdate */ },
-                  runExternalUpdate: async (_path, write) => {
-                    await write()
-                    return Date.now()
-                  },
-                  markEditorSession: () => { /* no-op */ },
-                  writeFileAtomic: async (filePath, content) => {
-                    await writeFile(filePath, content)
-                  },
-                  commitEditor: (content) => {
-                    setFileContent(content)
-                  },
-                  bumpDataVersion: () => {
-                    bumpDataVersion()
-                  },
-                })
+                await applyDeAiBatchChapter(task.chapterPath, task.candidateContent)
                 useDeAiTaskStore.getState().confirmTask(chapterId)
                 toast.success(`${task.chapterTitle} 去AI味结果已保存`)
               } catch (err) {
                 console.error("保存去AI味结果失败:", err)
                 toast.error(`保存失败:${err instanceof Error ? err.message : String(err)}`)
-              }
-            }}
-            onRegenerate={async (_taskId, chapterId) => {
-              const task = deAiTasks.find((t) => t.id === chapterId)
-              if (!task || !project) return
+               }
+             }}
+             onSaveDraft={async (_taskId, chapterId) => {
+               const task = readyTasks.find((item) => item.id === chapterId)
+               if (!task) return
+               await handleDeAiSaveDraft(task.chapterPath, task.candidateContent)
+             }}
+             onRegenerate={async (_taskId, chapterId) => {
+              const task = readyTasks.find((t) => t.id === chapterId)
+              if (!task) return
               useDeAiTaskStore.getState().updateTask(chapterId, {
                 status: "processing",
                 candidateContent: "",
@@ -1773,7 +1803,7 @@ export function PreviewPanel() {
               try {
                 await streamChat(
                   llmConfig,
-                  buildDeAiRewriteMessages(task.sourceContent, ""),
+                  buildDeAiRewriteMessages(task.sourceContent, task.skillContent),
                   {
                     onToken: (token) => { result += token },
                     onDone: () => {
@@ -1791,7 +1821,7 @@ export function PreviewPanel() {
             onCancelChapter={(_taskId, chapterId) => {
               useDeAiTaskStore.getState().cancelTask(chapterId)
             }}
-            onClose={() => useDeAiTaskStore.getState().closeReview()}
+            onClose={() => useDeAiTaskStore.getState().closeReview(project.path)}
           />
         )
       })()}

+ 14 - 0
src/components/layout/sidebar-panel.skill-library.spec.ts

@@ -0,0 +1,14 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+const source = readFileSync(resolve(__dirname, "sidebar-panel.tsx"), "utf8")
+
+describe("SidebarPanel skill library routing", () => {
+  it("uses the unified sidebar for both skill library views", () => {
+    expect(source).toContain("UnifiedSkillLibrarySidebarPanel")
+    expect(source).toContain('activeView === "skillLibrary" || activeView === "writingSkillLibrary"')
+    expect(source).toContain("<UnifiedSkillLibrarySidebarPanel />")
+    expect(source).not.toContain("<SkillLibrarySidebarPanel />")
+  })
+})

+ 44 - 10
src/components/layout/sidebar-panel.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState } from "react"
+import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react"
 import { useTranslation } from "react-i18next"
 import {
   BookOpenCheck,
@@ -20,11 +20,8 @@ import {
 import { KnowledgeTree, RawSourcesSection, type KnowledgeCreateRequest } from "./knowledge-tree"
 import { TrashPanel } from "./trash-panel"
 import { GraphSidebarPanel } from "./graph-sidebar-panel"
-import { SoulSidebarPanel } from "./soul-sidebar-panel"
 import { ReviewCenterSidebarPanel } from "./review-center-sidebar-panel"
-import { BookAnalysisSidebarPanel } from "./book-analysis-sidebar-panel"
 import { FrameworkList } from "@/components/novel/story-simulation/framework-list"
-import { SkillLibrarySidebarPanel } from "@/components/skill-library/skill-library-view"
 
 import { useWikiStore } from "@/stores/wiki-store"
 import { useChatStore } from "@/stores/chat-store"
@@ -70,6 +67,21 @@ import { useImportProgressStore } from "@/stores/import-progress-store"
 import { openExternalUrl } from "@/lib/open-external-url"
 import type { ReferenceToken } from "@/lib/reference/types"
 
+const SoulSidebarPanel = lazy(async () => {
+  const mod = await import("./soul-sidebar-panel")
+  return { default: mod.SoulSidebarPanel }
+})
+
+const BookAnalysisSidebarPanel = lazy(async () => {
+  const mod = await import("./book-analysis-sidebar-panel")
+  return { default: mod.BookAnalysisSidebarPanel }
+})
+
+const UnifiedSkillLibrarySidebarPanel = lazy(async () => {
+  const mod = await import("@/components/skill-library/unified-skill-library-view")
+  return { default: mod.UnifiedSkillLibrarySidebarPanel }
+})
+
 const USAGE_GUIDE_URL = "https://tcnk9ik08e1c.feishu.cn/wiki/FWiSwYQKoifpwBk6mSRcSlB8nrh?from=from_copylink"
 
 const DISMANTLING_NO_PREPROCESSING_NEEDED = "no preprocessing needed"
@@ -125,6 +137,16 @@ function SearchHistoryPanel() {
   )
 }
 
+function SidebarPanelLoading() {
+  const { t } = useTranslation()
+  return (
+    <div className="flex h-full items-center justify-center gap-2 text-xs text-muted-foreground">
+      <RefreshCw className="h-3.5 w-3.5 animate-spin" />
+      <span>{t("common.loading", { defaultValue: "加载中..." })}</span>
+    </div>
+  )
+}
+
 export function DismantlingSidebarPanel() {
   const project = useWikiStore((s) => s.project)
   const selectedDismantlingProjectId = useWikiStore((s) => s.selectedDismantlingProjectId)
@@ -1222,11 +1244,19 @@ export function SidebarPanel() {
   }
 
   if (activeView === "soul") {
-    return <SoulSidebarPanel />
+    return (
+      <Suspense fallback={<SidebarPanelLoading />}>
+        <SoulSidebarPanel />
+      </Suspense>
+    )
   }
 
-  if (activeView === "skillLibrary") {
-    return <SkillLibrarySidebarPanel />
+  if (activeView === "skillLibrary" || activeView === "writingSkillLibrary") {
+    return (
+      <Suspense fallback={<SidebarPanelLoading />}>
+        <UnifiedSkillLibrarySidebarPanel />
+      </Suspense>
+    )
   }
 
   if (activeView === "reviewCenter") {
@@ -1234,7 +1264,11 @@ export function SidebarPanel() {
   }
 
   if (activeView === "bookAnalysis") {
-    return <BookAnalysisSidebarPanel />
+    return (
+      <Suspense fallback={<SidebarPanelLoading />}>
+        <BookAnalysisSidebarPanel />
+      </Suspense>
+    )
   }
 
   if (activeView === "search") {
@@ -1332,9 +1366,9 @@ export function SidebarPanel() {
 
   return (
     <div className="flex h-full flex-col">
-      <div className="flex shrink-0 items-center justify-between border-b px-3 py-2">
+      <div className="flex h-12 shrink-0 items-center justify-between border-b px-3">
         <div className="min-w-0">
-          <div className="flex items-center gap-1.5 text-sm font-semibold">
+          <div className="flex min-w-0 items-center gap-1.5 text-sm font-semibold">
             <PanelHeaderWithHelp
               title={isChapter ? t("sidebar.knowledge") : t("sidebar.files")}
               helpKey={isChapter ? "chapter" : "outline"}

+ 1 - 1
src/components/layout/workspace-top-bars.spec.ts

@@ -11,7 +11,7 @@ describe("workspace top bars", () => {
   it("uses one fixed height for the chapter sidebar, editor toolbar, AI chat and outline chat headers", () => {
     expect(sidebarSource).toContain('className="flex h-12 shrink-0 items-center justify-between border-b px-3"')
     expect(previewSource).toContain('className="flex h-12 shrink-0 items-center border-b px-3"')
-    expect(chatSource).toContain('className="flex h-12 shrink-0 items-center border-b bg-muted/20 px-2"')
+    expect(chatSource).toContain('className="flex h-12 shrink-0 items-center gap-2 border-b bg-muted/20 px-2"')
     expect(outlineChatSource).toContain('className="flex h-12 shrink-0 items-center gap-2 border-b bg-muted/20 px-2"')
   })
 

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

@@ -8,12 +8,16 @@ const wikiStore = readFileSync("src/stores/wiki-store.ts", "utf8")
 const novelSection = readFileSync("src/components/settings/sections/novel-section.tsx", "utf8")
 
 describe("de-ai batch entry and settings", () => {
-  it("在主布局挂载批量工作区但保留现有单章预览入口", () => {
-    expect(appLayout).toContain("<DeAiBatchWorkspace")
-    expect(previewPanel).toContain("<DeAiPreviewDialog")
+  it("uses the chapter task queue without restoring the retired floating batch workspace", () => {
+    expect(appLayout).not.toContain("<DeAiBatchWorkspace")
+    expect(previewPanel).toContain("<DeAiBatchReviewDialog")
     expect(previewPanel).toContain("runWholeChapterDeAi")
     expect(previewPanel).toContain("registerEditorExternalUpdateHandler")
-    expect(previewPanel).toContain("applyOpenChapterBodyUpdate")
+    expect(previewPanel).toContain("createDeAiBatchChapterApplier")
+    expect(previewPanel).toContain("await applyDeAiBatchChapter(task.chapterPath, task.candidateContent)")
+    expect(previewPanel).toContain("saveDeAiDraftWithoutOverwrite")
+    expect(previewPanel).toContain("writeFileIfAbsent")
+    expect(previewPanel).toContain("deAiDraftSaving")
     expect(previewPanel).toContain("chapterExternalUpdateCoordinator.flushBeforeLeave(path")
     expect(batchWorkspace).toContain("resolveDeAiBatchModelKey")
     expect(batchWorkspace).not.toContain("`${llmConfig.provider}/${llmConfig.model}`")
@@ -27,6 +31,12 @@ describe("de-ai batch entry and settings", () => {
     expect(batchWorkspace).not.toMatch(/void useDeAiBatchStore\.getState\(\)\.(?:continueTask|cancelTask|confirmChapter|regenerateChapter|cancelChapter)/)
   })
 
+  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)")
+  })
+
   it("小说设置包含默认 3、范围 1–5 的批量并发设置", () => {
     expect(wikiStore).toContain("deAiBatchConcurrency: 3")
     expect(novelSection).toContain('min={1}')

+ 10 - 0
src/components/novel/de-ai-batch-review-dialog.spec.tsx

@@ -80,6 +80,7 @@ function callbacks() {
   return {
     onSelectChapter: vi.fn(),
     onConfirm: vi.fn(),
+    onSaveDraft: vi.fn(),
     onRegenerate: vi.fn(),
     onCancelChapter: vi.fn(),
     onClose: vi.fn(),
@@ -168,6 +169,14 @@ describe("DeAiBatchReviewDialog", () => {
     expect(props.onCancelChapter).toHaveBeenCalledWith("task-a", "chapter-1")
   })
 
+  it("候选结果可以另存草稿且只传当前章", () => {
+    const props = render()
+
+    act(() => button("另存草稿").click())
+
+    expect(props.onSaveDraft).toHaveBeenCalledWith("task-a", "chapter-1")
+  })
+
   it("生成中保留旧候选并禁用确认和重复重新生成", () => {
     render("chapter-2")
 
@@ -210,5 +219,6 @@ describe("DeAiBatchReviewDialog", () => {
     expect(content?.className).toContain("overflow-y-auto")
     expect(button("确认当前章").disabled).toBe(true)
     expect(button("重新生成").disabled).toBe(true)
+    expect(button("另存草稿").disabled).toBe(true)
     expect(button("取消当前章").disabled).toBe(true)
   })})

+ 13 - 0
src/components/novel/de-ai-batch-review-dialog.tsx

@@ -31,6 +31,7 @@ export interface DeAiBatchReviewDialogProps {
   pending?: boolean
   onSelectChapter(chapterId: string): void
   onConfirm(taskId: string, chapterId: string): void
+  onSaveDraft?(taskId: string, chapterId: string): void
   onRegenerate(taskId: string, chapterId: string): void
   onCancelChapter(taskId: string, chapterId: string): void
   onClose(): void
@@ -82,6 +83,7 @@ export function DeAiBatchReviewDialog({
   pending = false,
   onSelectChapter,
   onConfirm,
+  onSaveDraft,
   onRegenerate,
   onCancelChapter,
   onClose,
@@ -89,6 +91,7 @@ export function DeAiBatchReviewDialog({
   const [mobileTab, setMobileTab] = useState<"source" | "candidate">("source")
   const chapter = record?.chapters.find((item) => item.id === currentChapterId) ?? record?.chapters[0] ?? null
   const canConfirm = !pending && chapter?.status === "ready" && !!chapter.candidateContent
+  const canSaveDraft = !pending && !!onSaveDraft && !!chapter?.candidateContent && chapter.status !== "generating"
   const canRegenerate = !pending && !!chapter && chapter.status !== "generating" && chapter.status !== "confirmed"
   const canCancel = !pending && !!chapter && chapter.status !== "confirmed" && chapter.status !== "cancelled"
 
@@ -213,6 +216,16 @@ export function DeAiBatchReviewDialog({
           >
             重新生成
           </Button>
+          {onSaveDraft ? (
+            <Button
+              type="button"
+              variant="outline"
+              disabled={!canSaveDraft}
+              onClick={() => { if (record && chapter) onSaveDraft(record.task.id, chapter.id) }}
+            >
+              另存草稿
+            </Button>
+          ) : null}
           <Button
             type="button"
             disabled={!canConfirm}

+ 10 - 7
src/components/sources/outline-workbench-integration.spec.ts

@@ -15,9 +15,10 @@ describe("AI 大纲工作台页面接入", () => {
   })
 
   it("大纲文件树接入左侧大纲侧栏而不是主内容区", () => {
-    expect(sidebarPanelSource).toContain("OutlineFileTreePanel")
-    expect(sidebarPanelSource).toContain('activeView === "sources" && novelMode')
-    expect(sidebarPanelSource).toContain("<OutlineFileTreePanel")
+    expect(sidebarPanelSource).toContain('activeView === "sources"')
+    expect(sidebarPanelSource).toContain('setMode("files")')
+    expect(sidebarPanelSource).toContain("<KnowledgeTree")
+    expect(sidebarPanelSource).toContain('filterType={isChapter ? "chapter" : "outline"}')
     expect(readFileSync(resolve(__dirname, "outline-workbench.tsx"), "utf8")).not.toContain("OutlineFileTree")
     expect(readFileSync(resolve(__dirname, "outline-workbench.tsx"), "utf8")).not.toContain("outline-tree-pane")
   })
@@ -29,11 +30,13 @@ describe("AI 大纲工作台页面接入", () => {
     expect(toolbarSource).not.toContain('mode="refine"')
   })
 
-  it("移除旧的非对话式大纲生成组件和深度生成模块", () => {
+  it("移除旧的非对话式生成界面且不把独立生成模块接回工作台", () => {
     expect(existsSync(resolve(__dirname, "outline-generator-dialog.tsx"))).toBe(false)
-    expect(existsSync(resolve(__dirname, "../../lib/novel/outline-generation.ts"))).toBe(false)
-    expect(existsSync(resolve(__dirname, "../../lib/novel/deep-outline-generation.ts"))).toBe(false)
-    expect(promptTemplatesSource).not.toContain("outlineGeneration:")
+    expect(existsSync(resolve(__dirname, "../../lib/novel/outline-generation.ts"))).toBe(true)
+    expect(existsSync(resolve(__dirname, "../../lib/novel/deep-outline-generation.ts"))).toBe(true)
+    expect(sourcesViewSource).not.toContain("runDeepOutlineGeneration")
+    expect(toolbarSource).not.toContain("runDeepOutlineGeneration")
+    expect(promptTemplatesSource).toContain("outlineGeneration:")
     expect(promptTemplatesSource).not.toContain("outlineRefinementGeneration:")
   })
 })

+ 3 - 0
src/i18n/en.json

@@ -1161,6 +1161,9 @@
     "selectChaptersHint": "Select chapters to move them to a volume",
     "moveSelectedToVolume": "Move selected to volume",
     "rename": "Rename",
+    "renameFolderFailed": "Failed to rename folder. Please try again.",
+    "renameTargetFolderExists": "The target folder already exists. Choose another name.",
+    "renameTargetFileExists": "A target file already exists. Rename it or choose another folder.",
     "deleteFolder": "Delete folder",
     "deleteVolume": "Delete volume",
     "deleteFolderConfirm": "Move \"{{name}}\" and its {{count}} files to trash?",

+ 288 - 0
src/i18n/i18n-parity.test.ts

@@ -0,0 +1,288 @@
+/**
+ * Runtime translation-key coverage check for both bundles.
+ *
+ * The bundles still contain different sets of unused legacy keys, so this
+ * test checks every static reference that does not provide an explicit
+ * runtime fallback instead of requiring destructive full-file parity cleanup.
+ *
+ * This test is deliberately string-based rather than going through
+ * i18next's runtime — it should fail on the FILE contents before
+ * anyone notices in the UI.
+ */
+import { describe, it, expect } from "vitest"
+import { dirname, join } from "node:path"
+import { fileURLToPath } from "node:url"
+import { readFileSync, readdirSync, statSync } from "node:fs"
+import en from "./en.json"
+import zh from "./zh.json"
+
+/** Flattens a nested translation object to "a.b.c" dot-path keys. */
+function flattenKeys(obj: unknown, prefix = ""): string[] {
+  if (obj === null || typeof obj !== "object") return []
+  const out: string[] = []
+  for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
+    const path = prefix ? `${prefix}.${k}` : k
+    if (v !== null && typeof v === "object") {
+      out.push(...flattenKeys(v, path))
+    } else {
+      out.push(path)
+    }
+  }
+  return out
+}
+
+function collectReferencedI18nKeys(): string[] {
+  const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..")
+  const files: string[] = []
+
+  const visit = (dir: string) => {
+    for (const entry of readdirSync(dir)) {
+      const full = join(dir, entry)
+      const stat = statSync(full)
+      if (stat.isDirectory()) {
+        visit(full)
+        continue
+      }
+      if (full.endsWith(".ts") || full.endsWith(".tsx")) {
+        files.push(full)
+      }
+    }
+  }
+
+  visit(rootDir)
+
+  const keys = new Set<string>()
+  const patterns = [/\bt\(\s*["'`]([^"'`]+)["'`]/g, /i18n\.t\(\s*["'`]([^"'`]+)["'`]/g]
+
+  files.forEach((file) => {
+    const text = readFileSync(file, "utf8")
+    patterns.forEach((pattern) => {
+      for (const match of text.matchAll(pattern)) {
+        const key = match[1]
+        const snippet = text.slice(match.index ?? 0, (match.index ?? 0) + 240)
+        const hasExplicitFallback = snippet.includes("defaultValue")
+        if (key.includes(".") && !key.includes("${") && !hasExplicitFallback) {
+          keys.add(key)
+        }
+      }
+    })
+  })
+
+  return [...keys].sort()
+}
+
+function collectDynamicKeyWhitelist(): string[] {
+  return [
+    "novel.outline.type.story",
+    "novel.outline.type.volume",
+    "novel.outline.type.chapter",
+    "novel.outlineGenerator.genres.fantasy",
+    "novel.outlineGenerator.genres.xianxia",
+    "novel.outlineGenerator.genres.scifi",
+    "novel.outlineGenerator.genres.mystery",
+    "novel.outlineGenerator.genres.romance",
+    "novel.outlineGenerator.genres.historical",
+    "novel.outlineGenerator.genres.military",
+    "novel.outlineGenerator.genres.urban",
+    "novel.outlineGenerator.genres.general",
+    "novel.outlineGenerator.scales.short",
+    "novel.outlineGenerator.scales.medium",
+    "novel.outlineGenerator.scales.long",
+    "novel.outlineGenerator.scales.epic",
+    "novel.search.keyword",
+    "novel.search.vector",
+    "novel.search.graph",
+    "novel.search.recentChapters",
+    "novel.search.canon",
+    "settings.sections.sourceWatch.groups.documents",
+    "settings.sections.sourceWatch.groups.presentations",
+    "settings.sections.sourceWatch.groups.spreadsheets",
+    "settings.sections.sourceWatch.groups.web",
+    "settings.sections.sourceWatch.groups.images",
+    "settings.sections.sourceWatch.groups.media",
+    "settings.sections.sourceWatch.groups.code",
+    "settings.sections.sourceWatch.groups.data",
+    "review.results.severity.error",
+    "review.results.severity.warning",
+    "review.results.severity.info",
+    "review.results.dimension.character_consistency",
+    "review.results.dimension.timeline",
+    "review.results.dimension.foreshadowing",
+    "review.results.dimension.setting",
+    "review.results.dimension.plot",
+    "review.results.dimension.style",
+  ]
+}
+
+function collectScopedOrphanCandidates(): string[] {
+  return [
+    "review.notifications.created",
+    "review.fallbacks.stripActionPrefixes",
+    "review.fallbacks.openActionLabel",
+    "review.results.type.character_consistency",
+    "review.results.type.timeline",
+    "review.results.type.foreshadowing",
+    "review.results.type.setting",
+    "review.results.type.plot",
+    "review.results.type.style",
+    "novel.outlineGenerator.success",
+    "novel.outlineGenerator.ingestSuccess",
+  ]
+}
+
+describe("i18n referenced-key coverage (en.json and zh.json)", () => {
+  const i18nDir = dirname(fileURLToPath(import.meta.url))
+  const enKeys = new Set(flattenKeys(en))
+  const zhKeys = new Set(flattenKeys(zh))
+  const referencedKeys = collectReferencedI18nKeys()
+  const dynamicKeyWhitelist = collectDynamicKeyWhitelist()
+  const scopedOrphanCandidates = collectScopedOrphanCandidates()
+
+  it("does not contain duplicate top-level JSON keys", () => {
+    const findDuplicates = (fileName: string) => {
+      const text = readFileSync(join(i18nDir, fileName), "utf8")
+      const seen = new Set<string>()
+      const duplicates = new Set<string>()
+      for (const match of text.matchAll(/^  "([^"]+)":/gm)) {
+        const key = match[1]
+        if (seen.has(key)) duplicates.add(key)
+        seen.add(key)
+      }
+      return [...duplicates].sort()
+    }
+
+    expect(findDuplicates("en.json"), "duplicate top-level keys in en.json").toEqual([])
+    expect(findDuplicates("zh.json"), "duplicate top-level keys in zh.json").toEqual([])
+  })
+
+  it("every referenced key without an explicit fallback exists in en.json", () => {
+    const missing = referencedKeys.filter((k) => !enKeys.has(k))
+    expect(
+      missing,
+      `Referenced keys missing from en.json:\n  ${missing.join("\n  ")}`,
+    ).toEqual([])
+  })
+
+  it("every referenced key without an explicit fallback exists in zh.json", () => {
+    const missing = referencedKeys.filter((k) => !zhKeys.has(k))
+    expect(
+      missing,
+      `Referenced keys missing from zh.json:\n  ${missing.join("\n  ")}`,
+    ).toEqual([])
+  })
+
+  it("keeps B5 novel copy keys for context, review, and adjacent feedback UI", () => {
+    const requiredKeys = [
+      "novel.contextPack.title",
+      "novel.contextPack.currentTask",
+      "novel.contextPack.recentRevisionDirectives",
+      "novel.contextPack.mustDo.previousChapterEnding",
+      "novel.contextPack.mustAvoid.canonRules",
+      "novel.contextPack.nextChapterAdvice.searchResults",
+      "novel.contextPack.foreshadowing.repeatedUnresolved",
+      "novel.revisionFeedback.mustFix",
+      "novel.revisionFeedback.shouldImprove",
+      "novel.revisionFeedback.carryToNextChapter",
+      "novel.reviewPrompt.reviewChapterInstruction",
+      "novel.reviewPrompt.specialChecksTitle",
+      "review.notifications.savedToWiki",
+      "review.notifications.saveFailed",
+      "review.notifications.deleted",
+      "review.notifications.deleteFailed",
+      "review.notifications.createdPage",
+      "review.notifications.openedPage",
+      "review.notifications.createFailed",
+      "review.fallbacks.savedQueryTitle",
+      "review.fallbacks.untitled",
+      "review.fallbacks.stripTitlePrefixes",
+      "review.fallbacks.genericActionLabel",
+      "review.results.severity.error",
+      "review.results.severity.warning",
+      "review.results.severity.info",
+      "review.results.dimension.character_consistency",
+      "review.results.dimension.timeline",
+      "review.results.dimension.foreshadowing",
+      "review.results.dimension.setting",
+      "review.results.dimension.plot",
+      "review.results.dimension.style",
+      "lint.reviewFallbacks.skipActionLabel",
+      "lint.reviewFallbacks.semanticTitleFallback",
+      "lint.messages.fixQueuedForReview",
+      "lint.messages.semanticQueuedForReview",
+      "lint.messages.openedFromLint",
+      "lint.messages.deletedFromLint",
+      "lint.messages.fixFailed",
+      "lint.messages.deleteFailed",
+      "lint.messages.unableToLoad",
+    ]
+
+    requiredKeys.forEach((key) => {
+      expect(enKeys.has(key), `missing en key: ${key}`).toBe(true)
+      expect(zhKeys.has(key), `missing zh key: ${key}`).toBe(true)
+    })
+  })
+
+  it("keeps the approved dynamic-key whitelist present in both bundles", () => {
+    dynamicKeyWhitelist.forEach((key) => {
+      expect(enKeys.has(key), `missing en dynamic key: ${key}`).toBe(true)
+      expect(zhKeys.has(key), `missing zh dynamic key: ${key}`).toBe(true)
+    })
+  })
+
+  it("has no scoped orphan keys in the confirmed cleanup set", () => {
+    const allowedReferencedKeys = new Set([...referencedKeys, ...dynamicKeyWhitelist])
+    const orphanEnKeys = scopedOrphanCandidates.filter((key) => enKeys.has(key) && !allowedReferencedKeys.has(key))
+    const orphanZhKeys = scopedOrphanCandidates.filter((key) => zhKeys.has(key) && !allowedReferencedKeys.has(key))
+
+    expect(
+      orphanEnKeys,
+      `Scoped orphan keys in en.json:\n  ${orphanEnKeys.join("\n  ")}`,
+    ).toEqual([])
+    expect(
+      orphanZhKeys,
+      `Scoped orphan keys in zh.json:\n  ${orphanZhKeys.join("\n  ")}`,
+    ).toEqual([])
+  })
+
+  it("keeps novel extract model copy explicit and distinct from embedding model", () => {
+    expect(enKeys.has("novel.settings.extractModelHint"), "missing en extract model hint").toBe(true)
+    expect(zhKeys.has("novel.settings.extractModelHint"), "missing zh extract model hint").toBe(true)
+    expect(zh.novel.settings.extractModel).toContain("非嵌入模型")
+    expect(zh.novel.settings.extractModelHint).toContain("大纲")
+    expect(zh.novel.settings.extractModelHint).toContain("章节")
+    expect(zh.novel.settings.extractModelHint).toContain("不是向量嵌入模型")
+  })
+
+  it("every leaf value is a non-empty string (no null / empty / placeholder slips)", () => {
+    const check = (bundle: unknown, label: string) => {
+      const keys = flattenKeys(bundle)
+      for (const path of keys) {
+        let ref: unknown = bundle
+        for (const part of path.split(".")) {
+          ref = (ref as Record<string, unknown>)[part]
+        }
+        expect(typeof ref, `${label}: ${path} is not a string`).toBe("string")
+        expect((ref as string).length, `${label}: ${path} is empty`).toBeGreaterThan(0)
+      }
+    }
+    check(en, "en.json")
+    check(zh, "zh.json")
+  })
+
+  it("pluralization keys come in pairs: every foo_plural has a matching foo", () => {
+    const check = (bundle: unknown, label: string) => {
+      const keys = new Set(flattenKeys(bundle))
+      for (const k of keys) {
+        if (k.endsWith("_plural")) {
+          const singular = k.slice(0, -"_plural".length)
+          expect(
+            keys.has(singular),
+            `${label}: found ${k} but no matching ${singular} (i18next will fall back to the raw key for count=1)`,
+          ).toBe(true)
+        }
+      }
+    }
+    check(en, "en.json")
+    check(zh, "zh.json")
+  })
+})

+ 3 - 0
src/i18n/zh.json

@@ -892,6 +892,9 @@
     "emptyFiltered": "暂无{{label}},点击 + 创建",
     "emptyAll": "暂无章节和大纲",
     "rename": "重命名",
+    "renameFolderFailed": "重命名文件夹失败,请稍后重试。",
+    "renameTargetFolderExists": "目标文件夹已存在,请换一个名称。",
+    "renameTargetFileExists": "目标文件已存在,请先改名或选择其他文件夹。",
     "deleteFolder": "删除文件夹",
     "deleteVolume": "删除卷",
     "deleteFolderConfirm": "确认将「{{name}}」及其下 {{count}} 个文件移入回收站吗?",

+ 37 - 17
src/lib/agent/tools/run-chapter-workflow.ts

@@ -1,6 +1,7 @@
 import type { LlmConfig } from "@/stores/wiki-store"
 import { resolveAiWorkflowMode, type LegacyAiWorkflowMode } from "@/lib/agent/workflow-mode"
 import type {
+  ChapterWorkflowEvent,
   DeepChapterGenerationCallbacks,
   DeepChapterGenerationDeps,
   DeepChapterGenerationInput,
@@ -22,6 +23,10 @@ export interface RunChapterWorkflowToolOptions {
   runDeepChapterGeneration: RunDeepChapterGeneration
   onToolEvent?: (event: AgentToolEvent) => void
   onActivityEvent?: (event: AgentActivityEvent) => void
+  /**
+   * 当 AI 未在工具调用参数中携带 planBlueprint 时,从这里兜底取已确认的章节计划。
+   * 保证用户确认的计划为强制约束,不依赖模型是否遵守自然语言提示。
+   */
   getPlanBlueprint?: () => string | undefined
 }
 
@@ -55,6 +60,22 @@ function normalizeParams(params: Record<string, unknown>): RunChapterWorkflowPar
   }
 }
 
+function workflowEventToToolEvent(parentCallId: string, event: ChapterWorkflowEvent): AgentToolEvent {
+  return {
+    type: event.type === "started" ? "call_started" : event.type === "error" ? "error" : "result",
+    callId: `${parentCallId}:${event.name}`,
+    parentCallId,
+    name: event.name,
+    params: {
+      title: event.title,
+      ...(event.detail ? { detail: event.detail } : {}),
+      ...(event.params ?? {}),
+    },
+    result: event.result ?? event.detail ?? event.title,
+    timestamp: event.timestamp,
+  }
+}
+
 export function createRunChapterWorkflowTool(options: RunChapterWorkflowToolOptions): Tool {
   return {
     name: "run_chapter_workflow",
@@ -99,32 +120,29 @@ export function createRunChapterWorkflowTool(options: RunChapterWorkflowToolOpti
         return "错误:缺少 userRequest,无法运行章节工作流。"
       }
 
-      const parentCallId = context?.callId ?? `run_chapter_workflow:${Date.now()}`
-      const emitToolEvent = context?.onToolEvent ?? options.onToolEvent
+     const parentCallId = context?.callId ?? `run_chapter_workflow:${Date.now()}`
+     const emitToolEvent = context?.onToolEvent ?? options.onToolEvent
+     const emitActivityEvent = context?.onActivityEvent ?? options.onActivityEvent
+      // 兜底:AI 未在工具调用参数中携带 planBlueprint 时,从外部 getter 补上,
+      // 保证用户确认的计划一定进入章节生成链路,不依赖模型是否遵守自然语言提示。
       const planBlueprint = params.planBlueprint?.trim() || options.getPlanBlueprint?.()?.trim() || undefined
-
-      const enhancedUserRequest = planBlueprint
-        ? `${userRequest}\n\n【章节计划】\n${planBlueprint}`
-        : userRequest
-
       const result = await options.runDeepChapterGeneration(
         {
           projectPath: options.projectPath,
-          userRequest: enhancedUserRequest,
+          userRequest,
           chapterNumber: params.chapterNumber,
           llmConfig: options.llmConfig,
+          aiWorkflowMode: params.workflowMode ?? options.aiWorkflowMode,
+          planBlueprint,
         },
         {
-          onThinking: (_content) => {
+          onWorkflowEvent: (event) => {
+            emitToolEvent?.(workflowEventToToolEvent(parentCallId, event))
           },
-          onFinalContent: (content) => {
-            emitToolEvent?.({
-              type: "result",
-              callId: parentCallId,
-              name: "run_chapter_workflow",
-              params: {},
-              result: content,
-              timestamp: Date.now(),
+          onActivityEvent: (event) => {
+            emitActivityEvent?.({
+              ...event,
+              toolCallId: event.toolCallId ?? parentCallId,
             })
           },
         },
@@ -136,6 +154,8 @@ export function createRunChapterWorkflowTool(options: RunChapterWorkflowToolOpti
         "章节工作流完成。",
         `是否返修:${result.revised ? "是" : "否"}`,
         `任务书:${result.taskBrief}`,
+        result.executionReport ? `执行报告:\n${result.executionReport}` : "",
+        result.planCompliance ? `计划履约度:\n${result.planCompliance}` : "",
         "",
         "最终正文:",
         result.finalContent,

+ 62 - 0
src/lib/bundle-boundaries.spec.ts

@@ -0,0 +1,62 @@
+import { readFileSync } from "node:fs"
+import { describe, expect, it } from "vitest"
+
+const read = (relativePath: string) =>
+  readFileSync(new URL(relativePath, import.meta.url), "utf8").replace(/\r\n/g, "\n")
+
+describe("frontend bundle boundaries", () => {
+  it("uses focused vendor chunks instead of one forced book-analysis chunk", () => {
+    const viteConfig = read("../../vite.config.ts")
+
+    expect(viteConfig).toContain('return "opencc-vendor"')
+    expect(viteConfig).toContain('return "pinyin-vendor"')
+    expect(viteConfig).toContain('return "yaml-vendor"')
+    expect(viteConfig).not.toContain('return "book-analysis-vendor"')
+  })
+
+  it("matches sigma packages before the broad React vendor rule", () => {
+    const viteConfig = read("../../vite.config.ts")
+    const sigmaRule = viteConfig.indexOf('id.includes("@react-sigma")')
+    const reactRule = viteConfig.indexOf('id.includes("react") || id.includes("scheduler")')
+
+    expect(sigmaRule).toBeGreaterThan(-1)
+    expect(reactRule).toBeGreaterThan(-1)
+    expect(sigmaRule).toBeLessThan(reactRule)
+  })
+
+  it("keeps character-aura matching out of the initial context graph", () => {
+    const characterAura = read("./novel/character-aura.ts")
+    const contextEngine = read("./novel/context-engine.ts")
+    const reviewAdapter = read("./novel/review-adapter.ts")
+
+    expect(characterAura).toContain('from "opencc-js/t2cn"')
+    expect(characterAura).not.toContain('from "opencc-js"')
+    expect(contextEngine).not.toContain('from "./character-aura"')
+    expect(contextEngine).toContain('await import("./character-aura")')
+    expect(reviewAdapter).not.toContain('from "./character-aura"')
+    expect(reviewAdapter).toContain('await import("./character-aura")')
+  })
+
+  it("loads optional search, skill, soul, and book-analysis views on demand", () => {
+    const contentArea = read("../components/layout/content-area.tsx")
+    const sidebarPanel = read("../components/layout/sidebar-panel.tsx")
+
+    expect(contentArea).not.toContain('import { SearchView } from')
+    expect(contentArea).not.toContain('import { UnifiedSkillLibraryView } from')
+    expect(contentArea).toContain('await import("@/components/search/search-view")')
+    expect(contentArea).toContain('await import("@/components/skill-library/unified-skill-library-view")')
+    expect(sidebarPanel).not.toContain('import { SoulSidebarPanel } from')
+    expect(sidebarPanel).not.toContain('import { BookAnalysisSidebarPanel } from')
+    expect(sidebarPanel).not.toContain('import { UnifiedSkillLibrarySidebarPanel } from')
+    expect(sidebarPanel).toContain('await import("./soul-sidebar-panel")')
+    expect(sidebarPanel).toContain('await import("./book-analysis-sidebar-panel")')
+    expect(sidebarPanel).toContain('await import("@/components/skill-library/unified-skill-library-view")')
+  })
+
+  it("keeps the lazy-loading fallback user-facing text in Chinese", () => {
+    const contentArea = read("../components/layout/content-area.tsx")
+
+    expect(contentArea).toContain("加载中...")
+    expect(contentArea).not.toContain("Loading...")
+  })
+})

+ 15 - 2
src/lib/changelog.spec.ts

@@ -6,7 +6,9 @@ describe("changelog", () => {
     const entries = allChangelog()
     const versions = entries.map((entry) => entry.version)
 
-    expect(versions.slice(0, 27)).toEqual([
+    expect(versions.slice(0, 29)).toEqual([
+      "2.2.36",
+      "2.2.35",
       "2.2.33",
       "2.2.32",
       "2.2.31",
@@ -35,7 +37,7 @@ describe("changelog", () => {
       "2.2.0",
       "2.1.0",
     ])
-    expect(versions[27]).toBe("2.0.0")
+    expect(versions[29]).toBe("2.0.0")
 
     for (let patch = 1; patch <= 6; patch += 1) {
       expect(versions).not.toContain(`2.2.${patch}`)
@@ -63,6 +65,17 @@ describe("changelog", () => {
     expect(release.highlights.en.join("\n")).toContain("AI Rewrite")
   })
 
+  it("returns the 2.2.36 reliability release notes", () => {
+    const release = currentVersionChangelog("2.2.36")[0]
+    const zh = release.highlights.zh.join("\n")
+
+    expect(release.version).toBe("2.2.36")
+    expect(zh).toContain("确认计划")
+    expect(zh).toContain("严格审稿")
+    expect(zh).toContain("草稿")
+    expect(zh).toContain("并发保存")
+  })
+
   it("returns the 2.2.0 changelog entry", () => {
     const release = currentVersionChangelog("2.2.0")[0]
     const zh = release.highlights.zh.join("\n")

+ 54 - 0
src/lib/changelog.ts

@@ -7,6 +7,54 @@ export interface ChangelogEntry {
   };
 }
 
+const TWO_POINT_TWO_THIRTY_SIX_CHANGELOG: ChangelogEntry = {
+  version: "2.2.36",
+  date: "2026-07-15",
+  highlights: {
+    en: [
+      "Confirmed chapter plans now participate in context retrieval and de-AI Skill selection, and resume checkpoints preserve both the plan and execution contract.",
+      "Plan and execution repairs are accepted only after meaningful content changes and a successful compliance recheck, preventing false success reports.",
+      "Strict review now reports unavailable review models as failures and cancels sibling chunk requests when one review chunk fails.",
+      "De-AI review tasks stay isolated by project and case-sensitive path, while Save as Draft uses no-overwrite file creation and cannot refresh another project's file tree.",
+      "Overlapping chat and review saves are queued so the newest cleared or updated state is not lost.",
+      "Reduced initial frontend loading by lazy-loading optional views and separating Sigma, graph, language, and editor vendor bundles.",
+    ],
+    zh: [
+      "确认计划现在会参与上下文检索和去 AI 味 Skill 选择,恢复检查点同时保留计划与执行清单。",
+      "计划返修和执行清单返修只有在正文发生有效变化且复检通过后才会采用,避免错误提示返修成功。",
+      "严格审稿会把审稿模型不可用明确报告为失败,并在任一分段失败时取消同批其他审稿请求。",
+      "去 AI 味审查任务按项目和大小写敏感路径隔离;另存草稿采用不覆盖写入,且不会把旧项目文件树写入新项目界面。",
+      "聊天与审查的并发保存改为排队执行,清空或更新后的最新状态不会再被丢弃。",
+      "搜索、技能库等可选页面改为按需加载,并拆分图谱、语言处理和编辑器依赖,降低首次加载开销。",
+    ],
+  },
+};
+
+const TWO_POINT_TWO_THIRTY_FIVE_CHANGELOG: ChangelogEntry = {
+  version: "2.2.35",
+  date: "2026-07-15",
+  highlights: {
+    en: [
+      "Added a project-level context hub shared by AI Chat and AI Outline, with reusable source caches, session summaries, automatic invalidation, and inspectable cache snapshots.",
+      "Restored the full AI chapter workflow so fast, standard, and strict modes again route through confirmed plans, execution contracts, compliance checks, execution reports, and targeted repair.",
+      "Fixed multi-chapter de-AI review so background chapter results are actually written before success is shown, and review tasks stay isolated to the current project.",
+      "De-AI regeneration now reuses the originally selected Skill instead of silently falling back to an empty Skill prompt.",
+      "Improved AI Outline auto-save, plan confirmation, scrolling, and responsive panel sizing.",
+      "Removed inaccurate millisecond duration labels from AI Chat and AI Outline execution details.",
+      "Improved update failure guidance with a direct fallback link to the official download site.",
+    ],
+    zh: [
+      "新增项目级上下文中控:AI 对话与 AI 大纲共享资料缓存和会话摘要,支持自动失效、缓存命中统计与完整快照查看。",
+      "恢复完整 AI 章节工作流:快速、标准、严格模式重新接入确认计划、执行契约、履约检查、执行报告和定向返修。",
+      "修复多章节去 AI 味审查未写盘却提示成功的问题,并将审查任务严格隔离到当前项目。",
+      "去 AI 味重新生成会继续使用首次选择的 Skill,不再退回空 Skill 提示词。",
+      "修复 AI 大纲自动保存、计划确认、滚动定位和面板宽度自适应问题。",
+      "移除 AI 对话与 AI 大纲执行详情中不准确的毫秒耗时显示。",
+      "更新检查失败时增加官网下载安装入口。",
+    ],
+  },
+};
+
 const TWO_POINT_TWO_TEN_CHANGELOG: ChangelogEntry = {
   version: "2.2.10",
   date: "2026-06-09",
@@ -796,6 +844,10 @@ export const CHANGELOG: ChangelogEntry[] = [
 ];
 
 export function currentVersionChangelog(version: string): ChangelogEntry[] {
+  if (version === TWO_POINT_TWO_THIRTY_SIX_CHANGELOG.version)
+    return [TWO_POINT_TWO_THIRTY_SIX_CHANGELOG];
+  if (version === TWO_POINT_TWO_THIRTY_FIVE_CHANGELOG.version)
+    return [TWO_POINT_TWO_THIRTY_FIVE_CHANGELOG];
   if (version === TWO_POINT_TWO_THIRTY_THREE_CHANGELOG.version)
     return [TWO_POINT_TWO_THIRTY_THREE_CHANGELOG];
   if (version === TWO_POINT_TWO_THIRTY_TWO_CHANGELOG.version)
@@ -861,6 +913,8 @@ export function currentVersionChangelog(version: string): ChangelogEntry[] {
 
 export function allChangelog(): ChangelogEntry[] {
   return [
+    TWO_POINT_TWO_THIRTY_SIX_CHANGELOG,
+    TWO_POINT_TWO_THIRTY_FIVE_CHANGELOG,
     TWO_POINT_TWO_THIRTY_THREE_CHANGELOG,
     TWO_POINT_TWO_THIRTY_TWO_CHANGELOG,
     TWO_POINT_TWO_THIRTY_ONE_CHANGELOG,

+ 5 - 1
src/lib/export-center/export-service.spec.ts

@@ -110,7 +110,11 @@ it("默认二进制写入桥接在 Rust 后端实现并注册", () => {
   expect(fsSource).toContain("STANDARD as B64")
   expect(fsSource).toContain(".decode(contents_base64)")
   expect(fsSource).toContain("write_export_file_decodes_base64_before_writing")
-  expect(fsSource).not.toContain("let path = resolve_project_storage_path(path);\n        let p = Path::new(&path);\n        if let Some(parent) = p.parent()")
+  const exportImplementation = fsSource.slice(
+    fsSource.indexOf("fn do_write_export_file_with_replace"),
+    fsSource.indexOf("const MAX_EXPORT_BYTES"),
+  )
+  expect(exportImplementation).not.toContain("resolve_project_storage_path")
   expect(fsSource).toContain("do_write_export_file_with_replace")
   expect(fsSource).toContain("replace_export_file_atomically")
   expect(fsSource).toContain("write_export_file_uses_dialog_path_without_project_path_rewrite")

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

@@ -0,0 +1,207 @@
+import { readFileSync } from "node:fs"
+import { describe, expect, it } from "vitest"
+
+const read = (relativePath: string) =>
+  readFileSync(new URL(relativePath, import.meta.url), "utf8").replace(/\r\n/g, "\n")
+
+describe("user-facing hardcoded chinese strings", () => {
+  it("moves selected visible UI copy into i18n", () => {
+    const reviewView = read("../components/review/review-view.tsx")
+    const graphView = read("../components/graph/graph-view.tsx")
+    const lint = read("../lib/lint.ts")
+    const lintLib = read("../components/lint/lint-view.tsx")
+    const outlineIngest = read("../lib/novel/outline-ingest.ts")
+    const sourcesView = read("../components/sources/sources-view.tsx")
+    const outlineActionToolbar = read("../components/sources/outline-action-toolbar.tsx")
+    const appLayout = read("../components/layout/app-layout.tsx")
+    const previewPanel = read("../components/layout/preview-panel.tsx")
+    const trashPanel = read("../components/layout/trash-panel.tsx")
+    const knowledgeTree = read("../components/layout/knowledge-tree.tsx")
+    const iconSidebar = read("../components/layout/icon-sidebar.tsx")
+    const chatPanel = read("../components/chat/chat-panel.tsx")
+    const activityPanel = read("../components/layout/activity-panel.tsx")
+    const llmProviderSection = read("../components/settings/sections/llm-provider-section.tsx")
+    const changelogSection = read("../components/settings/sections/changelog-section.tsx")
+
+    expect(reviewView).not.toContain("历史审稿记录")
+    expect(reviewView).not.toContain("清空全部")
+    expect(reviewView).not.toContain("章审稿")
+    expect(reviewView).not.toContain("个严重问题")
+    expect(reviewView).not.toContain("个警告")
+
+    expect(graphView).not.toContain("编辑真实节点档案页")
+    expect(graphView).not.toContain("编辑:")
+    expect(graphView).not.toContain("构建图谱失败")
+    expect(graphView).not.toContain("图谱视图")
+    expect(graphView).not.toContain("文档视图")
+    expect(graphView).not.toContain("脑图视图")
+    expect(graphView).not.toContain("重置图谱过滤")
+    expect(graphView).not.toContain("编辑节点档案:")
+    expect(graphView).not.toContain("保存后会写入真实 wiki 档案页,并刷新图谱、脑图与可用检索内容。")
+    expect(graphView).not.toContain("取消")
+    expect(graphView).not.toContain("保存中...")
+    expect(graphView).not.toContain("保存档案页")
+    expect(graphView).not.toContain("关系强度")
+
+    expect(lint).not.toContain("No other pages link to this page.")
+    expect(lint).not.toContain("This page has no [[wikilink]] references to other pages.")
+    expect(lint).not.toContain("Broken link")
+    expect(lint).not.toContain("target page not found")
+    expect(lintLib).not.toContain("# Wiki Index\n")
+
+    expect(outlineIngest).not.toContain("大纲已生成完成")
+    expect(outlineIngest).not.toContain("已打开大纲")
+    expect(outlineIngest).not.toContain("正在摄取大纲...")
+    expect(outlineIngest).not.toContain("大纲摄取成功")
+    expect(outlineIngest).not.toContain("大纲摄取失败")
+
+    expect(sourcesView).not.toContain("OUTLINE_SECTION_GENERATION_CONFIGS")
+    expect(sourcesView).not.toContain("setOutlineDialogSectionKey")
+    expect(sourcesView).not.toContain("novel.outlineGenerator.sectionButtons")
+    expect(sourcesView).toContain("OutlineActionToolbar")
+    expect(sourcesView).toContain("OutlineWorkbench")
+    expect(outlineActionToolbar).not.toContain("outlineDialogMode")
+    expect(outlineActionToolbar).not.toContain('openOutlineDialog("refine")')
+    expect(outlineActionToolbar).not.toContain("novel.outlineGenerator.refineTitle")
+    expect(outlineActionToolbar).toContain("AI大纲")
+    expect(sourcesView).not.toContain("setOutlineCreatorOpen(true)")
+    expect(sourcesView).not.toContain("生成细纲")
+    expect(sourcesView).not.toContain("生成人物小传")
+    expect(sourcesView).not.toContain("生成势力")
+    expect(sourcesView).not.toContain("生成金手指")
+
+    expect(appLayout).not.toContain("大纲已生成完成")
+    expect(appLayout).not.toContain("可立即打开查看,或返回弹窗继续摄取大纲。")
+    expect(appLayout).not.toContain("打开大纲")
+    expect(appLayout).not.toContain("立即摄取")
+    expect(appLayout).not.toContain("稍后处理")
+
+    expect(previewPanel).not.toContain('t("preview.closeChatSession", { defaultValue: "关闭AI会话" })')
+    expect(previewPanel).not.toContain('t("preview.openChatSession", { defaultValue: "打开AI会话" })')
+
+    expect(trashPanel).not.toContain(">回收站<")
+    expect(trashPanel).not.toContain(">正在加载回收站…<")
+    expect(trashPanel).not.toContain(">回收站为空<")
+    expect(trashPanel).not.toContain('title="恢复到原位置;如原位置已有同名文件,会自动改名恢复"')
+
+    expect(knowledgeTree).not.toContain("未打开项目")
+    expect(knowledgeTree).not.toContain("暂无章节")
+    expect(knowledgeTree).not.toContain("暂无大纲")
+    expect(knowledgeTree).not.toContain("暂无章节和大纲")
+    expect(knowledgeTree).not.toContain("重命名")
+    expect(knowledgeTree).not.toContain('window.alert("目标文件夹已存在')
+    expect(knowledgeTree).not.toContain('window.alert("目标文件已存在')
+    expect(knowledgeTree).not.toContain("原始来源")
+    expect(knowledgeTree).not.toContain("正在移入回收站")
+    expect(knowledgeTree).not.toContain("再次点击确认移入回收站")
+    expect(knowledgeTree).not.toContain("移入回收站:")
+
+    expect(iconSidebar).not.toContain("切换到深色模式")
+    expect(iconSidebar).not.toContain("切换到深蓝护眼模式")
+    expect(iconSidebar).not.toContain("切换到浅色模式")
+    expect(iconSidebar).not.toContain("切换主题")
+    expect(iconSidebar).not.toContain("展开或隐藏章节栏")
+    expect(iconSidebar).not.toContain("青慕AI写作")
+    expect(iconSidebar).not.toContain("回收站")
+
+    expect(chatPanel).not.toContain("审稿中...")
+    expect(chatPanel).not.toContain("审稿发现")
+    expect(chatPanel).not.toContain("缺少LLM配置,跳过自动提取")
+
+    expect(activityPanel).not.toContain("文件同步失败")
+    expect(activityPanel).not.toContain("重新扫描")
+    expect(activityPanel).not.toContain("摄取队列")
+    expect(activityPanel).not.toContain("清除已完成")
+
+    expect(llmProviderSection).not.toContain("仅桌面端可用")
+    expect(llmProviderSection).not.toContain("CLI status")
+    expect(llmProviderSection).not.toContain("Checking…")
+    expect(llmProviderSection).not.toContain("Re-check")
+    expect(llmProviderSection).not.toContain("Detecting local claude binary…")
+    expect(llmProviderSection).not.toContain("Ready to use your local subscription — no API key needed.")
+    expect(llmProviderSection).not.toContain("If chat fails with an authentication error")
+    expect(llmProviderSection).not.toContain("then re-check.")
+    expect(llmProviderSection).not.toContain("Detecting local codex binary…")
+    expect(llmProviderSection).not.toContain("Ready to use your local Codex login — no API key needed.")
+    expect(llmProviderSection).not.toContain("in a terminal to refresh the login.")
+
+    expect(changelogSection).not.toContain('t("settings.sections.changelog.title", { defaultValue: "更新日志" })')
+    expect(changelogSection).not.toContain("当前版本中对用户可见的主要修改内容。")
+  })
+
+  it("keeps visible trash and chapter header labels readable when i18n misses keys", () => {
+    const previewPanel = read("../components/layout/preview-panel.tsx")
+    const trashPanel = read("../components/layout/trash-panel.tsx")
+    const changelogSection = read("../components/settings/sections/changelog-section.tsx")
+    const scheduledImportSection = read("../components/settings/sections/scheduled-import-section.tsx")
+    const maintenanceSection = read("../components/settings/sections/maintenance-section.tsx")
+
+    expect(trashPanel).toContain('t("trash.title", { defaultValue: "回收站" })')
+    expect(trashPanel).toContain('t("trash.retention", { defaultValue: "内容默认保留30天" })')
+    expect(trashPanel).toContain('t("trash.empty", { defaultValue: "回收站为空" })')
+    expect(previewPanel).toContain('t("preview.closeChatSession", { defaultValue: "关闭会话栏" })')
+    expect(previewPanel).toContain('t("preview.openChatSession", { defaultValue: "打开会话栏" })')
+    expect(previewPanel).toContain('t("preview.chatSession", { defaultValue: "AI会话" })')
+    expect(previewPanel).toContain('t("preview.formatWriting", { defaultValue: "一键排版" })')
+    expect(changelogSection).toContain('t("settings.sections.changelog.title", { defaultValue: "软件更新日志" })')
+    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.never", { defaultValue: "从未扫描" })')
+    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.title", {')
+    expect(scheduledImportSection).toContain('defaultValue: "定时导入"')
+    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.description", {')
+    expect(scheduledImportSection).toContain('defaultValue: "自动监控目录,并按固定间隔导入新增或更新的文件。"')
+    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.enable", {')
+    expect(scheduledImportSection).toContain('defaultValue: "启用定时导入"')
+    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.privacyNotice", {')
+    expect(scheduledImportSection).toContain('defaultValue: "所选目录中的文件可能会被复制到当前项目,并在摄取时发送给已配置的大模型。源目录删除文件后,项目内不会自动删除。"')
+    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.directory", {')
+    expect(scheduledImportSection).toContain('defaultValue: "监控目录"')
+    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.browse", {')
+    expect(scheduledImportSection).toContain('defaultValue: "浏览目录"')
+    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.directoryHelp", {')
+    expect(scheduledImportSection).toContain('defaultValue: "该目录及其子目录中的文件会自动导入;新增文件会复制到素材区,修改后的文件会重新摄取。"')
+    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.interval", {')
+    expect(scheduledImportSection).toContain('defaultValue: "扫描间隔(分钟)"')
+    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.intervalHelp", {')
+    expect(scheduledImportSection).toContain('defaultValue: "多久检查一次变更,最短为 1 分钟。"')
+    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.scanning", { defaultValue: "扫描中..." })')
+    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.scanNow", { defaultValue: "立即扫描" })')
+    expect(scheduledImportSection).toContain('t("settings.sections.scheduledImport.lastScan", {')
+    expect(scheduledImportSection).toContain('defaultValue: "上次扫描:{{time}}"')
+
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.title", { defaultValue: "维护工具" })')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.description", {')
+    expect(maintenanceSection).toContain(
+      'defaultValue:\n              "用于清理资料库的工具:检测并合并那些在多次重新摄取后被大模型以不同名称创建出来的重复实体或概念。",'
+    )
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.title", {')
+    expect(maintenanceSection).toContain('defaultValue: "检测重复实体 / 概念"')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.description", {')
+    expect(maintenanceSection).toContain(
+      'defaultValue:\n              "让大模型扫描全部实体 / 概念页面,并把那些很可能只是名称不同、实则指向同一主题的条目分组出来(例如中英文名称、单复数、简称与全称)。每组都需要你确认后才会合并。合并任务会进入队列并逐个执行,以保持交叉引用一致。",'
+    )
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.noProject", {')
+    expect(maintenanceSection).toContain('defaultValue: "请先打开一个项目。"')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.noLlm", {')
+    expect(maintenanceSection).toContain('defaultValue: "请先配置大模型提供方。"')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.scanButton", {')
+    expect(maintenanceSection).toContain('defaultValue: "开始扫描重复项"')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.noneFound", {')
+    expect(maintenanceSection).toContain('defaultValue: "未发现重复分组,当前资料库很干净。"')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.queueTitle", {')
+    expect(maintenanceSection).toContain('defaultValue: "进行中的合并任务"')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.queueDescription", {')
+    expect(maintenanceSection).toContain(
+      'defaultValue:\n            "这里显示上一次扫描后仍未完成的任务。合并会逐个排队执行。",'
+    )
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.retry", {')
+    expect(maintenanceSection).toContain('defaultValue: "重试"')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.delete", {')
+    expect(maintenanceSection).toContain('defaultValue: "删除"')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.merging", {')
+    expect(maintenanceSection).toContain('defaultValue: "合并中..."')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.queued", {')
+    expect(maintenanceSection).toContain('defaultValue: "已排队"')
+    expect(maintenanceSection).toContain('t("settings.sections.maintenance.dedup.queuedAhead", {')
+    expect(maintenanceSection).toContain('defaultValue: "已排队(前方还有 {{n}} 项)"')
+  })
+})

+ 11 - 1
src/lib/novel/character-aura.spec.ts

@@ -6,7 +6,10 @@ vi.mock("@/commands/fs", () => ({
     if (path.endsWith("/.qmai/character-aura.json")) {
       return JSON.stringify({
         customAuras: [],
-        bindings: [{ characterName: "小晴", auraId: "builtin-li-qingzhao" }],
+        bindings: [
+          { characterName: "小晴", auraId: "builtin-li-qingzhao" },
+          { characterName: "小云", auraId: "builtin-li-qingzhao" },
+        ],
       })
     }
     return ""
@@ -26,4 +29,11 @@ describe("buildCharacterAuraContext", () => {
     expect(context).toContain("李清照")
     expect(context).toContain("角色灵魂必须服从大纲")
   })
+
+  it("matches a simplified bound name when the task uses traditional Chinese", async () => {
+    const context = await buildCharacterAuraContext("E:/Novel", "小雲推開舊屋的門")
+
+    expect(context).toContain("小云")
+    expect(context).toContain("李清照")
+  })
 })

+ 1 - 1
src/lib/novel/character-aura.ts

@@ -11,7 +11,7 @@ import { webSearch, type WebSearchResult } from "@/lib/web-search"
 import { isTauri } from "@/lib/platform"
 import { useWikiStore } from "@/stores/wiki-store"
 import { pinyin } from "pinyin-pro"
-import * as OpenCC from "opencc-js"
+import * as OpenCC from "opencc-js/t2cn"
 
 /** 把中文文本转为无音调小写拼音,用于拼音模糊匹配 */
 function toPinyin(text: string): string {

+ 1 - 1
src/lib/novel/context-engine.ts

@@ -10,7 +10,6 @@ import { buildRevisionDirectives } from "./revision-feedback"
 import { extractChapterOutlineStatus } from "./outline-quality-check"
 import { loadCognitionState, cognitionToContextText } from "./character-cognition"
 import { getChapterVolumes } from "./volume"
-import { buildCharacterAuraContext } from "./character-aura"
 import { isAuthoritativeGenerationPath, isHistoricalProjectionSnippet, novelMixedSearch } from "./search-adapter"
 import { rerankCandidates } from "@/lib/rerank"
 import type { FileNode } from "@/types/wiki"
@@ -221,6 +220,7 @@ async function buildContextPackFromRawData(
   const revisionDirectives = buildRevisionDirectives(rawData.revisionFeedback)
   
   // 构建角色氛围上下文(依赖其他数据)
+  const { buildCharacterAuraContext } = await import("./character-aura")
   const characterAuras = await buildCharacterAuraContext(context.projectPath, context.task, {
     matchingText: joinNonEmpty([
       chapterGoal,

+ 65 - 0
src/lib/novel/de-ai-draft.spec.ts

@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest"
+import {
+  resolveAvailableDeAiDraftPath,
+  saveDeAiDraftWithoutOverwrite,
+} from "./de-ai-draft"
+
+describe("resolveAvailableDeAiDraftPath", () => {
+  it("uses the standard draft name when it is available", async () => {
+    const result = await resolveAvailableDeAiDraftPath(
+      "C:\\Book\\wiki\\chapters\\第一章.md",
+      async () => false,
+    )
+
+    expect(result).toBe("C:/Book/wiki/chapters/第一章-去AI味稿.md")
+  })
+
+  it("adds a sequence number instead of overwriting an existing draft", async () => {
+    const existing = new Set([
+      "C:/Book/第一章-去AI味稿.md",
+      "C:/Book/第一章-去AI味稿-2.md",
+    ])
+
+    const result = await resolveAvailableDeAiDraftPath(
+      "C:/Book/第一章.md",
+      async (path) => existing.has(path),
+    )
+
+    expect(result).toBe("C:/Book/第一章-去AI味稿-3.md")
+  })
+
+  it("atomically retries another name when a draft appears during saving", async () => {
+    const attempts: string[] = []
+    const result = await saveDeAiDraftWithoutOverwrite(
+      "C:/Book/第一章.md",
+      "候选正文",
+      async (path, content) => {
+        attempts.push(path)
+        expect(content).toBe("候选正文")
+        return attempts.length > 1
+      },
+    )
+
+    expect(attempts).toEqual([
+      "C:/Book/第一章-去AI味稿.md",
+      "C:/Book/第一章-去AI味稿-2.md",
+    ])
+    expect(result).toBe("C:/Book/第一章-去AI味稿-2.md")
+  })
+
+  it("keeps probing unique timestamp names after the numbered range is full", async () => {
+    let attempts = 0
+    const result = await saveDeAiDraftWithoutOverwrite(
+      "C:/Book/第一章.md",
+      "候选正文",
+      async () => {
+        attempts += 1
+        return attempts > 100
+      },
+      () => 123456,
+    )
+
+    expect(attempts).toBe(101)
+    expect(result).toBe("C:/Book/第一章-去AI味稿-123456-2.md")
+  })
+})

+ 55 - 0
src/lib/novel/de-ai-draft.ts

@@ -0,0 +1,55 @@
+import { normalizePath } from "@/lib/path-utils"
+
+type FileExists = (path: string) => Promise<boolean>
+type WriteFileIfAbsent = (path: string, content: string) => Promise<boolean>
+
+function getDeAiDraftStem(chapterPath: string): string {
+  const normalizedPath = normalizePath(chapterPath)
+  const slashIndex = normalizedPath.lastIndexOf("/")
+  const dir = slashIndex >= 0 ? normalizedPath.slice(0, slashIndex + 1) : ""
+  const fileName = slashIndex >= 0 ? normalizedPath.slice(slashIndex + 1) : normalizedPath
+  const baseName = fileName.replace(/\.md$/i, "") || "chapter"
+  return `${dir}${baseName}-去AI味稿`
+}
+
+export async function resolveAvailableDeAiDraftPath(
+  chapterPath: string,
+  fileExists: FileExists,
+): Promise<string> {
+  const draftStem = getDeAiDraftStem(chapterPath)
+
+  const firstPath = `${draftStem}.md`
+  if (!(await fileExists(firstPath))) return firstPath
+
+  for (let index = 2; index <= 99; index += 1) {
+    const candidate = `${draftStem}-${index}.md`
+    if (!(await fileExists(candidate))) return candidate
+  }
+
+  return `${draftStem}-${Date.now()}.md`
+}
+
+export async function saveDeAiDraftWithoutOverwrite(
+  chapterPath: string,
+  content: string,
+  writeFileIfAbsent: WriteFileIfAbsent,
+  now: () => number = Date.now,
+): Promise<string> {
+  const draftStem = getDeAiDraftStem(chapterPath)
+  const numberedCandidates = [
+    `${draftStem}.md`,
+    ...Array.from({ length: 98 }, (_, index) => `${draftStem}-${index + 2}.md`),
+  ]
+
+  for (const candidate of numberedCandidates) {
+    if (await writeFileIfAbsent(candidate, content)) return candidate
+  }
+
+  const timestamp = now()
+  for (let suffix = 1; ; suffix += 1) {
+    const candidate = suffix === 1
+      ? `${draftStem}-${timestamp}.md`
+      : `${draftStem}-${timestamp}-${suffix}.md`
+    if (await writeFileIfAbsent(candidate, content)) return candidate
+  }
+}

+ 321 - 24
src/lib/novel/deep-chapter-generation.spec.ts

@@ -20,6 +20,12 @@ import {
 } from "./deep-chapter-prompts"
 import { contractToTaskBriefText, createEmptyContract, type ChapterExecutionContract } from "./chapter-execution-contract"
 
+const loadSmartDeAiSkillMock = vi.hoisted(() => vi.fn(async () => null))
+
+vi.mock("./de-ai-adapter", () => ({
+  loadSmartDeAiSkill: loadSmartDeAiSkillMock,
+}))
+
 const llmConfig = {
   provider: "custom",
   apiKey: "test-key",
@@ -149,6 +155,78 @@ function createLegacyPlanComplianceDeps(reviewResults: NovelReviewResult[] = [])
 }
 
 describe("runDeepChapterGeneration", () => {
+  it("uses the confirmed plan when retrieving context and selecting the de-AI skill", async () => {
+    const deps = createDeps()
+    const planBlueprint = "确认计划:新角色顾舟在北塔登场,并带出铜钥匙。"
+    loadSmartDeAiSkillMock.mockClear()
+
+    await runDeepChapterGeneration(
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第3章",
+        chapterNumber: 3,
+        llmConfig,
+        planBlueprint,
+      },
+      {},
+      deps,
+    )
+
+    expect(deps.buildContextPack).toHaveBeenCalledWith(
+      "E:/Novel",
+      expect.stringContaining(planBlueprint),
+      3,
+    )
+    expect(loadSmartDeAiSkillMock).toHaveBeenCalledWith(
+      "E:/Novel",
+      expect.stringContaining(planBlueprint),
+      expect.any(Object),
+    )
+  })
+
+  it("persists the confirmed plan and execution contract in resume checkpoints", async () => {
+    const checkpoints: DeepChapterGenerationResumeCheckpoint[] = []
+    const planBlueprint = "确认计划:新角色顾舟在北塔登场,并带出铜钥匙。"
+
+    await runDeepChapterGeneration(
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第3章",
+        chapterNumber: 3,
+        llmConfig,
+        planBlueprint,
+      },
+      { onCheckpoint: (checkpoint) => checkpoints.push(checkpoint) },
+      createDeps(),
+    )
+
+    const reviewCheckpoint = checkpoints.find((checkpoint) => checkpoint.stage === "after_review")
+    expect(reviewCheckpoint).toMatchObject({
+      planBlueprint,
+      executionContract,
+    })
+
+    const resumeDeps = createDeps()
+    await runDeepChapterGeneration(
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第3章",
+        chapterNumber: 3,
+        llmConfig,
+        resumeCheckpoint: reviewCheckpoint,
+      },
+      {},
+      resumeDeps,
+    )
+
+    expect(resumeDeps.runChapterExecutionContractBuild).not.toHaveBeenCalled()
+    expect(resumeDeps.buildContextPack).toHaveBeenCalledWith(
+      "E:/Novel",
+      expect.stringContaining(planBlueprint),
+      3,
+    )
+  })
+
   it("keeps the word-count target in planning and draft prompts without forcing later review stages", () => {
     const reviewResults: NovelReviewResult[] = [{
       severity: "error",
@@ -294,20 +372,30 @@ describe("runDeepChapterGeneration", () => {
     const deps = {
       ...createDeps(),
       runChapterExecutionContractBuild: vi.fn(async () => executionContract),
-      runChapterExecutionReportCheck: vi.fn(async () => ({
-        status: "fail" as const,
-        sceneResults: [{
-          id: "S1",
-          passed: false,
-          missing: ["主角进入旧屋"],
-          evidence: "正文停在门外。",
-          repairInstruction: "补写主角进入旧屋。",
-        }],
-        mustDoResults: [],
-        mustAvoidResults: [],
-        finalHookPassed: true,
-        repairItems: ["S1 缺少主角进入旧屋"],
-      })),
+      runChapterExecutionReportCheck: vi
+        .fn()
+        .mockResolvedValueOnce({
+          status: "fail" as const,
+          sceneResults: [{
+            id: "S1",
+            passed: false,
+            missing: ["主角进入旧屋"],
+            evidence: "正文停在门外。",
+            repairInstruction: "补写主角进入旧屋。",
+          }],
+          mustDoResults: [],
+          mustAvoidResults: [],
+          finalHookPassed: true,
+          repairItems: ["S1 缺少主角进入旧屋"],
+        })
+        .mockResolvedValueOnce({
+          status: "pass" as const,
+          sceneResults: [{ id: "S1", passed: true, missing: [], evidence: "已进入旧屋。", repairInstruction: "" }],
+          mustDoResults: [],
+          mustAvoidResults: [],
+          finalHookPassed: true,
+          repairItems: [],
+        }),
       runChapterPlanComplianceCheck: vi.fn(async () => "不应调用旧履约检查"),
       runChapterPlanDeviationRepair: vi.fn(async (_config, _plan, _content, compliance) => {
         expect(String(compliance)).toContain("S1 缺少主角进入旧屋")
@@ -437,6 +525,56 @@ describe("runDeepChapterGeneration", () => {
     expect(activityEvents.some((event) => event.stageId === "plan_compliance")).toBe(false)
   })
 
+  it("does not complete an execution repair that returns the original content", async () => {
+    const workflowEvents: Array<{ type: string; name: string }> = []
+    const failedReport = {
+      status: "fail" as const,
+      sceneResults: [{
+        id: "S1",
+        passed: false,
+        missing: ["主角进入旧屋"],
+        evidence: "正文停在门外。",
+        repairInstruction: "补写主角进入旧屋。",
+      }],
+      mustDoResults: [],
+      mustAvoidResults: [],
+      finalHookPassed: true,
+      repairItems: ["S1 缺少主角进入旧屋"],
+    }
+    const deps = {
+      ...createDeps(),
+      runChapterExecutionContractBuild: vi.fn(async () => executionContract),
+      runChapterExecutionReportCheck: vi.fn(async () => failedReport),
+      runChapterPlanDeviationRepair: vi.fn(async (
+        _config: LlmConfig,
+        _plan: string,
+        content: string,
+      ) => content),
+    }
+
+    const result = await runDeepChapterGeneration(
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第3章",
+        chapterNumber: 3,
+        llmConfig,
+        planBlueprint: "确认计划:主角进入旧屋。",
+      },
+      { onWorkflowEvent: (event) => workflowEvents.push(event) },
+      deps,
+    )
+
+    expect(result.revised).toBe(false)
+    expect(workflowEvents).toContainEqual(expect.objectContaining({
+      type: "error",
+      name: "chapter_execution_repair",
+    }))
+    expect(workflowEvents).not.toContainEqual(expect.objectContaining({
+      type: "completed",
+      name: "chapter_execution_repair",
+    }))
+  })
+
   it("keeps final content when execution report generation fails", async () => {
     const deps = {
       ...createDeps(),
@@ -509,6 +647,32 @@ describe("runDeepChapterGeneration", () => {
     expect(events.some((event) => event.name === "chapter_plan_compliance")).toBe(true)
   })
 
+  it("does not emit a completed workflow event when plan compliance fails", async () => {
+    const deps = {
+      ...createLegacyPlanComplianceDeps(),
+      runChapterPlanComplianceCheck: vi.fn(async () => {
+        throw new Error("履约检查服务不可用")
+      }),
+    }
+    const events: Array<{ type: string; name: string }> = []
+
+    const result = await runDeepChapterGeneration(
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第3章",
+        chapterNumber: 3,
+        llmConfig,
+        planBlueprint: "确认计划:旧屋揭示,章末脚步声钩子。",
+      },
+      { onWorkflowEvent: (event) => events.push(event) },
+      deps,
+    )
+
+    expect(result.planCompliance).toContain("履约检查服务不可用")
+    expect(events.filter((event) => event.name === "chapter_plan_compliance").map((event) => event.type))
+      .toEqual(["started", "error"])
+  })
+
   it("publishes final content before waiting for blueprint compliance", async () => {
     const order: string[] = []
     const deps = {
@@ -570,15 +734,22 @@ describe("runDeepChapterGeneration", () => {
     const activityEvents: AgentActivityEvent[] = []
     const deps = {
       ...createLegacyPlanComplianceDeps(),
-      runChapterPlanComplianceCheck: vi.fn(async () => JSON.stringify({
-        status: "partial_deviation",
-        summary: "结尾钩子缺失。",
-        deviations: [{
-          point: "章末钩子",
-          evidence: "正文没有门外第二个人影。",
-          suggestion: "只在结尾补入门外第二个人影,导向下一章。",
-        }],
-      })),
+      runChapterPlanComplianceCheck: vi
+        .fn()
+        .mockResolvedValueOnce(JSON.stringify({
+          status: "partial_deviation",
+          summary: "结尾钩子缺失。",
+          deviations: [{
+            point: "章末钩子",
+            evidence: "正文没有门外第二个人影。",
+            suggestion: "只在结尾补入门外第二个人影,导向下一章。",
+          }],
+        }))
+        .mockResolvedValueOnce(JSON.stringify({
+          status: "compliant",
+          summary: "返修后已符合计划。",
+          deviations: [],
+        })),
       runChapterPlanDeviationRepair: vi.fn(async (
         _config: LlmConfig,
         plan: string,
@@ -609,6 +780,7 @@ describe("runDeepChapterGeneration", () => {
     )
 
     expect(deps.runChapterPlanDeviationRepair).toHaveBeenCalledOnce()
+    expect(deps.runChapterPlanComplianceCheck).toHaveBeenCalledTimes(2)
     expect(result.finalContent).toBe(repairedContent)
     expect(result.revised).toBe(true)
     expect(result.planCompliance).toContain("计划偏离点返修:已完成")
@@ -626,6 +798,77 @@ describe("runDeepChapterGeneration", () => {
     expect(repairEvent?.content).toContain("返修后")
   })
 
+  it("keeps the original content when a plan repair returns it unchanged", async () => {
+    const workflowEvents: Array<{ type: string; name: string }> = []
+    const deps = {
+      ...createLegacyPlanComplianceDeps(),
+      runChapterPlanComplianceCheck: vi.fn(async () => JSON.stringify({
+        status: "partial_deviation",
+        summary: "章末钩子缺失。",
+        deviations: [{ point: "章末钩子", evidence: "未出现人影。", suggestion: "补入人影。" }],
+      })),
+      runChapterPlanDeviationRepair: vi.fn(async (
+        _config: LlmConfig,
+        _plan: string,
+        content: string,
+      ) => content),
+    }
+
+    const result = await runDeepChapterGeneration(
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第3章",
+        chapterNumber: 3,
+        llmConfig,
+        planBlueprint: "确认计划:章末必须出现门外第二个人影。",
+      },
+      { onWorkflowEvent: (event) => workflowEvents.push(event) },
+      deps,
+    )
+
+    expect(result.revised).toBe(false)
+    expect(result.finalContent).toContain("最终去AI味正文")
+    expect(workflowEvents).toContainEqual(expect.objectContaining({
+      type: "error",
+      name: "chapter_plan_deviation_repair",
+    }))
+    expect(workflowEvents).not.toContainEqual(expect.objectContaining({
+      type: "completed",
+      name: "chapter_plan_deviation_repair",
+    }))
+  })
+
+  it("rejects a structurally valid plan repair when the compliance recheck still fails", async () => {
+    const repairedContent = chapterText("复检仍偏离的返修正文", 3000)
+    const deviation = JSON.stringify({
+      status: "partial_deviation",
+      summary: "章末钩子仍然缺失。",
+      deviations: [{ point: "章末钩子", evidence: "仍未出现人影。", suggestion: "补入人影。" }],
+    })
+    const deps = {
+      ...createLegacyPlanComplianceDeps(),
+      runChapterPlanComplianceCheck: vi.fn(async () => deviation),
+      runChapterPlanDeviationRepair: vi.fn(async () => repairedContent),
+    }
+
+    const result = await runDeepChapterGeneration(
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第3章",
+        chapterNumber: 3,
+        llmConfig,
+        planBlueprint: "确认计划:章末必须出现门外第二个人影。",
+      },
+      {},
+      deps,
+    )
+
+    expect(deps.runChapterPlanComplianceCheck).toHaveBeenCalledTimes(2)
+    expect(result.revised).toBe(false)
+    expect(result.finalContent).toContain("最终去AI味正文")
+    expect(result.finalContent).not.toBe(repairedContent)
+  })
+
   it("does not repair final content when plan compliance is mostly compliant", async () => {
     const activityEvents: AgentActivityEvent[] = []
     const deps = {
@@ -810,6 +1053,28 @@ describe("runDeepChapterGeneration", () => {
     expect(thinking.join("\n")).toContain("未发现阻断问题")
   })
 
+  it("reports strict review failures without claiming that no blocking issues were found", async () => {
+    const deps = createDeps()
+    vi.mocked(deps.reviewChapter).mockRejectedValueOnce(new Error("审稿服务不可用"))
+    const thinking: string[] = []
+    const events: Array<{ type: string; name: string }> = []
+
+    const result = await runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第3章", chapterNumber: 3, llmConfig },
+      {
+        onThinking: (content) => thinking.push(content),
+        onWorkflowEvent: (event) => events.push(event),
+      },
+      deps,
+    )
+
+    expect(result.finalContent).toContain("最终去AI味正文")
+    expect(thinking.join("\n")).toContain("AI 审稿失败")
+    expect(thinking.join("\n")).not.toContain("AI 审稿完成,未发现阻断问题")
+    expect(events.filter((event) => event.name === "chapter_review").map((event) => event.type))
+      .toEqual(["started", "error"])
+  })
+
   it("emits structured activity events for context extraction and stage outputs", async () => {
     const deps = createDeps()
     const activityEvents: AgentActivityEvent[] = []
@@ -976,13 +1241,16 @@ describe("runDeepChapterGeneration", () => {
     expect(fastDeps.reviewChapter).not.toHaveBeenCalled()
 
     const standardDeps = createDeps()
+    const standardThinking: string[] = []
     await runDeepChapterGeneration(
       { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig, aiWorkflowMode: "standard" },
-      {},
+      { onThinking: (content) => standardThinking.push(content) },
       standardDeps,
     )
     expect(standardDeps.streamChat).toHaveBeenCalledTimes(2)
     expect(standardDeps.reviewChapter).not.toHaveBeenCalled()
+    expect(standardThinking.join("\n")).toContain("阶段4:标准完成")
+    expect(standardThinking.join("\n")).not.toContain("快速模式")
 
     const strictDeps = createDeps()
     await runDeepChapterGeneration(
@@ -1178,6 +1446,35 @@ describe("runDeepChapterGeneration", () => {
     expect(thinking.length).toBeGreaterThan(0)
   })
 
+  it("stops promptly while context building is still pending", async () => {
+    const deps = createDeps()
+    let releaseContext!: (value: ContextPack) => void
+    const pendingContext = new Promise<ContextPack>((resolve) => {
+      releaseContext = resolve
+    })
+    vi.mocked(deps.buildContextPack).mockReturnValueOnce(pendingContext)
+    const controller = new AbortController()
+
+    const generation = runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第3章", chapterNumber: 3, llmConfig },
+      {},
+      deps,
+      controller.signal,
+    )
+    await vi.waitFor(() => expect(deps.buildContextPack).toHaveBeenCalledTimes(1))
+    controller.abort()
+
+    const outcome = await Promise.race([
+      generation.then(() => "resolved", (error) => error instanceof Error ? error.message : String(error)),
+      new Promise<string>((resolve) => setTimeout(() => resolve("timeout"), 50)),
+    ])
+    releaseContext(contextPack)
+    await generation.catch(() => {})
+
+    expect(outcome).toBe("已停止生成")
+    expect(deps.streamChat).not.toHaveBeenCalled()
+  })
+
   it("revises once when review returns blocking errors", async () => {
     const deps = createDeps([
       {

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 1460 - 296
src/lib/novel/deep-chapter-generation.ts


+ 1 - 1
src/lib/novel/outline-generation.spec.ts

@@ -121,7 +121,7 @@ describe("outline-generation context fallback", () => {
     const prompt = await buildOutlineGenerationPrompt("E:/Novel", "通用", "短篇", "测试")
 
     expect(prompt).toContain("测试")
-    expect(prompt).toContain("请为以下小说生成大纲")
+    expect(prompt).toContain("请根据以上信息,设计一个完整、有吸引力的小说大纲")
   })
 
   it("returns an empty refinement context when context loading fails", async () => {

+ 89 - 2
src/lib/novel/review-adapter.spec.ts

@@ -6,6 +6,8 @@ import { buildReviewPrompt, reviewChapter } from "./review-adapter"
 
 const mocks = vi.hoisted(() => ({
   streamChatMock: vi.fn(),
+  hasUsableLlm: true,
+  novelMode: true,
   novelConfig: { reviewModel: "", reviewReasoningEffort: "high" as "low" | "medium" | "high" },
   llmConfig: {
     provider: "custom" as const,
@@ -53,14 +55,14 @@ vi.mock("@/stores/wiki-store", () => ({
     getState: () => ({
       llmConfig,
       novelConfig: mocks.novelConfig,
-      novelMode: true,
+      novelMode: mocks.novelMode,
       providerConfigs: {},
     }),
   },
 }))
 
 vi.mock("@/lib/has-usable-llm", () => ({
-  hasUsableLlm: () => true,
+  hasUsableLlm: () => mocks.hasUsableLlm,
 }))
 
 vi.mock("./model-resolver", () => ({
@@ -90,6 +92,8 @@ describe("review-adapter staged review", () => {
     streamChatMock.mockReset()
     llmConfig.reasoning = { mode: "auto" }
     mocks.novelConfig.reviewReasoningEffort = "high"
+    mocks.hasUsableLlm = true
+    mocks.novelMode = true
   })
 
   it("builds a staged deep review prompt with outline, memory, foreshadowing, and cognition checks", () => {
@@ -267,6 +271,89 @@ describe("review-adapter staged review", () => {
     expect(results).toEqual([])
   })
 
+  it("throws malformed review output when the caller requires a successful review", async () => {
+    streamChatMock.mockImplementation(async (
+      _config: LlmConfig,
+      _messages: Array<{ role: string; content: string }>,
+      callbacks: StreamCallbacks,
+    ) => {
+      callbacks.onToken("[not-json]")
+      callbacks.onDone()
+    })
+
+    await expect(reviewChapter("E:/Novel", "正文", 8, {
+      contextPack,
+      throwOnFailure: true,
+    })).rejects.toThrow()
+  })
+
+  it("throws missing structured review output when the caller requires a successful review", async () => {
+    streamChatMock.mockImplementation(async (
+      _config: LlmConfig,
+      _messages: Array<{ role: string; content: string }>,
+      callbacks: StreamCallbacks,
+    ) => {
+      callbacks.onToken("逐维度审查完成,但没有返回最终 JSON。")
+      callbacks.onDone()
+    })
+
+    await expect(reviewChapter("E:/Novel", "正文", 8, {
+      contextPack,
+      throwOnFailure: true,
+    })).rejects.toThrow("未返回结构化 JSON")
+  })
+
+  it("throws when strict review has no usable review model", async () => {
+    mocks.hasUsableLlm = false
+
+    await expect(reviewChapter("E:/Novel", "正文", 8, {
+      contextPack,
+      throwOnFailure: true,
+    })).rejects.toThrow("未配置可用的审稿模型")
+    expect(streamChatMock).not.toHaveBeenCalled()
+  })
+
+  it("throws when strict review is requested outside novel mode", async () => {
+    mocks.novelMode = false
+
+    await expect(reviewChapter("E:/Novel", "正文", 8, {
+      contextPack,
+      throwOnFailure: true,
+    })).rejects.toThrow("当前项目未启用小说模式")
+    expect(streamChatMock).not.toHaveBeenCalled()
+  })
+
+  it("aborts sibling chunk reviews after one chunk fails", async () => {
+    let callIndex = 0
+    let siblingSignal: AbortSignal | undefined
+    streamChatMock.mockImplementation(async (
+      _config: LlmConfig,
+      _messages: Array<{ role: string; content: string }>,
+      callbacks: StreamCallbacks,
+      signal?: AbortSignal,
+    ) => {
+      callIndex += 1
+      if (callIndex === 1) {
+        callbacks.onToken("[not-json]")
+        callbacks.onDone()
+        return
+      }
+      siblingSignal = signal
+      await new Promise((resolve) => setTimeout(resolve, 20))
+      callbacks.onToken("[]")
+      callbacks.onDone()
+    })
+
+    await expect(reviewChapter("E:/Novel", "正".repeat(9000), 8, {
+      contextPack,
+      throwOnFailure: true,
+    })).rejects.toThrow()
+    await new Promise((resolve) => setTimeout(resolve, 30))
+
+    expect(streamChatMock).toHaveBeenCalledTimes(2)
+    expect(siblingSignal?.aborted).toBe(true)
+  })
+
   it("uses the configured review reasoning effort instead of forcing high", async () => {
     mocks.novelConfig.reviewReasoningEffort = "low"
     streamChatMock.mockImplementation(async (

+ 37 - 5
src/lib/novel/review-adapter.ts

@@ -4,7 +4,6 @@ import type { ChatMessage } from "@/lib/llm-providers"
 import { useWikiStore } from "@/stores/wiki-store"
 import { getOutputLanguage, buildLanguageReminder } from "@/lib/output-language"
 import { contextPackToPrompt, buildContextPack, type ContextPack } from "./context-engine"
-import { buildCharacterAuraContext } from "./character-aura"
 import { resolveNovelModel } from "./model-resolver"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { rethrowIfUserAbort } from "@/lib/user-abort"
@@ -39,6 +38,11 @@ export interface ReviewChapterOptions extends NovelReviewCallbacks {
    * 场景序列、信息流、伏笔动作和结尾钩子,偏离按 error 标记。
    */
   planBlueprint?: string
+  /**
+   * 严格工作流需要区分“没有问题”和“审稿执行失败”。
+   * 普通手动审稿保持原有兼容行为,失败时仍返回空结果。
+   */
+  throwOnFailure?: boolean
 }
 
 /** 角色一致性相关的审查维度,用于 characterOnly 轻量审查模式 */
@@ -207,10 +211,16 @@ export async function reviewChapter(
     state.novelConfig,
     "review",
   )
-  if (!hasUsableLlm(llmConfig, state.providerConfigs)) return []
+  if (!hasUsableLlm(llmConfig, state.providerConfigs)) {
+    if (options.throwOnFailure) throw new Error("未配置可用的审稿模型")
+    return []
+  }
 
   const novelMode = state.novelMode
-  if (!novelMode) return []
+  if (!novelMode) {
+    if (options.throwOnFailure) throw new Error("当前项目未启用小说模式")
+    return []
+  }
 
   // 复用调用方已构建的 contextPack;没有才自行构建。
   const baseContextPack = options.contextPack ?? await buildContextPack(
@@ -224,6 +234,7 @@ export async function reviewChapter(
   // 这里把 chapterContent 加入 matchingText 重新匹配,确保审查阶段能看到初稿新角色的完整光环。
   let contextPack = baseContextPack
   try {
+    const { buildCharacterAuraContext } = await import("./character-aura")
     const draftCharacterAuras = await buildCharacterAuraContext(projectPath, baseContextPack.task, {
       matchingText: [
         baseContextPack.chapterGoal,
@@ -253,11 +264,16 @@ ${langReminder}`
   // 章节超长时分段审查,合并所有段的审查结果
   const chunks = splitChapterForReview(chapterContent)
   const stageThinking = new Map<string, string>()
+  const reviewController = new AbortController()
+  const reviewSignal = signal
+    ? combineSignals(signal, reviewController.signal)
+    : reviewController.signal
 
   try {
     // 并行审查所有分段,缩短超长章节审查时延
     const chunkResults = await Promise.all(chunks.map(async (chunk, i) => {
-      if (signal?.aborted) throw new Error("已停止生成")
+      try {
+      if (reviewSignal.aborted) throw new Error("已停止生成")
       const chunkContent = chunks.length > 1
         ? `【第${i + 1}段/共${chunks.length}段】\n${chunk}`
         : chunk
@@ -282,19 +298,25 @@ ${langReminder}`
         stageTitle,
         options,
         stageThinking,
-        signal,
+        reviewSignal,
         reviewReasoningEffort,
       )
 
       const jsonMatch = extractJsonArray(result)
       if (!jsonMatch) {
         console.warn(`[Novel Review] No JSON array found in chunk ${i + 1}:`, result.slice(0, 500))
+        if (options.throwOnFailure) {
+          throw new Error(`第 ${i + 1} 段审稿未返回结构化 JSON 结果`)
+        }
         return []
       }
 
       const parsed = JSON.parse(jsonMatch)
       if (!Array.isArray(parsed)) {
         console.warn(`[Novel Review] Parsed result is not an array in chunk ${i + 1}:`, parsed)
+        if (options.throwOnFailure) {
+          throw new Error(`第 ${i + 1} 段审稿结果不是 JSON 数组`)
+        }
         return []
       }
 
@@ -306,12 +328,19 @@ ${langReminder}`
         relatedMemory: String(item.relatedMemory || ""),
         suggestion: String(item.suggestion || ""),
       }))
+      } catch (error) {
+        reviewController.abort()
+        throw error
+      }
     }))
 
     return chunkResults.flat()
   } catch (err) {
     rethrowIfUserAbort(err, signal)
     console.error("[Novel Review] Failed:", err)
+    if (options.throwOnFailure) {
+      throw err instanceof Error ? err : new Error(String(err))
+    }
     return []
   }
 }
@@ -358,6 +387,7 @@ async function runReviewStage(
   let result = ""
   let reasoning = ""
   const renderThinking = () => {
+    if (signal?.aborted) return
     const combined = reasoning
       ? `${reasoning}${result ? `\n\n${result}` : ""}`
       : result
@@ -365,12 +395,14 @@ async function runReviewStage(
   }
   const streamCallbacks: StreamCallbacks = {
     onToken: (token: string) => {
+      if (signal?.aborted) return
       result += token
       renderThinking()
     },
     // 审稿模型多为推理模型,分阶段分析走 reasoning 通道:捕获后用于 thinking 展示,
     // 但不计入 result,最终 JSON 只从 content(result)解析,避免分析文字污染 JSON。
     onReasoningToken: (token: string) => {
+      if (signal?.aborted) return
       reasoning += token
       renderThinking()
     },

+ 53 - 0
src/lib/persist.spec.ts

@@ -99,6 +99,59 @@ describe("chat context summary persistence", () => {
     expect(JSON.parse(messageCall[1])[0].contextHubSnapshot).toEqual(contextHubSnapshot)
   })
 
+  it("writes an empty message file when an existing conversation is cleared", async () => {
+    await saveChatHistory("E:/Novel", [{
+      id: "chat-1",
+      title: "会话",
+      createdAt: 1,
+      updatedAt: 2,
+      deAiMode: false,
+    }], [])
+
+    const messageCall = fsMocks.writeFile.mock.calls.find(([path]) => path.endsWith("/.qmai/chats/chat-1.json"))
+    expect(messageCall).toBeDefined()
+    expect(JSON.parse(messageCall![1])).toEqual([])
+    expect(contextHubMocks.pruneSnapshots).toHaveBeenCalledWith("ai-chat", [])
+  })
+
+  it("queues overlapping saves so the newest chat state is not discarded", async () => {
+    const conversation = {
+      id: "chat-1",
+      title: "会话",
+      createdAt: 1,
+      updatedAt: 2,
+      deAiMode: false,
+    }
+    let releaseFirstManifestWrite: (() => void) | undefined
+    let manifestWriteCount = 0
+    fsMocks.writeFile.mockImplementation(async (path: string) => {
+      if (path.endsWith("/.qmai/conversations.json") && manifestWriteCount++ === 0) {
+        await new Promise<void>((resolve) => {
+          releaseFirstManifestWrite = resolve
+        })
+      }
+    })
+
+    const firstSave = saveChatHistory("E:/Novel", [conversation], [{
+      id: "old-message",
+      role: "assistant",
+      content: "旧消息",
+      timestamp: 1,
+      conversationId: "chat-1",
+    }])
+    await vi.waitFor(() => expect(releaseFirstManifestWrite).toBeTypeOf("function"))
+
+    const clearSave = saveChatHistory("E:/Novel", [conversation], [])
+    releaseFirstManifestWrite?.()
+    await Promise.all([firstSave, clearSave])
+
+    const messageWrites = fsMocks.writeFile.mock.calls.filter(([path]) =>
+      path.endsWith("/.qmai/chats/chat-1.json")
+    )
+    expect(messageWrites).toHaveLength(2)
+    expect(JSON.parse(messageWrites.at(-1)![1])).toEqual([])
+  })
+
   it("prunes AI chat snapshots using only references that were actually persisted", async () => {
     const stats = {
       hits: 1, refreshed: 0, failures: 0,

+ 17 - 19
src/lib/persist.ts

@@ -11,19 +11,17 @@ const RETRY_DELAY_MS = 500
 
 /**
  * 按项目路径的写入锁,防止同一项目的多个保存操作并发写入。
- * 当某个项目正在保存时,后续的保存请求会被跳过(最新数据会被下一次保存覆盖)。
+ * 当某个项目正在保存时,后续保存按调用顺序等待,确保最新状态不会丢失。
  */
 const saveLocks = new Map<string, Promise<void>>()
 
 /**
  * 获取指定项目的写入锁。
- * 如果已有保存操作在进行中,返回 null 表示跳过本次保存。
- * 否则返回一个 release 函数,调用后释放锁。
+ * 如果已有保存操作在进行中,等待它释放后再获取锁。
  */
-function acquireSaveLock(projectPath: string): (() => void) | null {
-  if (saveLocks.has(projectPath)) {
-    console.warn(`persist: 项目 ${projectPath} 正在保存中,跳过本次保存`)
-    return null
+async function acquireSaveLock(projectPath: string): Promise<() => void> {
+  while (saveLocks.has(projectPath)) {
+    await saveLocks.get(projectPath)
   }
   let release: () => void = () => {}
   const lock = new Promise<void>((resolve) => {
@@ -78,8 +76,7 @@ async function ensureDir(projectPath: string): Promise<void> {
 
 export async function saveReviewItems(projectPath: string, items: ReviewItem[]): Promise<void> {
   const pp = normalizePath(projectPath)
-  const release = acquireSaveLock(`review:${pp}`)
-  if (!release) return // 已有保存操作在进行中,跳过
+  const release = await acquireSaveLock(`review:${pp}`)
   try {
     await ensureDir(pp)
     await withRetry(
@@ -142,8 +139,7 @@ export async function saveChatHistory(
   runStates: ConversationRunStates = {},
 ): Promise<void> {
   const pp = normalizePath(projectPath)
-  const release = acquireSaveLock(`chat:${pp}`)
-  if (!release) return // 已有保存操作在进行中,跳过
+  const release = await acquireSaveLock(`chat:${pp}`)
   try {
     await ensureDir(pp)
 
@@ -157,22 +153,19 @@ export async function saveChatHistory(
     )
 
     // Save each conversation's messages separately
-    const byConversation = new Map<string, DisplayMessage[]>()
+    const byConversation = new Map<string, DisplayMessage[]>(
+      conversations.map((conversation) => [conversation.id, []]),
+    )
     const persistedSnapshotIds = new Set<string>()
     for (const msg of messages) {
-      const list = byConversation.get(msg.conversationId) ?? []
+      const list = byConversation.get(msg.conversationId)
+      if (!list) continue
       list.push(msg)
-      byConversation.set(msg.conversationId, list)
     }
 
     for (const [convId, msgs] of byConversation) {
       // Keep last N messages per conversation
       const toSave = msgs.slice(-(maxMessages || 100))
-      for (const message of toSave) {
-        if (message.contextHubSnapshot?.surface === "ai-chat") {
-          persistedSnapshotIds.add(message.contextHubSnapshot.id)
-        }
-      }
       await withRetry(
         () => writeFile(
           `${pp}/.qmai/chats/${convId}.json`,
@@ -180,6 +173,11 @@ export async function saveChatHistory(
         ),
         `saveChatHistory(chat:${convId})`,
       )
+      for (const message of toSave) {
+        if (message.contextHubSnapshot?.surface === "ai-chat") {
+          persistedSnapshotIds.add(message.contextHubSnapshot.id)
+        }
+      }
     }
 
     try {

+ 362 - 0
src/lib/project-store.integration.test.ts

@@ -0,0 +1,362 @@
+/**
+ * Tier 4 — real-FS integration tests for project-store per-project config persistence.
+ *
+ * Verifies that project-level configs (novelConfig, revisionFeedbackWindowConfig,
+ * sourceWatchConfig) are correctly persisted to project-directory files in .qmai/
+ * and correctly recovered when the global app store has no data (simulating reinstall).
+ */
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
+import { realFs, createTempProject, writeFileRaw, fileExists, readFileRaw } from "@/test-helpers/fs-temp"
+import type { NovelConfig, RevisionFeedbackWindowConfig, SourceWatchConfig, RerankConfig } from "@/stores/wiki-store"
+import type { McpConfig } from "@/lib/mcp/config"
+
+vi.mock("@/commands/fs", () => realFs)
+
+const inMemoryStore = new Map<string, unknown>()
+
+vi.mock("@/lib/web-store", () => ({
+  getStore: async () => ({
+    get: async <T>(key: string): Promise<T | null> => {
+      if (inMemoryStore.has(key)) return inMemoryStore.get(key) as T
+      return null
+    },
+    set: async (key: string, value: unknown): Promise<void> => {
+      inMemoryStore.set(key, value)
+    },
+    save: async (): Promise<void> => {},
+  }),
+}))
+
+import {
+  saveNovelConfig,
+  loadNovelConfig,
+  saveRerankConfig,
+  loadRerankConfig,
+  saveRevisionFeedbackWindowConfig,
+  loadRevisionFeedbackWindowConfig,
+  saveSourceWatchConfig,
+  loadSourceWatchConfig,
+  saveMcpConfig,
+  loadMcpConfig,
+} from "./project-store"
+
+let tmp: { path: string; cleanup: () => Promise<void> }
+
+beforeEach(async () => {
+  tmp = await createTempProject("project-store")
+  inMemoryStore.clear()
+})
+
+afterEach(async () => {
+  await tmp.cleanup()
+})
+
+function makeNovelConfig(overrides: Partial<NovelConfig> = {}): NovelConfig {
+  return {
+    contextTokenBudget: 200000,
+    recentSummaryWindow: 16,
+    searchTopK: 20,
+    chapterTargetChars: 3000,
+    autoIngestOnSave: true,
+    autoExtractOnImport: true,
+    reviewBeforeSave: false,
+    deepPreviousChaptersAnalysis: false,
+    deepChapterReview: true,
+    reviewReasoningEffort: "high",
+    writingModel: "claude-4-sonnet",
+    reviewModel: "claude-4-sonnet",
+    summaryModel: "claude-4-sonnet",
+    extractModel: "claude-4-sonnet",
+    communitySummaryEnabled: false,
+    communitySummaryInterval: 5,
+    communitySummaryAsync: false,
+    autoGenerateChapterTitle: true,
+    ...overrides,
+    defaultLlmModel: overrides.defaultLlmModel ?? "",
+    deAiModel: overrides.deAiModel ?? "",
+    deAiBatchConcurrency: overrides.deAiBatchConcurrency ?? 3,
+  }
+}
+
+function makeRevisionConfig(overrides: Partial<RevisionFeedbackWindowConfig> = {}): RevisionFeedbackWindowConfig {
+  return {
+    currentChapterIncludeShouldImprove: true,
+    previousChapterCarryEnabled: true,
+    lookbackChapterCount: 2,
+    lookbackIncludeMustFixOnly: true,
+    ...overrides,
+  }
+}
+
+function makeSourceWatchConfig(overrides: Partial<SourceWatchConfig> = {}): SourceWatchConfig {
+  return {
+    enabled: true,
+    autoIngest: true,
+    includeExtensions: ["md", "txt"],
+    excludeExtensions: ["tmp", "swp"],
+    excludeDirs: [".git", "node_modules"],
+    excludeGlobs: ["~$*"],
+    maxFileSizeMb: 100,
+    ...overrides,
+  }
+}
+
+function makeRerankConfig(overrides: Partial<RerankConfig> = {}): RerankConfig {
+  return {
+    enabled: true,
+    useMainLlm: false,
+    provider: "custom",
+    apiKey: "rerank-key",
+    model: "rerank-model",
+    ollamaUrl: "http://127.0.0.1:11434",
+    customEndpoint: "http://127.0.0.1:1234/v1",
+    apiMode: "chat_completions",
+    maxCandidates: 14,
+    ...overrides,
+  }
+}
+function makeMcpConfig(): McpConfig {
+  return {
+    servers: [{
+      id: "graph",
+      name: "Knowledge Graph",
+      enabled: true,
+      tools: [{
+        serverId: "graph",
+        serverName: "Knowledge Graph",
+        name: "query_graph",
+        description: "Query graph",
+        operation: "read",
+        inputSchema: {
+          type: "object",
+          properties: { query: { type: "string", description: "Query" } },
+          required: ["query"],
+        },
+      }],
+    }],
+  }
+}
+
+describe("novelConfig — project-directory persistence", () => {
+  it("round-trip save then load returns identical config", async () => {
+    const config = makeNovelConfig({ contextTokenBudget: 100000, searchTopK: 15 })
+    await saveNovelConfig(config, "proj-1", tmp.path)
+    const loaded = await loadNovelConfig("proj-1", tmp.path)
+    expect(loaded).toEqual(config)
+  })
+
+  it("persists to .qmai/novel-config.json", async () => {
+    await saveNovelConfig(makeNovelConfig(), "proj-2", tmp.path)
+    expect(await fileExists(`${tmp.path}/.qmai/novel-config.json`)).toBe(true)
+    const raw = await readFileRaw(`${tmp.path}/.qmai/novel-config.json`)
+    const parsed = JSON.parse(raw)
+    expect(parsed.contextTokenBudget).toBe(200000)
+  })
+
+  it("returns null when neither file nor store has data", async () => {
+    const loaded = await loadNovelConfig("proj-none", tmp.path)
+    expect(loaded).toBeNull()
+  })
+
+  it("reinstall recovery: loads from project dir when global store is empty", async () => {
+    const config = makeNovelConfig({ searchTopK: 15, writingModel: "custom-model" })
+    await writeFileRaw(
+      `${tmp.path}/.qmai/novel-config.json`,
+      JSON.stringify(config),
+    )
+    const loaded = await loadNovelConfig("proj-r", tmp.path)
+    expect(loaded?.searchTopK).toBe(15)
+    expect(loaded?.writingModel).toBe("custom-model")
+  })
+
+  it("project dir file wins over global store (reinstall scenario)", async () => {
+    inMemoryStore.set("projectNovelConfigs", { "proj-w": makeNovelConfig({ searchTopK: 5 }) })
+    const dirConfig = makeNovelConfig({ searchTopK: 18, writingModel: "dir-model" })
+    await writeFileRaw(
+      `${tmp.path}/.qmai/novel-config.json`,
+      JSON.stringify(dirConfig),
+    )
+    const loaded = await loadNovelConfig("proj-w", tmp.path)
+    expect(loaded?.searchTopK).toBe(18)
+    expect(loaded?.writingModel).toBe("dir-model")
+  })
+
+  it("migrates: when loaded from global store, writes to project dir", async () => {
+    const config = makeNovelConfig({ searchTopK: 12, writingModel: "migrated-model" })
+    inMemoryStore.set("projectNovelConfigs", { "proj-m": config })
+    const loaded = await loadNovelConfig("proj-m", tmp.path)
+    expect(loaded?.searchTopK).toBe(12)
+    expect(loaded?.writingModel).toBe("migrated-model")
+    expect(await fileExists(`${tmp.path}/.qmai/novel-config.json`)).toBe(true)
+    const raw = await readFileRaw(`${tmp.path}/.qmai/novel-config.json`)
+    expect(JSON.parse(raw).searchTopK).toBe(12)
+  })
+
+  it("fills in autoExtractOnImport when loading legacy novel config", async () => {
+    const legacyConfig = {
+      contextTokenBudget: 50000,
+      recentSummaryWindow: 6,
+      searchTopK: 8,
+      autoIngestOnSave: false,
+      reviewBeforeSave: true,
+      writingModel: "writer",
+      reviewModel: "reviewer",
+      summaryModel: "summarizer",
+      extractModel: "extractor",
+    }
+    await writeFileRaw(
+      `${tmp.path}/.qmai/novel-config.json`,
+      JSON.stringify(legacyConfig),
+    )
+    const loaded = await loadNovelConfig("proj-legacy", tmp.path)
+    expect(loaded?.autoExtractOnImport).toBe(true)
+  })
+})
+
+describe("revisionFeedbackWindowConfig — project-directory persistence", () => {
+  it("round-trip save then load returns identical config", async () => {
+    const config = makeRevisionConfig({ lookbackChapterCount: 5 })
+    await saveRevisionFeedbackWindowConfig(config, "proj-3", tmp.path)
+    const loaded = await loadRevisionFeedbackWindowConfig("proj-3", tmp.path)
+    expect(loaded).toEqual(config)
+  })
+
+  it("persists to .qmai/revision-feedback-config.json", async () => {
+    await saveRevisionFeedbackWindowConfig(makeRevisionConfig(), "proj-4", tmp.path)
+    expect(await fileExists(`${tmp.path}/.qmai/revision-feedback-config.json`)).toBe(true)
+  })
+
+  it("returns defaults when neither file nor store has data", async () => {
+    const loaded = await loadRevisionFeedbackWindowConfig("proj-none", tmp.path)
+    expect(loaded).toEqual({
+      currentChapterIncludeShouldImprove: true,
+      previousChapterCarryEnabled: true,
+      lookbackChapterCount: 2,
+      lookbackIncludeMustFixOnly: true,
+    })
+  })
+
+  it("reinstall recovery: loads from project dir when global store is empty", async () => {
+    const config = makeRevisionConfig({ lookbackChapterCount: 8 })
+    await writeFileRaw(
+      `${tmp.path}/.qmai/revision-feedback-config.json`,
+      JSON.stringify(config),
+    )
+    const loaded = await loadRevisionFeedbackWindowConfig("proj-r2", tmp.path)
+    expect(loaded?.lookbackChapterCount).toBe(8)
+  })
+
+  it("project dir file wins over global store", async () => {
+    inMemoryStore.set("projectRevisionFeedbackWindowConfigs", {
+      "proj-x": makeRevisionConfig({ lookbackChapterCount: 1 }),
+    })
+    const dirConfig = makeRevisionConfig({ lookbackChapterCount: 10 })
+    await writeFileRaw(
+      `${tmp.path}/.qmai/revision-feedback-config.json`,
+      JSON.stringify(dirConfig),
+    )
+    const loaded = await loadRevisionFeedbackWindowConfig("proj-x", tmp.path)
+    expect(loaded?.lookbackChapterCount).toBe(10)
+  })
+
+  it("migrates: when loaded from global store, writes to project dir", async () => {
+    const config = makeRevisionConfig({ lookbackChapterCount: 6 })
+    inMemoryStore.set("projectRevisionFeedbackWindowConfigs", { "proj-m2": config })
+    const loaded = await loadRevisionFeedbackWindowConfig("proj-m2", tmp.path)
+    expect(loaded?.lookbackChapterCount).toBe(6)
+    expect(await fileExists(`${tmp.path}/.qmai/revision-feedback-config.json`)).toBe(true)
+  })
+})
+
+describe("sourceWatchConfig — project-directory persistence", () => {
+  it("round-trip save then load returns identical config", async () => {
+    const config = makeSourceWatchConfig({ enabled: true, maxFileSizeMb: 50 })
+    await saveSourceWatchConfig(config, "proj-5", tmp.path)
+    const loaded = await loadSourceWatchConfig("proj-5", tmp.path)
+    expect(loaded).toEqual(config)
+  })
+
+  it("persists to .qmai/source-watch-config.json", async () => {
+    await saveSourceWatchConfig(makeSourceWatchConfig(), "proj-6", tmp.path)
+    expect(await fileExists(`${tmp.path}/.qmai/source-watch-config.json`)).toBe(true)
+  })
+
+  it("returns defaults when neither file nor store has data", async () => {
+    const loaded = await loadSourceWatchConfig("proj-none", tmp.path)
+    expect(loaded.enabled).toBe(true)
+    expect(loaded.autoIngest).toBe(true)
+  })
+
+  it("reinstall recovery: loads from project dir when global store is empty", async () => {
+    const config = makeSourceWatchConfig({ enabled: true, maxFileSizeMb: 42 })
+    await writeFileRaw(
+      `${tmp.path}/.qmai/source-watch-config.json`,
+      JSON.stringify(config),
+    )
+    const loaded = await loadSourceWatchConfig("proj-r3", tmp.path)
+    expect(loaded.enabled).toBe(true)
+    expect(loaded.maxFileSizeMb).toBe(42)
+  })
+
+  it("project dir file wins over global store", async () => {
+    inMemoryStore.set("sourceWatchConfig", { "proj-y": makeSourceWatchConfig({ enabled: false }) })
+    const dirConfig = makeSourceWatchConfig({ enabled: true })
+    await writeFileRaw(
+      `${tmp.path}/.qmai/source-watch-config.json`,
+      JSON.stringify(dirConfig),
+    )
+    const loaded = await loadSourceWatchConfig("proj-y", tmp.path)
+    expect(loaded.enabled).toBe(true)
+  })
+
+  it("migrates: when loaded from global store, writes to project dir", async () => {
+    const config = makeSourceWatchConfig({ enabled: true, maxFileSizeMb: 99 })
+    inMemoryStore.set("sourceWatchConfig", { "proj-m3": config })
+    const loaded = await loadSourceWatchConfig("proj-m3", tmp.path)
+    expect(loaded.enabled).toBe(true)
+    expect(await fileExists(`${tmp.path}/.qmai/source-watch-config.json`)).toBe(true)
+  })
+})
+describe("rerankConfig persistence", () => {
+  it("round-trip save then load returns identical config", async () => {
+    const config = makeRerankConfig({ maxCandidates: 9, useMainLlm: true })
+    await saveRerankConfig(config, "rerank-1", tmp.path)
+    const loaded = await loadRerankConfig("rerank-1", tmp.path)
+    expect(loaded).toEqual(config)
+  })
+
+  it("persists to .qmai/rerank-config.json", async () => {
+    await saveRerankConfig(makeRerankConfig(), "rerank-2", tmp.path)
+    expect(await fileExists(`${tmp.path}/.qmai/rerank-config.json`)).toBe(true)
+  })
+
+  it("reinstall recovery: loads from project dir when global store is empty", async () => {
+    const config = makeRerankConfig({ model: "local-rerank", maxCandidates: 18 })
+    await writeFileRaw(
+      `${tmp.path}/.qmai/rerank-config.json`,
+      JSON.stringify(config),
+    )
+    const loaded = await loadRerankConfig("rerank-r", tmp.path)
+    expect(loaded?.model).toBe("local-rerank")
+    expect(loaded?.maxCandidates).toBe(18)
+  })
+})
+
+describe("mcpConfig persistence", () => {
+  it("round-trip save then load returns normalized MCP config", async () => {
+    const config = makeMcpConfig()
+
+    await saveMcpConfig(config)
+    const loaded = await loadMcpConfig()
+
+    expect(loaded).toEqual(config)
+  })
+
+  it("normalizes invalid stored MCP config to an empty config", async () => {
+    inMemoryStore.set("mcpConfig", { servers: "bad" })
+
+    const loaded = await loadMcpConfig()
+
+    expect(loaded).toEqual({ servers: [] })
+  })
+})

+ 131 - 0
src/stores/de-ai-task-store.spec.ts

@@ -0,0 +1,131 @@
+import { beforeEach, describe, expect, it } from "vitest"
+import { selectProjectDeAiTasks, useDeAiTaskStore } from "./de-ai-task-store"
+
+describe("de-ai task store project isolation", () => {
+  beforeEach(() => {
+    useDeAiTaskStore.setState({
+      tasks: [],
+      reviewByProject: {},
+    })
+  })
+
+  it("selects only tasks that belong to the current project", () => {
+    const firstId = useDeAiTaskStore.getState().startTask({
+      projectPath: "C:/Novel-A",
+      chapterPath: "C:/Novel-A/wiki/chapters/1.md",
+      chapterTitle: "第一章",
+      skillId: "skill-a",
+      skillName: "自然叙事",
+      skillContent: "保留角色语气",
+      modelName: "model-a",
+      sourceContent: "原文 A",
+    })
+    useDeAiTaskStore.getState().startTask({
+      projectPath: "C:/Novel-B",
+      chapterPath: "C:/Novel-B/wiki/chapters/1.md",
+      chapterTitle: "第一章",
+      skillId: "skill-b",
+      skillName: "克制表达",
+      skillContent: "减少解释",
+      modelName: "model-b",
+      sourceContent: "原文 B",
+    })
+
+    const selected = selectProjectDeAiTasks(useDeAiTaskStore.getState().tasks, "c:\\Novel-A\\")
+
+    expect(selected.map((task) => task.id)).toEqual([firstId])
+  })
+
+  it("keeps case-sensitive project paths isolated on macOS and Linux", () => {
+    const upperId = useDeAiTaskStore.getState().startTask({
+      projectPath: "/Books/Novel",
+      chapterPath: "/Books/Novel/wiki/chapters/1.md",
+      chapterTitle: "第一章",
+      skillId: null,
+      skillName: "默认",
+      skillContent: "",
+      modelName: "model-a",
+      sourceContent: "项目 A",
+    })
+    const lowerId = useDeAiTaskStore.getState().startTask({
+      projectPath: "/Books/novel",
+      chapterPath: "/Books/novel/wiki/chapters/1.md",
+      chapterTitle: "第一章",
+      skillId: null,
+      skillName: "默认",
+      skillContent: "",
+      modelName: "model-b",
+      sourceContent: "项目 B",
+    })
+
+    expect(selectProjectDeAiTasks(useDeAiTaskStore.getState().tasks, "/Books/Novel").map((task) => task.id)).toEqual([upperId])
+    expect(selectProjectDeAiTasks(useDeAiTaskStore.getState().tasks, "/Books/novel").map((task) => task.id)).toEqual([lowerId])
+
+    useDeAiTaskStore.getState().finishTask(upperId, "候选 A")
+    useDeAiTaskStore.getState().finishTask(lowerId, "候选 B")
+    expect(Object.keys(useDeAiTaskStore.getState().reviewByProject)).toEqual(
+      expect.arrayContaining(["/Books/Novel", "/Books/novel"]),
+    )
+  })
+
+  it("retains the selected skill content for regeneration", () => {
+    const taskId = useDeAiTaskStore.getState().startTask({
+      projectPath: "C:/Novel",
+      chapterPath: "C:/Novel/wiki/chapters/1.md",
+      chapterTitle: "第一章",
+      skillId: "skill-a",
+      skillName: "自然叙事",
+      skillContent: "保留角色语气和叙事节奏",
+      modelName: "model-a",
+      sourceContent: "原文",
+    })
+
+    const task = useDeAiTaskStore.getState().tasks.find((item) => item.id === taskId)
+    expect(task?.skillContent).toBe("保留角色语气和叙事节奏")
+  })
+
+  it("keeps review visibility and confirmed-task cleanup isolated by project", () => {
+    const firstId = useDeAiTaskStore.getState().startTask({
+      projectPath: "C:/Novel-A",
+      chapterPath: "C:/Novel-A/wiki/chapters/1.md",
+      chapterTitle: "第一章",
+      skillId: "skill-a",
+      skillName: "自然叙事",
+      skillContent: "保留角色语气",
+      modelName: "model-a",
+      sourceContent: "原文 A",
+    })
+    const secondId = useDeAiTaskStore.getState().startTask({
+      projectPath: "C:/Novel-B",
+      chapterPath: "C:/Novel-B/wiki/chapters/1.md",
+      chapterTitle: "第一章",
+      skillId: "skill-b",
+      skillName: "克制表达",
+      skillContent: "减少解释",
+      modelName: "model-b",
+      sourceContent: "原文 B",
+    })
+
+    useDeAiTaskStore.getState().finishTask(firstId, "候选 A")
+    useDeAiTaskStore.getState().finishTask(secondId, "候选 B")
+
+    const reviewByProject = (useDeAiTaskStore.getState() as unknown as {
+      reviewByProject?: Record<string, { open: boolean; chapterId: string | null }>
+    }).reviewByProject
+    expect(reviewByProject?.["c:/novel-a"]).toEqual({ open: true, chapterId: firstId })
+    expect(reviewByProject?.["c:/novel-b"]).toEqual({ open: true, chapterId: secondId })
+
+    useDeAiTaskStore.getState().confirmTask(firstId)
+    useDeAiTaskStore.getState().confirmTask(secondId)
+    useDeAiTaskStore.getState().closeReview("C:/Novel-A")
+
+    const state = useDeAiTaskStore.getState()
+    expect(state.tasks.some((task) => task.id === firstId)).toBe(false)
+    expect(state.tasks.some((task) => task.id === secondId)).toBe(true)
+    const reviews = (state as unknown as {
+      reviewByProject?: Record<string, { open: boolean; chapterId: string | null }>
+    }).reviewByProject
+    expect(reviews?.["c:/novel-a"]?.open).toBe(false)
+    expect(reviews?.["c:/novel-b"]?.open).toBe(true)
+  })
+})

+ 102 - 29
src/stores/de-ai-task-store.ts

@@ -11,6 +11,7 @@ export interface DeAiTask {
   status: DeAiTaskStatus
   skillId: string | null
   skillName: string
+  skillContent: string
   modelName: string
   sourceContent: string
   candidateContent: string
@@ -25,14 +26,19 @@ export interface DeAiTaskStartInput {
   chapterTitle: string
   skillId: string | null
   skillName: string
+  skillContent: string
   modelName: string
   sourceContent: string
 }
 
+export interface DeAiProjectReviewState {
+  open: boolean
+  chapterId: string | null
+}
+
 export interface DeAiTaskState {
   tasks: DeAiTask[]
-  reviewOpen: boolean
-  reviewChapterId: string | null
+  reviewByProject: Record<string, DeAiProjectReviewState>
   startTask: (input: DeAiTaskStartInput) => string
   updateTask: (taskId: string, patch: Partial<DeAiTask>) => void
   finishTask: (taskId: string, candidateContent: string) => void
@@ -40,19 +46,43 @@ export interface DeAiTaskState {
   cancelTask: (taskId: string) => void
   confirmTask: (taskId: string) => void
   removeTask: (taskId: string) => void
-  clearConfirmed: () => void
-  openReview: (taskId?: string) => void
-  closeReview: () => void
-  setReviewChapter: (taskId: string | null) => void
+  clearConfirmed: (projectPath: string) => void
+  openReview: (projectPath: string, taskId?: string) => void
+  closeReview: (projectPath: string) => void
+  setReviewChapter: (projectPath: string, taskId: string | null) => void
   isChapterProcessing: (chapterPath: string) => boolean
 }
 
 let deAiTaskCounter = 0
 
+function normalizedProjectPath(path: string): string {
+  const normalized = normalizePath(path).replace(/\/$/, "")
+  const isWindowsPath = /^[A-Za-z]:\//.test(normalized) || normalized.startsWith("//")
+  return isWindowsPath ? normalized.toLowerCase() : normalized
+}
+
+const CLOSED_PROJECT_REVIEW: DeAiProjectReviewState = {
+  open: false,
+  chapterId: null,
+}
+
+export function selectProjectDeAiTasks(tasks: DeAiTask[], projectPath?: string | null): DeAiTask[] {
+  if (!projectPath) return []
+  const projectKey = normalizedProjectPath(projectPath)
+  return tasks.filter((task) => normalizedProjectPath(task.projectPath) === projectKey)
+}
+
+export function selectProjectDeAiReview(
+  state: Pick<DeAiTaskState, "reviewByProject">,
+  projectPath?: string | null,
+): DeAiProjectReviewState {
+  if (!projectPath) return CLOSED_PROJECT_REVIEW
+  return state.reviewByProject[normalizedProjectPath(projectPath)] ?? CLOSED_PROJECT_REVIEW
+}
+
 export const useDeAiTaskStore = create<DeAiTaskState>((set, get) => ({
   tasks: [],
-  reviewOpen: false,
-  reviewChapterId: null,
+  reviewByProject: {},
 
   startTask: (input) => {
     const now = Date.now()
@@ -87,17 +117,23 @@ export const useDeAiTaskStore = create<DeAiTaskState>((set, get) => ({
   finishTask: (taskId, candidateContent) => {
     const now = Date.now()
     set((state) => {
+      const finishedTask = state.tasks.find((task) => task.id === taskId)
       const tasks = state.tasks.map((task) =>
         task.id === taskId
           ? { ...task, status: "ready" as const, candidateContent, updatedAt: now }
           : task
       )
-      const hasReady = tasks.some((t) => t.status === "ready")
-      const shouldOpenReview = hasReady && !state.reviewOpen
+      if (!finishedTask) return { tasks }
+      const projectKey = normalizedProjectPath(finishedTask.projectPath)
+      const currentReview = state.reviewByProject[projectKey] ?? CLOSED_PROJECT_REVIEW
       return {
         tasks,
-        reviewOpen: shouldOpenReview ? true : state.reviewOpen,
-        reviewChapterId: shouldOpenReview ? taskId : state.reviewChapterId,
+        reviewByProject: {
+          ...state.reviewByProject,
+          [projectKey]: currentReview.open
+            ? currentReview
+            : { open: true, chapterId: taskId },
+        },
       }
     })
   },
@@ -133,36 +169,73 @@ export const useDeAiTaskStore = create<DeAiTaskState>((set, get) => ({
   },
 
   removeTask: (taskId) => {
-    set((state) => ({
-      tasks: state.tasks.filter((task) => task.id !== taskId),
-      reviewChapterId: state.reviewChapterId === taskId ? null : state.reviewChapterId,
-    }))
+    set((state) => {
+      const task = state.tasks.find((item) => item.id === taskId)
+      if (!task) return state
+      const projectKey = normalizedProjectPath(task.projectPath)
+      const currentReview = state.reviewByProject[projectKey]
+      return {
+        tasks: state.tasks.filter((item) => item.id !== taskId),
+        reviewByProject: currentReview?.chapterId === taskId
+          ? {
+              ...state.reviewByProject,
+              [projectKey]: { ...currentReview, chapterId: null },
+            }
+          : state.reviewByProject,
+      }
+    })
   },
 
-  clearConfirmed: () => {
+  clearConfirmed: (projectPath) => {
+    const projectKey = normalizedProjectPath(projectPath)
     set((state) => ({
-      tasks: state.tasks.filter((task) => task.status !== "confirmed"),
+      tasks: state.tasks.filter((task) =>
+        task.status !== "confirmed" || normalizedProjectPath(task.projectPath) !== projectKey
+      ),
     }))
   },
 
-  openReview: (taskId) => {
-    const tasks = get().tasks
+  openReview: (projectPath, taskId) => {
+    const projectKey = normalizedProjectPath(projectPath)
+    const tasks = selectProjectDeAiTasks(get().tasks, projectPath)
     const firstReady = tasks.find((t) => t.status === "ready")
-    set({
-      reviewOpen: true,
-      reviewChapterId: taskId ?? firstReady?.id ?? tasks[0]?.id ?? null,
-    })
+    const requestedTask = taskId ? tasks.find((task) => task.id === taskId) : null
+    set((state) => ({
+      reviewByProject: {
+        ...state.reviewByProject,
+        [projectKey]: {
+          open: true,
+          chapterId: requestedTask?.id ?? firstReady?.id ?? tasks[0]?.id ?? null,
+        },
+      },
+    }))
   },
 
-  closeReview: () => {
+  closeReview: (projectPath) => {
+    const projectKey = normalizedProjectPath(projectPath)
     set((state) => ({
-      reviewOpen: false,
-      // 关闭时清除已确认的任务
-      tasks: state.tasks.filter((task) => task.status !== "confirmed"),
+      reviewByProject: {
+        ...state.reviewByProject,
+        [projectKey]: CLOSED_PROJECT_REVIEW,
+      },
+      tasks: state.tasks.filter((task) =>
+        task.status !== "confirmed" || normalizedProjectPath(task.projectPath) !== projectKey
+      ),
     }))
   },
 
-  setReviewChapter: (taskId) => set({ reviewChapterId: taskId }),
+  setReviewChapter: (projectPath, taskId) => {
+    const projectKey = normalizedProjectPath(projectPath)
+    set((state) => ({
+      reviewByProject: {
+        ...state.reviewByProject,
+        [projectKey]: {
+          open: state.reviewByProject[projectKey]?.open ?? true,
+          chapterId: taskId,
+        },
+      },
+    }))
+  },
 
   isChapterProcessing: (chapterPath) => {
     const normalized = normalizePath(chapterPath)

+ 138 - 0
src/test-helpers/fs-temp.ts

@@ -0,0 +1,138 @@
+/**
+ * Real-filesystem adapter for @/commands/fs. Tests that want to exercise
+ * the actual JSON / path round-trip (rather than a vi.mock memory stub)
+ * should call `installRealFs()` then use a per-test temp directory via
+ * `createTempProject()`.
+ *
+ * The Tauri fs commands are replaced with implementations backed by
+ * node:fs/promises, operating on whatever path the caller passes. The
+ * caller is responsible for using a temp-dir root to prevent tests from
+ * touching each other's state.
+ */
+import fs from "node:fs/promises"
+import path from "node:path"
+import os from "node:os"
+import type { FileNode } from "@/types/wiki"
+
+async function buildTree(dir: string): Promise<FileNode[]> {
+  let entries
+  try {
+    entries = await fs.readdir(dir, { withFileTypes: true })
+  } catch {
+    return []
+  }
+
+  const nodes: FileNode[] = []
+  for (const entry of entries) {
+    const full = path.join(dir, entry.name).replace(/\\/g, "/")
+    if (entry.isDirectory()) {
+      nodes.push({
+        name: entry.name,
+        path: full,
+        is_dir: true,
+        children: await buildTree(full),
+      } as FileNode)
+    } else {
+      nodes.push({
+        name: entry.name,
+        path: full,
+        is_dir: false,
+        children: [],
+      } as FileNode)
+    }
+  }
+  return nodes
+}
+
+/**
+ * Implementations of the @/commands/fs API backed by node:fs.
+ * Import this from a test file that calls `vi.mock("@/commands/fs", ...)`.
+ */
+export const realFs = {
+  readFile: async (p: string): Promise<string> => {
+    return fs.readFile(p, "utf-8")
+  },
+  writeFile: async (p: string, contents: string): Promise<void> => {
+    await fs.mkdir(path.dirname(p), { recursive: true })
+    await fs.writeFile(p, contents, "utf-8")
+  },
+  writeFileAtomic: async (p: string, contents: string): Promise<void> => {
+    await fs.mkdir(path.dirname(p), { recursive: true })
+    const tmp = p + ".tmp-" + Date.now() + "-" + Math.random().toString(36).slice(2)
+    await fs.writeFile(tmp, contents, "utf-8")
+    await fs.rename(tmp, p)
+  },
+  listDirectory: async (p: string): Promise<FileNode[]> => {
+    return buildTree(p)
+  },
+  copyFile: async (source: string, destination: string): Promise<void> => {
+    await fs.mkdir(path.dirname(destination), { recursive: true })
+    await fs.copyFile(source, destination)
+  },
+  preprocessFile: async (p: string): Promise<string> => {
+    return fs.readFile(p, "utf-8")
+  },
+  deleteFile: async (p: string): Promise<void> => {
+    await fs.unlink(p).catch(() => {})
+  },
+  findRelatedWikiPages: async (): Promise<string[]> => {
+    return []
+  },
+  createDirectory: async (p: string): Promise<void> => {
+    await fs.mkdir(p, { recursive: true })
+  },
+  createProject: async () => {
+    throw new Error("createProject not supported in tests")
+  },
+  openProject: async () => {
+    throw new Error("openProject not supported in tests")
+  },
+  fileExists: async (p: string): Promise<boolean> => {
+    try {
+      await fs.access(p)
+      return true
+    } catch {
+      return false
+    }
+  },
+}
+
+/**
+ * Create a fresh unique temp directory for a single test. Returns the
+ * absolute path (with forward slashes) and a cleanup function.
+ */
+export async function createTempProject(label: string = "proj"): Promise<{
+  path: string
+  cleanup: () => Promise<void>
+}> {
+  const prefix = path.join(os.tmpdir(), `llmwiki-${label}-`)
+  const dir = await fs.mkdtemp(prefix)
+  const normalized = dir.replace(/\\/g, "/")
+  return {
+    path: normalized,
+    cleanup: async () => {
+      await fs.rm(normalized, { recursive: true, force: true }).catch(() => {})
+    },
+  }
+}
+
+/** Read a file directly (bypasses the mocked API) — used in assertions. */
+export async function readFileRaw(p: string): Promise<string> {
+  return fs.readFile(p, "utf-8")
+}
+
+/** Write a file directly (bypasses the mocked API) — used in fixture setup. */
+export async function writeFileRaw(p: string, contents: string): Promise<void> {
+  await fs.mkdir(path.dirname(p), { recursive: true })
+  await fs.writeFile(p, contents, "utf-8")
+}
+
+/** Check if a file exists on disk. */
+export async function fileExists(p: string): Promise<boolean> {
+  try {
+    await fs.access(p)
+    return true
+  } catch {
+    return false
+  }
+}

+ 15 - 10
vite.config.ts

@@ -38,6 +38,21 @@ export default defineConfig(async () => ({
       output: {
         manualChunks(id: string) {
           if (id.includes("node_modules")) {
+            if (id.includes("opencc-js")) {
+              return "opencc-vendor"
+            }
+            if (id.includes("pinyin-pro")) {
+              return "pinyin-vendor"
+            }
+            if (id.includes("js-yaml")) {
+              return "yaml-vendor"
+            }
+            if (id.includes("@react-sigma") || id.includes("sigma")) {
+              return "sigma-vendor"
+            }
+            if (id.includes("graphology")) {
+              return "graphology-vendor"
+            }
             if (id.includes("react") || id.includes("scheduler")) {
               return "react-vendor"
             }
@@ -50,16 +65,6 @@ export default defineConfig(async () => ({
             if (id.includes("cytoscape")) {
               return "cytoscape-vendor"
             }
-            if (id.includes("@react-sigma") || id.includes("sigma")) {
-              return "sigma-vendor"
-            }
-            if (id.includes("graphology")) {
-              return "graphology-vendor"
-            }
-          }
-          // Split large book-analysis modules into their own chunk
-          if (id.includes("/src/lib/novel/book-analysis/")) {
-            return "book-analysis-vendor"
           }
           return undefined
         },

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott