Explorar o código

feat: 多 Agent 批量去 AI 味队列、断点继续与三栏审核弹窗

Mochocyang hai 2 meses
pai
achega
0ab03f807e
Modificáronse 35 ficheiros con 4513 adicións e 4 borrados
  1. 232 0
      piliangquaiwei-分支说明.md
  2. 2 0
      src/components/layout/app-layout.tsx
  3. 38 4
      src/components/layout/preview-panel.tsx
  4. 38 0
      src/components/novel/de-ai-batch-entry.spec.ts
  5. 214 0
      src/components/novel/de-ai-batch-review-dialog.spec.tsx
  6. 227 0
      src/components/novel/de-ai-batch-review-dialog.tsx
  7. 92 0
      src/components/novel/de-ai-batch-start-dialog.spec.tsx
  8. 154 0
      src/components/novel/de-ai-batch-start-dialog.tsx
  9. 157 0
      src/components/novel/de-ai-batch-task-panel.spec.tsx
  10. 132 0
      src/components/novel/de-ai-batch-task-panel.tsx
  11. 206 0
      src/components/novel/de-ai-batch-workspace.tsx
  12. 17 0
      src/components/settings/sections/novel-section.tsx
  13. 67 0
      src/lib/chapter-external-update-coordinator.ts
  14. 20 0
      src/lib/editor-external-update-session.spec.ts
  15. 18 0
      src/lib/editor-external-update-session.ts
  16. 51 0
      src/lib/novel/de-ai-batch/catalog.spec.ts
  17. 70 0
      src/lib/novel/de-ai-batch/catalog.ts
  18. 204 0
      src/lib/novel/de-ai-batch/chapter-apply.spec.ts
  19. 57 0
      src/lib/novel/de-ai-batch/chapter-apply.ts
  20. 73 0
      src/lib/novel/de-ai-batch/chapter-path.ts
  21. 187 0
      src/lib/novel/de-ai-batch/engine.spec.ts
  22. 208 0
      src/lib/novel/de-ai-batch/engine.ts
  23. 46 0
      src/lib/novel/de-ai-batch/latest-value-save-queue.spec.ts
  24. 44 0
      src/lib/novel/de-ai-batch/latest-value-save-queue.ts
  25. 109 0
      src/lib/novel/de-ai-batch/llm-runner.spec.ts
  26. 99 0
      src/lib/novel/de-ai-batch/llm-runner.ts
  27. 205 0
      src/lib/novel/de-ai-batch/scheduler.spec.ts
  28. 155 0
      src/lib/novel/de-ai-batch/scheduler.ts
  29. 210 0
      src/lib/novel/de-ai-batch/storage.spec.ts
  30. 255 0
      src/lib/novel/de-ai-batch/storage.ts
  31. 77 0
      src/lib/novel/de-ai-batch/types.ts
  32. 1 0
      src/lib/project-store.ts
  33. 390 0
      src/stores/de-ai-batch-store.spec.ts
  34. 455 0
      src/stores/de-ai-batch-store.ts
  35. 3 0
      src/stores/wiki-store.ts

+ 232 - 0
piliangquaiwei-分支说明.md

@@ -0,0 +1,232 @@
+# piliangquaiwei 分支说明
+
+## 分支目标
+
+实现“多 Agent 批量去 AI 味”:支持多作品、多章节、作品级独立 Agent、默认并发 3(可设置 1–5)、FIFO 排队、失败隔离、持久化、重启后手动继续,以及逐章三栏审核。
+
+## 基线与约束
+
+- 分支:`piliangquaiwei`
+- 基线:`d1d02d5`
+- 工作区:`C:\QMAI_C\QMAI-main\.worktrees\piliangquaiwei`
+- 保留现有单章 `runWholeChapterDeAi` 与 `DeAiPreviewDialog` 流程。
+- 本分支只处理批量去 AI 味相关领域层、Store、任务面板、审核/开始弹窗、最小主布局与小说设置接入。
+- 不打包、不提交、不合并。
+
+## 已实现内容
+
+1. 新增 `src/lib/novel/de-ai-batch/`:
+   - `types.ts`:任务与章节状态、持久化记录类型。
+   - `storage.ts`:任务、章节检查点、不可变原文副本原子持久化;重启时 `running` 转 `interrupted`,生成中的章节回到可继续状态。
+   - `engine.ts`:逐章模型调用、候选持久化、章节失败隔离、当前章取消、重新生成保留旧候选、runId 防陈旧结果覆盖、dispose 阻止后续章节启动。
+   - `scheduler.ts`:作品级独立 Agent、默认并发 3、范围 1–5、FIFO、槽位释放、失败隔离、指定任务取消与晚到结果忽略。
+   - `catalog.ts`:从 `wiki/chapters` 一级子目录识别多作品并生成安全 ID;无子目录时使用当前项目作为作品。
+   - `llm-runner.ts`:复用现有 `deAi` 模型解析、去 AI 味消息与 AbortSignal。
+2. 新增 `src/stores/de-ai-batch-store.ts`:
+   - 初始化只加载,不自动恢复。
+   - 新批次、多作品入队、interrupted 手动继续。
+   - 跳过 ready/confirmed 章节。
+   - 指定章重新生成、取消、确认写回;整批取消;完成任务禁止整批继续。
+   - 审核弹窗与任务面板 UI 状态。
+3. 新增批量 UI:
+   - `de-ai-batch-task-panel.tsx`:中文状态、章节计数、折叠、继续、审核、中文取消确认、独立滚动。
+   - `de-ai-batch-start-dialog.tsx`:多作品/多章节选择,并发 1–5,视口约束与滚动。
+   - `de-ai-batch-review-dialog.tsx`:宽屏左章节/中原文/右候选三栏;窄屏章节选择 + 原文/候选页签;独立滚动、固定头尾、逐章确认/重新生成/取消;关闭只隐藏弹窗。
+   - `de-ai-batch-workspace.tsx`:主布局入口、任务面板、开始/审核弹窗与 Store 接线。
+4. 最小接入:
+   - `app-layout.tsx` 挂载批量工作区。
+   - `NovelConfig` 新增 `deAiBatchConcurrency`,默认 3,持久化归一化到 1–5。
+   - 小说设置新增中文并发项。
+
+## TDD RED / GREEN 证据
+
+- Storage:RED 为缺少 `./storage`;GREEN 4/4。
+- Engine:RED 为缺少 `./engine`;GREEN 5/5;后续取消链路 RED 为 dispose 后继续启动下一章导致超时,修复后 Engine + Store 13/13。
+- Scheduler:RED 为缺少 `./scheduler`;GREEN 5/5;指定任务取消 RED 为 `cancel is not a function`;晚到完成回调 RED 为收到 2 次完成回调,最终 Scheduler 7/7。
+- Store:RED 为缺少 `./de-ai-batch-store`;GREEN 7/7。
+- 任务面板:RED 为组件缺失;GREEN 5/5。
+- 审核弹窗:RED 为组件缺失;GREEN 6/6。
+- 入口/设置:Catalog、LLM runner、开始弹窗模块缺失且入口/设置断言失败;最终 4 文件 7/7。
+- 最终专项:10 文件,42/42 通过。
+
+## 验证结果
+
+- 源码启动:Vite 8.1.3,302ms ready,`http://127.0.0.1:1420` 返回 HTTP 200;随后仅终止本次启动的进程。
+- 专项测试:10 文件,42/42 通过。
+- 现有去 AI 味/章节/设置回归(排除已确认基线失败文件):49 文件,413 通过,6 todo。
+- `npm run typecheck`:通过。
+- `npm run build`:通过;仅有既有动态导入/大 chunk 警告。
+- `npm run test:mocks`:296 文件中 291 通过、2067 测试通过、6 todo;5 个未改动基线文件共 39 项失败:
+  - `src/lib/agent/tools/run-chapter-workflow.spec.ts`
+  - `src/lib/novel/deep-chapter-generation.spec.ts`
+  - `src/lib/novel/outline-generation.spec.ts`
+  - `src/components/sources/outline-workbench-integration.spec.ts`
+  - `src/components/layout/workspace-top-bars.spec.ts`
+  Git 已确认这些失败测试及对应实现相对本分支基线无差异,本分支未擅自修复无关模块。
+- 打包:按用户要求未执行。
+- Git:未提交、未合并。
+
+## 风险与限制
+
+- 多作品目录按 `wiki/chapters` 下一级子目录识别;章节直接位于该目录时归为当前项目作品。
+- 自动化测试使用注入 runner 验证并发、取消与持久化;真实模型质量和供应商限流仍取决于用户配置。
+- 完整 mocks 仍有上述 5 个既有基线失败文件,未纳入本功能修改范围。
+
+## 当前状态
+
+功能实施与要求内验证已完成,未提交。
+
+## 2026-07-13 规格复审修复
+
+### 修复内容
+
+1. **排队任务重启恢复**
+   - Storage 初始化现在同时把持久化 `running` 和 `queued` 转为 `interrupted`。
+   - `queued` 使用中文提示“软件上次关闭时任务尚未开始,可继续处理”。
+   - 原文副本和 pending 章节检查点保持不变,用户可通过现有“继续”操作无需重导恢复。
+
+2. **确认写回与编辑器安全同步**
+   - 新增 `src/lib/novel/de-ai-batch/chapter-apply.ts`,统一通过 `replaceWholeChapterBody` 只替换正文,保留磁盘/编辑器中的 frontmatter、章节标题和文件结构。
+   - 新增 `src/lib/editor-external-update-session.ts`,由 PreviewPanel 注册当前打开章节处理器。
+   - 当前章节打开时:递增 `saveGenerationRef`、清除旧延迟保存、原子写盘,然后同步 `fileContentRef`、`fileContent`、`diskSyncEpoch` 和 `dataVersion`,避免旧自动保存覆盖确认结果。
+   - 未打开章节时:读取当前磁盘内容、合并候选正文后原子写盘。
+
+3. **任务模型真实绑定**
+   - 创建任务按“去 AI 味专用模型 → 项目默认模型 → 聊天模型”解析稳定 `providerId/modelId` key,并写入 `task.modelKey`。
+   - 不持久化 API key;执行和恢复时按 `task.modelKey` 从当前 provider 配置重新读取凭据。
+   - Runner 不再使用当前 `deAi` 模型作为静默 fallback。
+   - 恢复初始化和手动继续前校验绑定;模型被删除或不可用时,以中文错误标记任务失败:“任务绑定模型……已不可用,请重新配置该模型后再继续”。
+
+4. **并发设置即时生效**
+   - Workspace 增加依赖 `[novelConfig.deAiBatchConcurrency]` 的独立 effect。
+   - 设置变化会立即调用当前 Store/Scheduler 的 `setConcurrency`,无需切换或重新打开项目。
+
+### 本轮 TDD 证据
+
+- queued 恢复:RED 为加载后仍是 `queued`;修复后 Storage + Store 12/12 GREEN。
+- 安全写回:RED 为 `chapter-apply` 模块和 PreviewPanel 外部更新处理器不存在;修复后章节应用、入口与 Store 12/12 GREEN。
+- 模型绑定:RED 包括 Runner 解析器未收到 task、稳定 key/绑定解析函数缺失、恢复任务未标记失败;修复后相关 3 文件 15/15 GREEN。
+- 并发 effect:RED 为 Workspace 未依赖 `novelConfig.deAiBatchConcurrency`;修复后入口与 Store 11/11 GREEN。
+
+### 本轮验证
+
+- 专项:11 文件,51/51 通过。
+- 相关旧去 AI 味/章节/设置/PreviewPanel 回归:50 文件,419 通过,6 todo。
+- `npm run typecheck`:通过。
+- `npm run build`:通过,5073 模块转换完成;仅保留既有动态导入和大 chunk 警告。
+- 源码启动:Vite 8.1.3,242ms ready,`http://127.0.0.1:1420` 返回 HTTP 200。
+- 完整 mocks:297 文件中 292 通过;2076 测试通过,6 todo;仍为同一 5 个未改动基线文件共 39 项失败,没有新增失败文件。
+- 打包:未执行。
+- Git:未提交、未合并。
+
+## 2026-07-13 确认写回竞态复审修复
+
+### 问题
+
+开始确认写回章节 A 时 A 仍在编辑器中打开;`await writeFileAtomic(A)` 期间用户切换到章节 B。旧实现写盘完成后仍无条件执行 `commitEditor(A)`,会把 A 的内容提交到 B 的编辑器内存。
+
+### 修复
+
+- `applyOpenChapterBodyUpdate` 不再使用调用开始时的静态 `openPath`。
+- 改为传入 `currentOpenPath()` 动态读取当前编辑器路径。
+- 开始时确认目标章节仍打开;在写盘完成后再次读取并校验当前路径。
+- 只有目标 A 仍为当前打开章节时才调用 `commitEditor(A)`。
+- 如果已切换到 B:
+  - A 的原子磁盘写入正常完成;
+  - `dataVersion` 正常更新;
+  - 不调用 `commitEditor`;
+  - 不修改 B 的 `fileContent`、ref 或编辑器内存。
+- `invalidatePendingSave` 仍在 `await writeFileAtomic` 之前执行,因此旧自动保存 generation 继续失效,不能把 A 的旧正文重新写回。
+
+### TDD 证据
+
+- 新增时序测试:写 A 时用 Promise gate 暂停磁盘写入,期间把当前 openPath 切换到 B,再释放写入。
+- RED:旧实现把 A 合并结果写入模拟的 B 编辑器。
+- GREEN:A 磁盘包含候选正文,B 内存保持原值,`commitEditor` 调用 0 次,`dataVersion` 更新 1 次,旧保存 generation 从 11 变为 12 且无法覆盖 A。
+
+### 验证
+
+- 专项:11 文件,52/52 通过。
+- 相关旧测试:50 文件,419 通过,6 todo。
+- `npm run typecheck`:通过。
+- `npm run build`:通过,5073 模块转换完成。
+- 源码启动:Vite 8.1.3,261ms ready,HTTP 200。
+- 完整 mocks:297 文件中 292 通过;2077 测试通过,6 todo;仍为同一 5 个未改动基线文件共 39 项失败,没有新增失败文件。
+- `git diff --check`:通过。
+- Git:未提交、未合并。
+- 打包:未执行。
+
+## 2026-07-13 flush-before-leave 竞态复审修复
+
+### 问题
+
+外部确认 A 等待原子写盘期间,真实 `selectedFile` 切换到 B 会触发 `flushChapterBeforeLeave(A, previousContent)`。该 flush 使用切换前捕获的旧 A 内容,可能在外部候选写入之后再次写盘,导致 A 候选被旧内容覆盖。
+
+### 修复
+
+新增 `src/lib/chapter-external-update-coordinator.ts`,为每个章节路径维护:
+
+- 外部更新版本号;
+- 当前编辑器会话版本号;
+- 活动外部更新 token;
+- 每路径串行写入队列。
+
+完整规则:
+
+1. 外部确认开始时同步提升 A 的路径版本,再进入 A 的串行写队列。
+2. PreviewPanel 每次从磁盘加载/提交编辑器内容时记录该路径当前编辑器会话版本。
+3. `flushChapterBeforeLeave` 捕获调用时的编辑器会话版本,并通过同一 A 路径写队列执行。
+4. 轮到 flush 时再次检查:
+   - 仍有外部更新活动则跳过;
+   - 路径当前版本与旧编辑器会话版本不一致则跳过。
+5. 如果 flush 已经先开始,外部确认会排在同一路径队列之后,因此最终仍由候选结果落盘。
+6. 外部写失败时版本回滚,原有未保存编辑的 flush 可以继续,不会丢失正常编辑。
+7. 没有外部更新时,编辑器会话版本与路径版本一致,正常离开章节仍执行 flush。
+
+### TDD 证据
+
+- 新增真实时序测试:外部确认 A 的写盘用 Promise gate 暂停,期间切换到 B,并调用 `flushBeforeLeave(A, oldA)`。
+- RED:协调器不存在,无法保护独立 flush 写链。
+- GREEN:
+  - A 最终磁盘内容包含候选正文;
+  - 旧 A flush 回调调用 0 次;
+  - B 编辑器内容保持不变;
+  - `commitEditor(A)` 调用 0 次。
+- 新增正常编辑保护测试:无外部更新时 `flushBeforeLeave` 返回 true 且写回调用 1 次。
+
+### 验证
+
+- 专项:11 文件,54/54 通过。
+- 相关旧测试:50 文件,419 通过,6 todo。
+- `npm run typecheck`:通过。
+- `npm run build`:通过,5074 模块转换完成。
+- 源码启动:Vite 8.1.3,228ms ready,HTTP 200。
+- 完整 mocks:297 文件中 292 通过;2079 测试通过,6 todo;仍为同一 5 个未改动基线文件共 39 项失败,没有新增失败文件。
+- Git:未提交、未合并。
+- 打包:未执行。
+
+## 2026-07-13 代码质量复审修复
+
+### 修复内容
+
+1. `sourcePath` 安全边界:新增跨平台绝对路径解析与真实 `wiki/chapters` 后代校验,在任务创建、checkpoint 加载、最终确认写回三处执行;拒绝 `..`、目录前缀伪装、跨盘、非同源 UNC、扩展设备路径与大小写前缀绕过。
+2. Scheduler 取消语义:活动任务取消只标记并 abort,不提前释放并发槽;等待原 `run` settle 后清理,并使用 `active.get(id) === entry` 身份检查防旧实例清除新实例。
+3. 章节操作并发:确认、重新生成、取消增加每章同步互斥锁与 generation/candidate CAS;重复确认只写回一次,操作期间拒绝同章冲突动作,避免已确认状态被陈旧结果回滚。
+4. 批次启动防重:Workspace 与开始弹窗均使用同步 ref 锁;`starting` 时禁用作品/章节选择、并发输入、提交、取消和弹窗关闭;补双击回归测试。
+5. Workspace 异步安全:统一异步 action pending key、中文错误 toast 和 catch;任务及章节 pending 时禁用对应按钮;并发设置持久化也进入统一捕获,避免未处理 Promise rejection。
+6. `continueTask` 仅允许 `interrupted`、`failed`、`partial` 状态。
+7. 编辑器外部更新注册返回身份 disposer,旧组件清理不会注销后注册的新 handler。
+8. 移动端审核 tabs 增加 `id`、`aria-controls`、`aria-labelledby`、`tabpanel`、roving `tabIndex` 和左右键切换/焦点移动;弹窗在极小高度下允许外层滚动,操作区仍可到达。
+
+### TDD 与验证
+
+- RED:新增测试分别复现 sourcePath 越界/篡改、取消提前释放槽、非白名单继续、重复确认与同章冲突、旧 disposer 清新 handler、开始按钮双击、pending 未禁用、tabs ARIA/键盘缺失和极小高度不可达。
+- GREEN 专项:12 文件,75/75 通过。
+- 相关旧测试:PreviewPanel、去 AI 味技能选择器、去 AI 味技能库 3 文件,59/59 通过。
+- `npm run typecheck`:通过。
+- `npm run build`:通过,5075 模块转换完成;仅保留既有动态导入和大 chunk 警告。
+- 源码启动:Vite 8.1.3,237ms ready,`http://127.0.0.1:1420` 返回 HTTP 200,随后停止本次启动任务。
+- 完整 mocks:298 文件中 293 通过;2100 测试通过、6 todo;仍为同一 5 个未改动基线文件共 39 项失败,没有新增失败文件。
+- `git diff --check`:通过。
+- 打包:按用户要求未执行。
+- Git:未提交、未合并。

+ 2 - 0
src/components/layout/app-layout.tsx

@@ -10,6 +10,7 @@ import { ErrorBoundary } from "@/components/error-boundary"
 import { clampSidebarWidth } from "@/lib/workspace-layout"
 import { useTranslation } from "react-i18next"
 import { HelpCircle, X } from "lucide-react"
+import { DeAiBatchWorkspace } from "@/components/novel/de-ai-batch-workspace"
 
 const USAGE_GUIDE_PROMPT_DISMISSED_KEY = "qmai-usage-guide-prompt-dismissed"
 
@@ -144,6 +145,7 @@ export function AppLayout({ onSwitchProject }: AppLayoutProps) {
 
   return (
     <div className="flex h-screen flex-col bg-background text-foreground">
+      <DeAiBatchWorkspace />
       {/* 拆书分析后台任务通知 */}
       {latestBookAnalysisTask && (
         <div className="fixed bottom-3 right-3 z-50 w-80 rounded-lg border bg-background p-3 shadow-lg">

+ 38 - 4
src/components/layout/preview-panel.tsx

@@ -1,4 +1,4 @@
-import { type CSSProperties, Suspense, lazy, useEffect, useCallback, useRef, useMemo, useState, useLayoutEffect } from "react"
+import { type CSSProperties, Suspense, lazy, useEffect, useCallback, useRef, useMemo, useState, useLayoutEffect } from "react"
 import { useTranslation } from "react-i18next"
 import { Check, MoreHorizontal, X } from "lucide-react"
 import { useWikiStore } from "@/stores/wiki-store"
@@ -54,6 +54,9 @@ import {
 } from "@/lib/chapter-selection"
 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"
 
 const SnapshotViewer = lazy(async () => {
   const mod = await import("@/components/novel/snapshot-viewer")
@@ -181,6 +184,7 @@ export function PreviewPanel() {
   const outlineTasks = useOutlineGenerationStore((s) => s.tasks)
   const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
   const saveGenerationRef = useRef(0)
+  const chapterExternalUpdateCoordinator = useMemo(() => createChapterExternalUpdateCoordinator(), [])
   const wikiEditorRef = useRef<WikiEditorHandle>(null)
   const [isSavingFinal, setIsSavingFinal] = useState(false)
   const [saveStatus, setSaveStatus] = useState<string>("")
@@ -241,7 +245,8 @@ export function PreviewPanel() {
     const key = normalizePath(path)
     lastLoadedRef.current = markdown
     lastLoadedByPathRef.current.set(key, markdown)
-  }, [])
+    chapterExternalUpdateCoordinator.markEditorSession(key)
+  }, [chapterExternalUpdateCoordinator])
 
   const applyDiskSyncIfSafe = useCallback(async (path: string): Promise<boolean> => {
     const normalizedPath = normalizePath(path)
@@ -284,6 +289,33 @@ export function PreviewPanel() {
     await applyDiskSyncIfSafe(path)
   }, [applyDiskSyncIfSafe])
 
+  const applyExternalChapterBody = useCallback(async (path: string, candidateContent: string): Promise<boolean> => {
+    const normalizedPath = normalizePath(path)
+    return applyOpenChapterBodyUpdate({
+      path: normalizedPath,
+      candidateContent,
+      currentOpenPath: () => selectedFileRef.current,
+      currentMarkdown: () => wikiEditorRef.current?.getCurrentMarkdown() ?? fileContentRef.current,
+      invalidatePendingSave: () => {
+        saveGenerationRef.current += 1
+        if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
+        saveTimerRef.current = null
+      },
+      runExternalUpdate: chapterExternalUpdateCoordinator.runExternalUpdate,
+      markEditorSession: chapterExternalUpdateCoordinator.markEditorSession,
+      writeFileAtomic,
+      commitEditor: (markdown) => {
+        rememberLoadedChapter(normalizedPath, markdown)
+        fileContentRef.current = markdown
+        setFileContent(markdown)
+        setDiskSyncEpoch((epoch) => epoch + 1)
+      },
+      bumpDataVersion,
+    })
+  }, [bumpDataVersion, chapterExternalUpdateCoordinator, rememberLoadedChapter, setFileContent])
+
+  useEffect(() => registerEditorExternalUpdateHandler(applyExternalChapterBody), [applyExternalChapterBody])
+
   useEffect(() => {
     registerEditorDiskSyncHandler(applyDiskSyncIfSafe)
     return () => registerEditorDiskSyncHandler(null)
@@ -371,11 +403,13 @@ export function PreviewPanel() {
     const resolvedMarkdown = resolveChapterFlushMarkdown(path, markdown, lastLoadedByPathRef.current)
     if (!shouldSyncChapterOnLeave(path, markdown, lastLoadedForPath)) return
     try {
-      await syncChapterToCanonicalPath(path, resolvedMarkdown, { renameToCanonical: false })
+      await chapterExternalUpdateCoordinator.flushBeforeLeave(path, async () => {
+        await syncChapterToCanonicalPath(path, resolvedMarkdown, { renameToCanonical: false })
+      })
     } catch (err) {
       console.error("切换章节前同步文件失败:", err)
     }
-  }, [syncChapterToCanonicalPath, finalChapterSave])
+  }, [chapterExternalUpdateCoordinator, syncChapterToCanonicalPath, finalChapterSave])
 
   useEffect(() => {
     let cancelled = false

+ 38 - 0
src/components/novel/de-ai-batch-entry.spec.ts

@@ -0,0 +1,38 @@
+import { readFileSync } from "node:fs"
+import { describe, expect, it } from "vitest"
+
+const appLayout = readFileSync("src/components/layout/app-layout.tsx", "utf8")
+const previewPanel = readFileSync("src/components/layout/preview-panel.tsx", "utf8")
+const batchWorkspace = readFileSync("src/components/novel/de-ai-batch-workspace.tsx", "utf8")
+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")
+    expect(previewPanel).toContain("runWholeChapterDeAi")
+    expect(previewPanel).toContain("registerEditorExternalUpdateHandler")
+    expect(previewPanel).toContain("applyOpenChapterBodyUpdate")
+    expect(previewPanel).toContain("chapterExternalUpdateCoordinator.flushBeforeLeave(path")
+    expect(batchWorkspace).toContain("resolveDeAiBatchModelKey")
+    expect(batchWorkspace).not.toContain("`${llmConfig.provider}/${llmConfig.model}`")
+    expect(batchWorkspace).toContain("}, [novelConfig.deAiBatchConcurrency])") // CONCURRENCY_EFFECT_MARKER
+    expect(batchWorkspace).toContain("startingRef.current")
+    expect(batchWorkspace).toContain("runWorkspaceAction")
+    expect(batchWorkspace).toContain("createLatestValueSaveQueue")
+    expect(batchWorkspace).not.toContain('runWorkspaceAction("config:concurrency"')
+    expect(batchWorkspace).toContain("pendingTaskIds.has(reviewTaskId)")
+    expect(batchWorkspace).toContain("操作失败")
+    expect(batchWorkspace).not.toMatch(/void useDeAiBatchStore\.getState\(\)\.(?:continueTask|cancelTask|confirmChapter|regenerateChapter|cancelChapter)/)
+  })
+
+  it("小说设置包含默认 3、范围 1–5 的批量并发设置", () => {
+    expect(wikiStore).toContain("deAiBatchConcurrency: 3")
+    expect(novelSection).toContain('min={1}')
+    expect(novelSection).toContain('max={5}')
+    expect(novelSection).toContain("deAiBatchConcurrency")
+  })
+})
+
+

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

@@ -0,0 +1,214 @@
+// @vitest-environment jsdom
+
+import { act, type ComponentPropsWithoutRef } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import type { DeAiBatchTaskRecord } from "@/lib/novel/de-ai-batch/types"
+import { DeAiBatchReviewDialog } from "./de-ai-batch-review-dialog"
+vi.mock("@/components/ui/resizable", () => ({
+  ResizablePanelGroup: ({ direction: _direction, ...props }: ComponentPropsWithoutRef<"div"> & { direction?: string }) => <div {...props} />,
+  ResizablePanel: ({ defaultSize: _defaultSize, minSize: _minSize, ...props }: ComponentPropsWithoutRef<"div"> & { defaultSize?: number; minSize?: number }) => <div {...props} />,
+  ResizableHandle: ({ withHandle: _withHandle, ...props }: ComponentPropsWithoutRef<"div"> & { withHandle?: boolean }) => <div {...props} />,
+}))
+
+;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+class ResizeObserverStub {
+  observe() {}
+  unobserve() {}
+  disconnect() {}
+}
+;(globalThis as typeof globalThis & { ResizeObserver: typeof ResizeObserver }).ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver
+
+function record(): DeAiBatchTaskRecord {
+  return {
+    task: {
+      version: 1,
+      id: "task-a",
+      projectPath: "C:/project",
+      workId: "work-a",
+      workTitle: "作品甲",
+      modelKey: "openai/test",
+      skillId: null,
+      skillName: "默认",
+      skillContent: "规则",
+      status: "reviewing",
+      chapterIds: ["chapter-1", "chapter-2"],
+      error: null,
+      createdAt: 1,
+      startedAt: 2,
+      completedAt: null,
+      updatedAt: 3,
+    },
+    chapters: [
+      {
+        version: 1,
+        id: "chapter-1",
+        taskId: "task-a",
+        title: "第一章",
+        order: 1,
+        sourcePath: "C:/project/第一章.md",
+        sourceContent: "第一章原文",
+        candidateContent: "第一章候选",
+        status: "ready",
+        runId: null,
+        generation: 1,
+        error: null,
+        createdAt: 1,
+        updatedAt: 2,
+      },
+      {
+        version: 1,
+        id: "chapter-2",
+        taskId: "task-a",
+        title: "第二章",
+        order: 2,
+        sourcePath: "C:/project/第二章.md",
+        sourceContent: "第二章原文",
+        candidateContent: "第二章旧候选",
+        status: "generating",
+        runId: "run-2",
+        generation: 2,
+        error: null,
+        createdAt: 1,
+        updatedAt: 3,
+      },
+    ],
+  }
+}
+
+function callbacks() {
+  return {
+    onSelectChapter: vi.fn(),
+    onConfirm: vi.fn(),
+    onRegenerate: vi.fn(),
+    onCancelChapter: vi.fn(),
+    onClose: vi.fn(),
+  }
+}
+
+let host: HTMLDivElement
+let root: Root
+
+beforeEach(() => {
+  host = document.createElement("div")
+  document.body.appendChild(host)
+  root = createRoot(host)
+})
+
+afterEach(() => {
+  act(() => root.unmount())
+  host.remove()
+  vi.restoreAllMocks()
+})
+
+function render(currentChapterId = "chapter-1", props: ReturnType<typeof callbacks> & { pending?: boolean } = callbacks(), source = record()) {
+  act(() => {
+    root.render(
+      <DeAiBatchReviewDialog
+        open
+        record={source}
+        currentChapterId={currentChapterId}
+        {...props}
+      />,
+    )
+  })
+  return props
+}
+
+function button(label: string): HTMLButtonElement {
+  const found = Array.from(document.body.querySelectorAll("button")).find((item) => item.textContent?.trim() === label || item.textContent?.trim().startsWith(label))
+  expect(found, `找不到按钮:${label}`).toBeTruthy()
+  return found as HTMLButtonElement
+}
+
+describe("DeAiBatchReviewDialog", () => {
+  it("宽屏提供左章节、中原文、右候选三栏和各自滚动区", () => {
+    render()
+
+    expect(document.body.textContent).toContain("第一章原文")
+    expect(document.body.textContent).toContain("第一章候选")
+    const desktop = document.body.querySelector('[data-testid="de-ai-review-desktop"]')
+    expect(desktop?.className).toContain("md:flex")
+    expect(desktop?.querySelectorAll('[data-testid="de-ai-review-scroll"]')).toHaveLength(3)
+  })
+
+  it("弹窗受视口约束且头部、正文、底部不越界", () => {
+    render()
+
+    const content = document.body.querySelector('[data-testid="de-ai-review-dialog"]')
+    expect(content?.className).toContain("max-h-[calc(100dvh-2rem)]")
+    expect(content?.className).toContain("overflow-y-auto")
+    expect(content?.className).toContain("max-w-[calc(100vw-2rem)]")
+    expect(document.body.querySelector('[data-testid="de-ai-review-body"]')?.className).toContain("min-h-24")
+  })
+
+  it("窄屏保留章节选择并用原文/候选页签降级", () => {
+    render()
+
+    const mobile = document.body.querySelector('[data-testid="de-ai-review-mobile"]')
+    expect(mobile?.className).toContain("md:hidden")
+    expect(mobile?.textContent).toContain("原文")
+    expect(mobile?.textContent).toContain("候选")
+    expect(mobile?.querySelector('[aria-label="选择审核章节"]')).not.toBeNull()
+  })
+
+  it("章节切换与逐章确认、重新生成、取消只传当前章", () => {
+    const props = render()
+    act(() => button("第二章").click())
+    expect(props.onSelectChapter).toHaveBeenCalledWith("chapter-2")
+
+    act(() => button("确认当前章").click())
+    act(() => button("重新生成").click())
+    vi.spyOn(window, "confirm").mockReturnValue(true)
+    act(() => button("取消当前章").click())
+
+    expect(props.onConfirm).toHaveBeenCalledWith("task-a", "chapter-1")
+    expect(props.onRegenerate).toHaveBeenCalledWith("task-a", "chapter-1")
+    expect(window.confirm).toHaveBeenCalledWith("确定取消“第一章”的本次处理吗?原文不会被修改。")
+    expect(props.onCancelChapter).toHaveBeenCalledWith("task-a", "chapter-1")
+  })
+
+  it("生成中保留旧候选并禁用确认和重复重新生成", () => {
+    render("chapter-2")
+
+    expect(document.body.textContent).toContain("第二章旧候选")
+    expect(document.body.textContent).toContain("生成中")
+    expect(button("确认当前章").disabled).toBe(true)
+    expect(button("重新生成").disabled).toBe(true)
+  })
+
+  it("关闭弹窗只调用 onClose,不取消后台章节", () => {
+    const props = render()
+
+    act(() => button("关闭").click())
+
+    expect(props.onClose).toHaveBeenCalledTimes(1)
+    expect(props.onCancelChapter).not.toHaveBeenCalled()
+  })
+
+  it("移动端 tabs 具备完整 ARIA 关联、roving tabindex 与左右键切换", () => {
+    render()
+    const tabs = Array.from(document.body.querySelectorAll<HTMLButtonElement>('[role="tab"]'))
+    const panels = Array.from(document.body.querySelectorAll<HTMLElement>('[role="tabpanel"]'))
+    expect(tabs).toHaveLength(2)
+    expect(panels).toHaveLength(1)
+    expect(tabs[0]).toMatchObject({ tabIndex: 0 })
+    expect(tabs[1]).toMatchObject({ tabIndex: -1 })
+    expect(tabs[0].getAttribute("aria-controls")).toBe(panels[0].id)
+    expect(panels[0].getAttribute("aria-labelledby")).toBe(tabs[0].id)
+    act(() => tabs[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })))
+    expect(tabs[1].getAttribute("aria-selected")).toBe("true")
+    expect(document.activeElement).toBe(tabs[1])
+    expect(panels[0].getAttribute("aria-labelledby")).toBe(tabs[1].id)
+    act(() => tabs[1].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true })))
+    expect(tabs[0].getAttribute("aria-selected")).toBe("true")
+  })
+
+  it("极小高度可滚动到操作区且 pending 时禁用章节操作", () => {
+    render(undefined, { ...callbacks(), pending: true })
+    const content = document.body.querySelector('[data-testid="de-ai-review-dialog"]')
+    expect(content?.className).toContain("overflow-y-auto")
+    expect(button("确认当前章").disabled).toBe(true)
+    expect(button("重新生成").disabled).toBe(true)
+    expect(button("取消当前章").disabled).toBe(true)
+  })})

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

@@ -0,0 +1,227 @@
+import { useState } from "react"
+import { Button } from "@/components/ui/button"
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog"
+import {
+  ResizableHandle,
+  ResizablePanel,
+  ResizablePanelGroup,
+} from "@/components/ui/resizable"
+import type { DeAiBatchChapter, DeAiBatchTaskRecord } from "@/lib/novel/de-ai-batch/types"
+
+const CHAPTER_STATUS: Record<DeAiBatchChapter["status"], string> = {
+  pending: "等待中",
+  generating: "生成中",
+  ready: "待确认",
+  confirmed: "已确认",
+  failed: "失败",
+  cancelled: "已取消",
+}
+
+export interface DeAiBatchReviewDialogProps {
+  open: boolean
+  record: DeAiBatchTaskRecord | null
+  currentChapterId: string | null
+  pending?: boolean
+  onSelectChapter(chapterId: string): void
+  onConfirm(taskId: string, chapterId: string): void
+  onRegenerate(taskId: string, chapterId: string): void
+  onCancelChapter(taskId: string, chapterId: string): void
+  onClose(): void
+}
+
+function ChapterList({
+  record,
+  currentChapterId,
+  onSelectChapter,
+}: Pick<DeAiBatchReviewDialogProps, "record" | "currentChapterId" | "onSelectChapter">) {
+  if (!record) return null
+  return (
+    <div data-testid="de-ai-review-scroll" className="h-full min-h-0 overflow-y-auto p-2">
+      <div className="space-y-1">
+        {record.chapters.map((chapter) => (
+          <button
+            key={chapter.id}
+            type="button"
+            aria-current={chapter.id === currentChapterId ? "true" : undefined}
+            className={`w-full rounded-md px-3 py-2 text-left text-sm ${
+              chapter.id === currentChapterId ? "bg-primary/10 text-primary" : "hover:bg-muted"
+            }`}
+            onClick={() => onSelectChapter(chapter.id)}
+          >
+            <span className="block truncate font-medium">{chapter.title}</span>
+            <span className="mt-0.5 block text-xs text-muted-foreground">{CHAPTER_STATUS[chapter.status]}</span>
+          </button>
+        ))}
+      </div>
+    </div>
+  )
+}
+
+function TextPane({ title, content, emptyText }: { title: string; content: string | null; emptyText: string }) {
+  return (
+    <div className="flex h-full min-h-0 flex-col">
+      <div className="shrink-0 border-b px-3 py-2 text-xs font-medium text-muted-foreground">{title}</div>
+      <div data-testid="de-ai-review-scroll" className="min-h-0 flex-1 overflow-y-auto p-4 text-sm leading-7 whitespace-pre-wrap">
+        {content || <span className="text-muted-foreground">{emptyText}</span>}
+      </div>
+    </div>
+  )
+}
+
+export function DeAiBatchReviewDialog({
+  open,
+  record,
+  currentChapterId,
+  pending = false,
+  onSelectChapter,
+  onConfirm,
+  onRegenerate,
+  onCancelChapter,
+  onClose,
+}: DeAiBatchReviewDialogProps) {
+  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 canRegenerate = !pending && !!chapter && chapter.status !== "generating" && chapter.status !== "confirmed"
+  const canCancel = !pending && !!chapter && chapter.status !== "confirmed" && chapter.status !== "cancelled"
+
+  function handleMobileTabKey(event: React.KeyboardEvent<HTMLButtonElement>): void {
+    if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return
+    event.preventDefault()
+    const nextTab = mobileTab === "source" ? "candidate" : "source"
+    setMobileTab(nextTab)
+    document.getElementById(`de-ai-review-tab-${nextTab}`)?.focus()
+  }
+  return (
+    <Dialog open={open} onOpenChange={(next) => { if (!next) onClose() }}>
+      <DialogContent
+        data-testid="de-ai-review-dialog"
+        showCloseButton={false}
+        className="flex h-[min(52rem,calc(100dvh-2rem))] max-h-[calc(100dvh-2rem)] w-[min(90rem,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-x-hidden overflow-y-auto p-0 sm:max-w-[calc(100vw-2rem)]"
+      >
+        <DialogHeader className="shrink-0 border-b px-4 py-3">
+          <DialogTitle>批量去 AI 味审核</DialogTitle>
+          <DialogDescription>
+            {record?.task.workTitle ?? "未选择作品"}
+            {chapter ? ` · ${chapter.title} · ${CHAPTER_STATUS[chapter.status]}` : ""}
+          </DialogDescription>
+        </DialogHeader>
+
+        <div data-testid="de-ai-review-body" className="min-h-24 flex-1 overflow-hidden">
+          <ResizablePanelGroup data-testid="de-ai-review-desktop" direction="horizontal" className="hidden h-full md:flex">
+            <ResizablePanel defaultSize={20} minSize={14}>
+              <ChapterList record={record} currentChapterId={chapter?.id ?? null} onSelectChapter={onSelectChapter} />
+            </ResizablePanel>
+            <ResizableHandle withHandle />
+            <ResizablePanel defaultSize={40} minSize={24}>
+              <TextPane title="原文" content={chapter?.sourceContent ?? null} emptyText="暂无原文副本" />
+            </ResizablePanel>
+            <ResizableHandle withHandle />
+            <ResizablePanel defaultSize={40} minSize={24}>
+              <TextPane title="候选" content={chapter?.candidateContent ?? null} emptyText="候选尚未生成" />
+            </ResizablePanel>
+          </ResizablePanelGroup>
+
+          <div data-testid="de-ai-review-mobile" className="flex h-full min-h-0 flex-col md:hidden">
+            <div className="shrink-0 space-y-2 border-b p-3">
+              <label className="block text-xs font-medium text-muted-foreground" htmlFor="de-ai-review-chapter-select">
+                审核章节
+              </label>
+              <select
+                id="de-ai-review-chapter-select"
+                aria-label="选择审核章节"
+                disabled={pending}
+                className="h-9 w-full rounded-md border bg-background px-2 text-sm"
+                value={chapter?.id ?? ""}
+                onChange={(event) => onSelectChapter(event.target.value)}
+              >
+                {record?.chapters.map((item) => (
+                  <option key={item.id} value={item.id}>{item.title} · {CHAPTER_STATUS[item.status]}</option>
+                ))}
+              </select>
+              <div className="grid grid-cols-2 rounded-md bg-muted p-1" role="tablist" aria-label="审核内容">
+                <button
+                  id="de-ai-review-tab-source"
+                  type="button"
+                  role="tab"
+                  aria-controls="de-ai-review-tabpanel"
+                  aria-selected={mobileTab === "source"}
+                  tabIndex={mobileTab === "source" ? 0 : -1}
+                  onKeyDown={handleMobileTabKey}
+                  className={`rounded px-2 py-1.5 text-sm ${mobileTab === "source" ? "bg-background shadow-sm" : ""}`}
+                  onClick={() => setMobileTab("source")}
+                >
+                  原文
+                </button>
+                <button
+                  id="de-ai-review-tab-candidate"
+                  type="button"
+                  role="tab"
+                  aria-controls="de-ai-review-tabpanel"
+                  aria-selected={mobileTab === "candidate"}
+                  tabIndex={mobileTab === "candidate" ? 0 : -1}
+                  onKeyDown={handleMobileTabKey}
+                  className={`rounded px-2 py-1.5 text-sm ${mobileTab === "candidate" ? "bg-background shadow-sm" : ""}`}
+                  onClick={() => setMobileTab("candidate")}
+                >
+                  候选
+                </button>
+              </div>
+            </div>
+            <div
+              id="de-ai-review-tabpanel"
+              role="tabpanel"
+              aria-labelledby={`de-ai-review-tab-${mobileTab}`}
+              tabIndex={0}
+              data-testid="de-ai-review-scroll"
+              className="min-h-0 flex-1 overflow-y-auto p-4 text-sm leading-7 whitespace-pre-wrap"
+            >
+              {mobileTab === "source"
+                ? chapter?.sourceContent || "暂无原文副本"
+                : chapter?.candidateContent || "候选尚未生成"}
+            </div>
+          </div>
+        </div>
+
+        <DialogFooter className="mx-0 mb-0 shrink-0 rounded-none px-4 py-3">
+          <Button type="button" variant="outline" onClick={onClose}>关闭</Button>
+          <Button
+            type="button"
+            variant="outline"
+            disabled={!canCancel}
+            onClick={() => {
+              if (!record || !chapter) return
+              if (window.confirm(`确定取消“${chapter.title}”的本次处理吗?原文不会被修改。`)) {
+                onCancelChapter(record.task.id, chapter.id)
+              }
+            }}
+          >
+            取消当前章
+          </Button>
+          <Button
+            type="button"
+            variant="outline"
+            disabled={!canRegenerate}
+            onClick={() => { if (record && chapter) onRegenerate(record.task.id, chapter.id) }}
+          >
+            重新生成
+          </Button>
+          <Button
+            type="button"
+            disabled={!canConfirm}
+            onClick={() => { if (record && chapter) onConfirm(record.task.id, chapter.id) }}
+          >
+            确认当前章
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 92 - 0
src/components/novel/de-ai-batch-start-dialog.spec.tsx

@@ -0,0 +1,92 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { DeAiBatchStartDialog } from "./de-ai-batch-start-dialog"
+
+;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+const works = [
+  { id: "work-a", title: "作品甲", chapters: [{ id: "a-1", title: "第一章", order: 1, sourcePath: "C:/a/1.md" }] },
+  { id: "work-b", title: "作品乙", chapters: [{ id: "b-1", title: "第一章", order: 1, sourcePath: "C:/b/1.md" }] },
+]
+
+let host: HTMLDivElement
+let root: Root
+beforeEach(() => {
+  host = document.createElement("div")
+  document.body.appendChild(host)
+  root = createRoot(host)
+})
+afterEach(() => {
+  act(() => root.unmount())
+  host.remove()
+})
+
+describe("DeAiBatchStartDialog", () => {
+  it("可选择多个作品/章节并设置 1–5 并发", () => {
+    const onStart = vi.fn()
+    const onConcurrencyChange = vi.fn()
+    act(() => root.render(
+      <DeAiBatchStartDialog
+        open
+        works={works}
+        concurrency={3}
+        onConcurrencyChange={onConcurrencyChange}
+        onStart={onStart}
+        onClose={vi.fn()}
+      />,
+    ))
+
+    const workCheckboxes = Array.from(document.body.querySelectorAll<HTMLInputElement>('input[type="checkbox"][data-work-id]'))
+    act(() => workCheckboxes.forEach((checkbox) => checkbox.click()))
+    const concurrency = document.body.querySelector<HTMLInputElement>('input[aria-label="批量去 AI 味并发数"]')
+    expect(concurrency?.min).toBe("1")
+    expect(concurrency?.max).toBe("5")
+    act(() => {
+      if (concurrency) {
+        Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(concurrency, "5")
+        concurrency.dispatchEvent(new Event("input", { bubbles: true }))
+      }
+    })
+    const start = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.includes("开始处理"))
+    act(() => start?.click())
+
+    expect(onConcurrencyChange).toHaveBeenCalledWith(5)
+    expect(onStart).toHaveBeenCalledWith([
+      { workId: "work-a", chapterIds: ["a-1"] },
+      { workId: "work-b", chapterIds: ["b-1"] },
+    ])
+  })
+  it("同步锁阻止开始按钮双击重复提交", async () => {
+    let release!: () => void
+    const gate = new Promise<void>((resolve) => { release = resolve })
+    const onStart = vi.fn(async () => gate)
+    act(() => root.render(
+      <DeAiBatchStartDialog open works={works} concurrency={3} onConcurrencyChange={vi.fn()} onStart={onStart} onClose={vi.fn()} />,
+    ))
+    const workCheckbox = document.body.querySelector<HTMLInputElement>('input[type="checkbox"][data-work-id="work-a"]')
+    act(() => workCheckbox?.click())
+    const start = Array.from(document.body.querySelectorAll("button")).find((button) => button.textContent?.includes("开始处理"))
+    act(() => {
+      start?.click()
+      start?.click()
+    })
+    expect(onStart).toHaveBeenCalledTimes(1)
+    release()
+    await act(async () => { await gate })
+  })
+
+  it("starting 时禁用选择、并发、提交和关闭", () => {
+    const onClose = vi.fn()
+    act(() => root.render(
+      <DeAiBatchStartDialog open starting works={works} concurrency={3} onConcurrencyChange={vi.fn()} onStart={vi.fn()} onClose={onClose} />,
+    ))
+    const inputs = Array.from(document.body.querySelectorAll<HTMLInputElement>("input"))
+    expect(inputs.length).toBeGreaterThan(0)
+    expect(inputs.every((input) => input.disabled)).toBe(true)
+    const buttons = Array.from(document.body.querySelectorAll<HTMLButtonElement>("button"))
+    expect(buttons.filter((button) => button.textContent?.includes("开始处理") || button.textContent === "取消").every((button) => button.disabled)).toBe(true)
+  })
+})

+ 154 - 0
src/components/novel/de-ai-batch-start-dialog.tsx

@@ -0,0 +1,154 @@
+import { useEffect, useMemo, useRef, useState } from "react"
+import { Button } from "@/components/ui/button"
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog"
+import { Input } from "@/components/ui/input"
+import type { DeAiBatchCatalogWork } from "@/lib/novel/de-ai-batch/catalog"
+import { clampDeAiBatchConcurrency } from "@/lib/novel/de-ai-batch/scheduler"
+
+export interface DeAiBatchSelection {
+  workId: string
+  chapterIds: string[]
+}
+
+export interface DeAiBatchStartDialogProps {
+  open: boolean
+  works: DeAiBatchCatalogWork[]
+  concurrency: number
+  starting?: boolean
+  onConcurrencyChange(value: number): void
+  onStart(selection: DeAiBatchSelection[]): void | Promise<void>
+  onClose(): void
+}
+
+export function DeAiBatchStartDialog({
+  open,
+  works,
+  concurrency,
+  starting = false,
+  onConcurrencyChange,
+  onStart,
+  onClose,
+}: DeAiBatchStartDialogProps) {
+  const [selected, setSelected] = useState<Record<string, Set<string>>>({})
+  const startLockRef = useRef(false)
+
+  useEffect(() => {
+    if (open) {
+      setSelected({})
+      startLockRef.current = false
+    }
+  }, [open])
+
+  const selection = useMemo(() => works.flatMap((work) => {
+    const chapterIds = Array.from(selected[work.id] ?? [])
+    return chapterIds.length > 0 ? [{ workId: work.id, chapterIds }] : []
+  }), [selected, works])
+
+  async function handleStart(): Promise<void> {
+    if (starting || startLockRef.current || selection.length === 0) return
+    startLockRef.current = true
+    try {
+      await onStart(selection)
+    } finally {
+      startLockRef.current = false
+    }
+  }
+  function toggleWork(work: DeAiBatchCatalogWork): void {
+    setSelected((current) => {
+      const next = { ...current }
+      const selectedCount = next[work.id]?.size ?? 0
+      next[work.id] = selectedCount === work.chapters.length
+        ? new Set()
+        : new Set(work.chapters.map((chapter) => chapter.id))
+      return next
+    })
+  }
+
+  function toggleChapter(workId: string, chapterId: string): void {
+    setSelected((current) => {
+      const chapters = new Set(current[workId] ?? [])
+      if (chapters.has(chapterId)) chapters.delete(chapterId)
+      else chapters.add(chapterId)
+      return { ...current, [workId]: chapters }
+    })
+  }
+
+  return (
+    <Dialog open={open} onOpenChange={(next) => { if (!next && !starting) onClose() }}>
+      <DialogContent
+        showCloseButton={false}
+        className="flex max-h-[calc(100dvh-2rem)] w-[min(48rem,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] flex-col overflow-hidden sm:max-w-[calc(100vw-2rem)]"
+      >
+        <DialogHeader>
+          <DialogTitle>新建批量去 AI 味任务</DialogTitle>
+          <DialogDescription>可同时选择多个作品和章节。每个作品使用独立 Agent,超出并发数后按顺序排队。</DialogDescription>
+        </DialogHeader>
+        <div className="flex shrink-0 items-center gap-3 rounded-md border p-3">
+          <label className="text-sm font-medium" htmlFor="de-ai-batch-concurrency">并发作品数</label>
+          <Input
+            id="de-ai-batch-concurrency"
+            aria-label="批量去 AI 味并发数"
+            type="number"
+            min={1}
+            max={5}
+            value={concurrency}
+            disabled={starting}
+            onChange={(event) => onConcurrencyChange(clampDeAiBatchConcurrency(Number(event.target.value)))}
+            className="w-20"
+          />
+          <span className="text-xs text-muted-foreground">范围 1–5,默认 3</span>
+        </div>
+        <div className="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
+          {works.length === 0 ? (
+            <div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
+              当前项目的章节库中没有可处理章节
+            </div>
+          ) : works.map((work) => {
+            const selectedIds = selected[work.id] ?? new Set<string>()
+            return (
+              <section key={work.id} className="rounded-md border" aria-label={work.title}>
+                <label className="flex cursor-pointer items-center gap-2 border-b px-3 py-2 font-medium">
+                  <input
+                    type="checkbox"
+                    data-work-id={work.id}
+                    checked={selectedIds.size === work.chapters.length && work.chapters.length > 0}
+                    disabled={starting}
+                    onChange={() => toggleWork(work)}
+                  />
+                  <span className="min-w-0 flex-1 truncate">{work.title}</span>
+                  <span className="text-xs text-muted-foreground">{selectedIds.size} / {work.chapters.length} 章</span>
+                </label>
+                <div className="max-h-52 overflow-y-auto p-2">
+                  {work.chapters.map((chapter) => (
+                    <label key={chapter.id} className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted">
+                      <input
+                        type="checkbox"
+                        checked={selectedIds.has(chapter.id)}
+                        disabled={starting}
+                        onChange={() => toggleChapter(work.id, chapter.id)}
+                      />
+                      <span className="truncate">{chapter.title}</span>
+                    </label>
+                  ))}
+                </div>
+              </section>
+            )
+          })}
+        </div>
+        <DialogFooter>
+          <Button type="button" variant="outline" disabled={starting} onClick={onClose}>取消</Button>
+          <Button type="button" disabled={starting || selection.length === 0} onClick={() => { void handleStart() }}>
+            {starting ? "正在创建..." : `开始处理(${selection.reduce((sum, work) => sum + work.chapterIds.length, 0)} 章)`}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 157 - 0
src/components/novel/de-ai-batch-task-panel.spec.tsx

@@ -0,0 +1,157 @@
+// @vitest-environment jsdom
+
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import type { DeAiBatchTaskRecord, DeAiBatchTaskStatus } from "@/lib/novel/de-ai-batch/types"
+import { DeAiBatchTaskPanel } from "./de-ai-batch-task-panel"
+
+;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+
+function record(status: DeAiBatchTaskStatus, id = `task-${status}`): DeAiBatchTaskRecord {
+  return {
+    task: {
+      version: 1,
+      id,
+      projectPath: "C:/project",
+      workId: id,
+      workTitle: `作品-${status}`,
+      modelKey: "openai/test",
+      skillId: null,
+      skillName: "默认",
+      skillContent: "规则",
+      status,
+      chapterIds: ["c1", "c2", "c3"],
+      error: status === "failed" ? "模型失败" : null,
+      createdAt: 1,
+      startedAt: null,
+      completedAt: null,
+      updatedAt: 1,
+    },
+    chapters: ["ready", "confirmed", "pending"].map((chapterStatus, index) => ({
+      version: 1 as const,
+      id: `c${index + 1}`,
+      taskId: id,
+      title: `第${index + 1}章`,
+      order: index + 1,
+      sourcePath: `C:/project/c${index + 1}.md`,
+      sourceContent: "原文",
+      candidateContent: index < 2 ? "候选" : null,
+      status: chapterStatus as "ready" | "confirmed" | "pending",
+      runId: null,
+      generation: 1,
+      error: null,
+      createdAt: 1,
+      updatedAt: 1,
+    })),
+  }
+}
+
+function callbacks() {
+  return {
+    onCollapsedChange: vi.fn(),
+    onContinue: vi.fn(),
+    onReview: vi.fn(),
+    onCancel: vi.fn(),
+  }
+}
+
+let host: HTMLDivElement
+let root: Root
+
+beforeEach(() => {
+  host = document.createElement("div")
+  document.body.appendChild(host)
+  root = createRoot(host)
+})
+
+afterEach(() => {
+  act(() => root.unmount())
+  host.remove()
+  vi.restoreAllMocks()
+})
+
+function render(records: DeAiBatchTaskRecord[], collapsed = false, props: ReturnType<typeof callbacks> & { pendingTaskIds?: ReadonlySet<string> } = callbacks()) {
+  act(() => {
+    root.render(
+      <DeAiBatchTaskPanel
+        records={records}
+        collapsed={collapsed}
+        {...props}
+      />,
+    )
+  })
+  return props
+}
+
+function click(label: string): void {
+  const button = Array.from(host.querySelectorAll("button")).find((item) => item.textContent?.trim() === label)
+  expect(button, `找不到按钮:${label}`).toBeTruthy()
+  act(() => button?.click())
+}
+
+describe("DeAiBatchTaskPanel", () => {
+  it("显示中文状态、已处理章节计数和独立滚动任务区", () => {
+    render([record("running"), record("failed")])
+
+    expect(host.textContent).toContain("批量去 AI 味")
+    expect(host.textContent).toContain("处理中")
+    expect(host.textContent).toContain("失败")
+    expect(host.textContent).toContain("2 / 3 章")
+    const list = host.querySelector('[data-testid="de-ai-batch-task-list"]')
+    expect(list?.className).toContain("overflow-y-auto")
+    expect(list?.className).toContain("max-h-")
+  })
+
+  it("折叠状态完全受控", () => {
+    const props = render([record("running")])
+    click("收起任务")
+    expect(props.onCollapsedChange).toHaveBeenCalledWith(true)
+
+    render([record("running")], true, props)
+    expect(host.textContent).not.toContain("作品-running")
+    click("展开任务")
+    expect(props.onCollapsedChange).toHaveBeenLastCalledWith(false)
+  })
+
+  it("interrupted 和 failed 可继续,reviewing 可审核", () => {
+    const props = render([record("interrupted"), record("failed"), record("reviewing")])
+    const continueButtons = Array.from(host.querySelectorAll("button")).filter((button) => button.textContent?.trim() === "继续")
+    act(() => continueButtons[0]?.click())
+    act(() => continueButtons[1]?.click())
+    const reviewingArticle = Array.from(host.querySelectorAll("article")).find((item) => item.textContent?.includes("作品-reviewing"))
+    const reviewButton = Array.from(reviewingArticle?.querySelectorAll("button") ?? []).find((item) => item.textContent?.trim() === "审核")
+    act(() => reviewButton?.click())
+
+    expect(props.onContinue).toHaveBeenNthCalledWith(1, "task-interrupted")
+    expect(props.onContinue).toHaveBeenNthCalledWith(2, "task-failed")
+    expect(props.onReview).toHaveBeenCalledWith("task-reviewing")
+  })
+
+  it("取消使用中文确认且只传递当前任务", () => {
+    const confirm = vi.spyOn(window, "confirm").mockReturnValue(true)
+    const props = render([record("queued")])
+
+    click("取消")
+
+    expect(confirm).toHaveBeenCalledWith("确定取消“作品-queued”的批量去 AI 味任务吗?")
+    expect(props.onCancel).toHaveBeenCalledWith("task-queued")
+  })
+
+  it("拒绝确认时不取消任务", () => {
+    vi.spyOn(window, "confirm").mockReturnValue(false)
+    const props = render([record("running")])
+
+    click("取消")
+
+    expect(props.onCancel).not.toHaveBeenCalled()
+  })
+
+  it("任务异步操作 pending 时禁用继续和取消", () => {
+    render([record("failed")], false, { ...callbacks(), pendingTaskIds: new Set(["task-failed"]) })
+    const buttons = Array.from(host.querySelectorAll<HTMLButtonElement>("button"))
+    expect(buttons.find((button) => button.textContent?.trim() === "继续")?.disabled).toBe(true)
+    expect(buttons.find((button) => button.textContent?.trim() === "取消")?.disabled).toBe(true)
+    expect(buttons.find((button) => button.textContent?.trim() === "审核")?.disabled).toBe(true)
+  })
+})

+ 132 - 0
src/components/novel/de-ai-batch-task-panel.tsx

@@ -0,0 +1,132 @@
+import { ChevronDown, ChevronUp, Layers3 } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import type { DeAiBatchTaskRecord, DeAiBatchTaskStatus } from "@/lib/novel/de-ai-batch/types"
+
+const STATUS_LABELS: Record<DeAiBatchTaskStatus, string> = {
+  queued: "等待中",
+  running: "处理中",
+  reviewing: "待审核",
+  partial: "部分完成",
+  interrupted: "已中断",
+  failed: "失败",
+  cancelled: "已取消",
+  completed: "已完成",
+}
+
+const CONTINUE_STATUSES = new Set<DeAiBatchTaskStatus>(["interrupted", "failed", "partial"])
+const CANCELLABLE_STATUSES = new Set<DeAiBatchTaskStatus>([
+  "queued",
+  "running",
+  "reviewing",
+  "partial",
+  "interrupted",
+  "failed",
+])
+
+export interface DeAiBatchTaskPanelProps {
+  records: DeAiBatchTaskRecord[]
+  collapsed: boolean
+  pendingTaskIds?: ReadonlySet<string>
+  onCollapsedChange(collapsed: boolean): void
+  onContinue(taskId: string): void
+  onReview(taskId: string): void
+  onCancel(taskId: string): void
+}
+
+function completedCount(record: DeAiBatchTaskRecord): number {
+  return record.chapters.filter((chapter) => chapter.status !== "pending" && chapter.status !== "generating").length
+}
+
+function canReview(record: DeAiBatchTaskRecord): boolean {
+  return record.chapters.some((chapter) => chapter.status === "ready" || chapter.status === "confirmed")
+}
+
+export function DeAiBatchTaskPanel({
+  records,
+  collapsed,
+  pendingTaskIds = new Set(),
+  onCollapsedChange,
+  onContinue,
+  onReview,
+  onCancel,
+}: DeAiBatchTaskPanelProps) {
+  if (records.length === 0) return null
+
+  return (
+    <section className="rounded-lg border bg-card" aria-label="批量去 AI 味任务">
+      <div className="flex items-center justify-between gap-3 border-b px-3 py-2">
+        <div className="flex min-w-0 items-center gap-2 text-sm font-medium">
+          <Layers3 className="h-4 w-4 shrink-0 text-primary" />
+          <span>批量去 AI 味</span>
+          <span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">{records.length}</span>
+        </div>
+        <Button
+          type="button"
+          size="sm"
+          variant="ghost"
+          aria-controls="de-ai-batch-task-list"
+          aria-expanded={!collapsed}
+          onClick={() => onCollapsedChange(!collapsed)}
+        >
+          {collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
+          {collapsed ? "展开任务" : "收起任务"}
+        </Button>
+      </div>
+      {!collapsed ? (
+        <div
+          id="de-ai-batch-task-list"
+          data-testid="de-ai-batch-task-list"
+          className="max-h-72 space-y-2 overflow-y-auto p-3"
+        >
+          {records.map((record) => {
+            const task = record.task
+            const finished = completedCount(record)
+            return (
+              <article key={task.id} className="rounded-md border p-3" aria-label={`${task.workTitle}任务`}>
+                <div className="flex items-start justify-between gap-3">
+                  <div className="min-w-0">
+                    <div className="truncate text-sm font-medium" title={task.workTitle}>{task.workTitle}</div>
+                    <div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
+                      <span>{STATUS_LABELS[task.status]}</span>
+                      <span>{finished} / {record.chapters.length} 章</span>
+                    </div>
+                    {task.error ? (
+                      <p className="mt-1 truncate text-xs text-destructive" title={task.error}>{task.error}</p>
+                    ) : null}
+                  </div>
+                  <div className="flex shrink-0 flex-wrap justify-end gap-1">
+                    {CONTINUE_STATUSES.has(task.status) ? (
+                      <Button type="button" size="sm" variant="outline" disabled={pendingTaskIds.has(task.id)} onClick={() => onContinue(task.id)}>
+                        继续
+                      </Button>
+                    ) : null}
+                    {canReview(record) ? (
+                      <Button type="button" size="sm" variant="outline" disabled={pendingTaskIds.has(task.id)} onClick={() => onReview(task.id)}>
+                        审核
+                      </Button>
+                    ) : null}
+                    {CANCELLABLE_STATUSES.has(task.status) ? (
+                      <Button
+                        type="button"
+                        size="sm"
+                        variant="ghost"
+                        disabled={pendingTaskIds.has(task.id)}
+                        onClick={() => {
+                          if (window.confirm(`确定取消“${task.workTitle}”的批量去 AI 味任务吗?`)) {
+                            onCancel(task.id)
+                          }
+                        }}
+                      >
+                        取消
+                      </Button>
+                    ) : null}
+                  </div>
+                </div>
+              </article>
+            )
+          })}
+        </div>
+      ) : null}
+    </section>
+  )
+}

+ 206 - 0
src/components/novel/de-ai-batch-workspace.tsx

@@ -0,0 +1,206 @@
+import { useEffect, useMemo, useRef, useState } from "react"
+import { WandSparkles } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { DeAiBatchReviewDialog } from "@/components/novel/de-ai-batch-review-dialog"
+import { DeAiBatchStartDialog, type DeAiBatchSelection } from "@/components/novel/de-ai-batch-start-dialog"
+import { DeAiBatchTaskPanel } from "@/components/novel/de-ai-batch-task-panel"
+import { readFile } from "@/commands/fs"
+import { toast } from "@/lib/toast"
+import { buildDeAiSystemPrompt } from "@/lib/novel/de-ai-adapter"
+import { loadEffectiveDeAiSkillSafely } from "@/lib/novel/de-ai-skill-library"
+import { buildDeAiBatchCatalog } from "@/lib/novel/de-ai-batch/catalog"
+import { resolveDeAiBatchModelKey } from "@/lib/novel/de-ai-batch/llm-runner"
+import { createLatestValueSaveQueue } from "@/lib/novel/de-ai-batch/latest-value-save-queue"
+import { saveNovelConfig } from "@/lib/project-store"
+import { useDeAiBatchStore } from "@/stores/de-ai-batch-store"
+import { useWikiStore } from "@/stores/wiki-store"
+import type { CreateDeAiBatchTaskInput } from "@/lib/novel/de-ai-batch/types"
+
+export function DeAiBatchWorkspace() {
+  const project = useWikiStore((state) => state.project)
+  const fileTree = useWikiStore((state) => state.fileTree)
+  const novelConfig = useWikiStore((state) => state.novelConfig)
+  const records = useDeAiBatchStore((state) => state.records)
+  const concurrency = useDeAiBatchStore((state) => state.concurrency)
+  const panelCollapsed = useDeAiBatchStore((state) => state.panelCollapsed)
+  const reviewOpen = useDeAiBatchStore((state) => state.reviewOpen)
+  const reviewTaskId = useDeAiBatchStore((state) => state.reviewTaskId)
+  const reviewChapterId = useDeAiBatchStore((state) => state.reviewChapterId)
+  const [startOpen, setStartOpen] = useState(false)
+  const [starting, setStarting] = useState(false)
+  const startingRef = useRef(false)
+  const pendingActionKeysRef = useRef(new Set<string>())
+  const [pendingActionKeys, setPendingActionKeys] = useState<ReadonlySet<string>>(new Set())
+
+  const catalog = useMemo(
+    () => project ? buildDeAiBatchCatalog(project, fileTree) : [],
+    [fileTree, project],
+  )
+  const concurrencySaveQueue = useMemo(() => project
+    ? createLatestValueSaveQueue((config: typeof novelConfig) => saveNovelConfig(config, project.id, project.path))
+    : null, [project?.id, project?.path])
+  const reviewRecord = records.find((record) => record.task.id === reviewTaskId) ?? null
+  const pendingTaskIds = useMemo(() => new Set(Array.from(pendingActionKeys).flatMap((key) => key.startsWith("task:") ? [key.slice(5)] : [])), [pendingActionKeys])
+  const reviewPending = (!!reviewTaskId && pendingTaskIds.has(reviewTaskId))
+    || (!!reviewTaskId && !!reviewChapterId && pendingActionKeys.has(`chapter:${reviewTaskId}:${reviewChapterId}`))
+
+  useEffect(() => {
+    if (!project) return
+    const store = useDeAiBatchStore.getState()
+    void store.initializeProject(project.path).catch((error) => {
+      console.error("加载批量去 AI 味任务失败", error)
+      toast.error("加载批量去 AI 味任务失败,请稍后重试。")
+    })
+    return () => store.dispose()
+  }, [project?.id, project?.path])
+
+  useEffect(() => {
+    useDeAiBatchStore.getState().setConcurrency(novelConfig.deAiBatchConcurrency)
+  }, [novelConfig.deAiBatchConcurrency])
+
+  if (!project) return null
+  const activeProject = project
+
+  async function runWorkspaceAction(
+    key: string,
+    label: string,
+    action: () => Promise<unknown>,
+  ): Promise<void> {
+    if (pendingActionKeysRef.current.has(key)) return
+    pendingActionKeysRef.current.add(key)
+    setPendingActionKeys(new Set(pendingActionKeysRef.current))
+    try {
+      await action()
+    } catch (error) {
+      console.error(`${label}失败`, error)
+      toast.error(`操作失败:${label}。${error instanceof Error ? error.message : String(error)}`)
+    } finally {
+      pendingActionKeysRef.current.delete(key)
+      setPendingActionKeys(new Set(pendingActionKeysRef.current))
+    }
+  }
+  async function handleStart(selection: DeAiBatchSelection[]): Promise<void> {
+    if (startingRef.current) return
+    startingRef.current = true
+    setStarting(true)
+    try {
+      const wikiState = useWikiStore.getState()
+      const modelKey = resolveDeAiBatchModelKey({
+        taskModel: wikiState.novelConfig.deAiModel,
+        defaultModel: wikiState.novelConfig.defaultLlmModel || wikiState.defaultLlmModel,
+        chatModel: wikiState.aiChatModel,
+        baseConfig: wikiState.llmConfig,
+        providerConfigs: wikiState.providerConfigs,
+      })
+      if (!modelKey) {
+        toast.error("未配置可用的去 AI 味模型,请先到小说设置中配置。")
+        return
+      }
+      const effective = await loadEffectiveDeAiSkillSafely(activeProject.path)
+      if (effective.warning) toast.info(effective.warning)
+      const skillContent = effective.skill?.content || buildDeAiSystemPrompt()
+      const skillName = effective.skill?.name || "系统默认去 AI 味规则"
+      const inputs: CreateDeAiBatchTaskInput[] = []
+      for (const selectedWork of selection) {
+        const work = catalog.find((item) => item.id === selectedWork.workId)
+        if (!work) continue
+        const selectedIds = new Set(selectedWork.chapterIds)
+        const chapters = []
+        for (const chapter of work.chapters) {
+          if (!selectedIds.has(chapter.id)) continue
+          chapters.push({
+            ...chapter,
+            sourceContent: await readFile(chapter.sourcePath),
+          })
+        }
+        if (chapters.length === 0) continue
+        inputs.push({
+          projectPath: activeProject.path,
+          workId: work.id,
+          workTitle: work.title,
+          modelKey,
+          skillId: effective.skill?.id ?? null,
+          skillName,
+          skillContent,
+          chapters,
+        })
+      }
+      if (inputs.length === 0) {
+        toast.error("请选择至少一个可读取的章节。")
+        return
+      }
+      await useDeAiBatchStore.getState().createBatch(inputs)
+      setStartOpen(false)
+      toast.success(`已创建 ${inputs.length} 个作品的批量去 AI 味任务。`)
+    } catch (error) {
+      console.error("创建批量去 AI 味任务失败", error)
+      toast.error(`创建批量去 AI 味任务失败:${error instanceof Error ? error.message : String(error)}`)
+    } finally {
+      startingRef.current = false
+      setStarting(false)
+    }
+  }
+
+  function handleConcurrencyChange(value: number): void {
+    useDeAiBatchStore.getState().setConcurrency(value)
+    const wikiState = useWikiStore.getState()
+    const nextConfig = { ...wikiState.novelConfig, deAiBatchConcurrency: value }
+    wikiState.setNovelConfig(nextConfig)
+    const saving = concurrencySaveQueue?.enqueue(nextConfig)
+    if (saving) {
+      void saving.catch((error) => {
+        console.error("保存批量去 AI 味并发设置失败", error)
+        toast.error(`保存批量去 AI 味并发设置失败:${error instanceof Error ? error.message : String(error)}`)
+      })
+    }
+  }
+
+  return (
+    <>
+      <aside className="fixed right-4 top-16 z-40 flex w-[min(26rem,calc(100vw-2rem))] max-h-[calc(100dvh-5rem)] flex-col gap-2" aria-label="批量去 AI 味工作区">
+        <div className="flex justify-end">
+          <Button type="button" size="sm" onClick={() => setStartOpen(true)} disabled={starting}>
+            <WandSparkles className="h-4 w-4" />
+            {starting ? "正在创建..." : "批量去 AI 味"}
+          </Button>
+        </div>
+        <DeAiBatchTaskPanel
+          records={records}
+          collapsed={panelCollapsed}
+          pendingTaskIds={pendingTaskIds}
+          onCollapsedChange={(collapsed) => useDeAiBatchStore.getState().setPanelCollapsed(collapsed)}
+          onContinue={(taskId) => runWorkspaceAction(`task:${taskId}`, "继续批量去 AI 味任务", () => useDeAiBatchStore.getState().continueTask(taskId))}
+          onReview={(taskId) => {
+            const record = useDeAiBatchStore.getState().records.find((item) => item.task.id === taskId)
+            const chapterId = record?.chapters.find((chapter) => chapter.status === "ready")?.id
+              ?? record?.chapters[0]?.id
+            useDeAiBatchStore.getState().openReview(taskId, chapterId)
+          }}
+          onCancel={(taskId) => runWorkspaceAction(`task:${taskId}`, "取消批量去 AI 味任务", () => useDeAiBatchStore.getState().cancelTask(taskId))}
+        />
+      </aside>
+
+      <DeAiBatchStartDialog
+        open={startOpen}
+        works={catalog}
+        concurrency={concurrency}
+        starting={starting}
+        onConcurrencyChange={handleConcurrencyChange}
+        onStart={handleStart}
+        onClose={() => setStartOpen(false)}
+      />
+
+      <DeAiBatchReviewDialog
+        open={reviewOpen}
+        record={reviewRecord}
+        currentChapterId={reviewChapterId}
+        pending={reviewPending}
+        onSelectChapter={(chapterId) => useDeAiBatchStore.getState().setReviewChapter(chapterId)}
+        onConfirm={(taskId, chapterId) => runWorkspaceAction(`chapter:${taskId}:${chapterId}`, "确认当前章节", () => useDeAiBatchStore.getState().confirmChapter(taskId, chapterId))}
+        onRegenerate={(taskId, chapterId) => runWorkspaceAction(`chapter:${taskId}:${chapterId}`, "重新生成当前章节", () => useDeAiBatchStore.getState().regenerateChapter(taskId, chapterId))}
+        onCancelChapter={(taskId, chapterId) => runWorkspaceAction(`chapter:${taskId}:${chapterId}`, "取消当前章节", () => useDeAiBatchStore.getState().cancelChapter(taskId, chapterId))}
+        onClose={() => useDeAiBatchStore.getState().closeReview()}
+      />
+    </>
+  )
+}

+ 17 - 0
src/components/settings/sections/novel-section.tsx

@@ -262,6 +262,23 @@ export function NovelSection({ draft, setDraft }: Props) {
             />
           </div>
 
+          <div className="space-y-2">
+            <Label htmlFor="de-ai-batch-concurrency-setting">批量去 AI 味并发作品数</Label>
+            <Input
+              id="de-ai-batch-concurrency-setting"
+              aria-label="批量去 AI 味并发作品数"
+              type="number"
+              min={1}
+              max={5}
+              value={draft.novelConfig.deAiBatchConcurrency}
+              onChange={(e) => updateNovelConfig({
+                deAiBatchConcurrency: Math.max(1, Math.min(5, Math.floor(Number(e.target.value) || 3))),
+              })}
+              className="w-24"
+            />
+            <p className="text-xs text-muted-foreground">默认同时处理 3 个作品,可设置 1–5;超出后按添加顺序排队。</p>
+          </div>
+
           <div className="space-y-2">
             <div className="flex items-center gap-1.5">
               <Label>{t("novel.settings.searchTopK")}</Label>

+ 67 - 0
src/lib/chapter-external-update-coordinator.ts

@@ -0,0 +1,67 @@
+import { normalizePath } from "@/lib/path-utils"
+
+export interface ChapterExternalUpdateCoordinator {
+  runExternalUpdate(path: string, write: () => Promise<void>): Promise<number>
+  flushBeforeLeave(path: string, write: () => Promise<void>): Promise<boolean>
+  markEditorSession(path: string, version?: number): void
+}
+
+export function createChapterExternalUpdateCoordinator(): ChapterExternalUpdateCoordinator {
+  const pathVersions = new Map<string, number>()
+  const editorSessionVersions = new Map<string, number>()
+  const activeExternalVersions = new Map<string, number>()
+  const writeChains = new Map<string, Promise<void>>()
+
+  function keyOf(path: string): string {
+    return normalizePath(path)
+  }
+
+  function currentVersion(key: string): number {
+    return pathVersions.get(key) ?? 0
+  }
+
+  function enqueue<T>(key: string, operation: () => Promise<T>): Promise<T> {
+    const previous = writeChains.get(key) ?? Promise.resolve()
+    const run = previous.catch(() => undefined).then(operation)
+    const tail = run.then(() => undefined, () => undefined)
+    writeChains.set(key, tail)
+    void tail.finally(() => {
+      if (writeChains.get(key) === tail) writeChains.delete(key)
+    })
+    return run
+  }
+
+  async function runExternalUpdate(path: string, write: () => Promise<void>): Promise<number> {
+    const key = keyOf(path)
+    const previousVersion = currentVersion(key)
+    const version = previousVersion + 1
+    pathVersions.set(key, version)
+    activeExternalVersions.set(key, version)
+    try {
+      await enqueue(key, write)
+      return version
+    } catch (error) {
+      if (pathVersions.get(key) === version) pathVersions.set(key, previousVersion)
+      throw error
+    } finally {
+      if (activeExternalVersions.get(key) === version) activeExternalVersions.delete(key)
+    }
+  }
+
+  function flushBeforeLeave(path: string, write: () => Promise<void>): Promise<boolean> {
+    const key = keyOf(path)
+    const expectedVersion = editorSessionVersions.get(key) ?? currentVersion(key)
+    return enqueue(key, async () => {
+      if (activeExternalVersions.has(key)) return false
+      if (currentVersion(key) !== expectedVersion) return false
+      await write()
+      return true
+    })
+  }
+
+  function markEditorSession(path: string, version = currentVersion(keyOf(path))): void {
+    editorSessionVersions.set(keyOf(path), version)
+  }
+
+  return { runExternalUpdate, flushBeforeLeave, markEditorSession }
+}

+ 20 - 0
src/lib/editor-external-update-session.spec.ts

@@ -0,0 +1,20 @@
+import { describe, expect, it, vi } from "vitest"
+import {
+  registerEditorExternalUpdateHandler,
+  requestEditorExternalChapterBodyUpdate,
+} from "./editor-external-update-session"
+
+describe("editor external update session", () => {
+  it("旧注册者的 disposer 不会清除后注册的新 handler", async () => {
+    const oldHandler = vi.fn(async () => true)
+    const newHandler = vi.fn(async () => true)
+    const disposeOld = registerEditorExternalUpdateHandler(oldHandler)
+    const disposeNew = registerEditorExternalUpdateHandler(newHandler)
+    disposeOld()
+    await expect(requestEditorExternalChapterBodyUpdate("C:/project/wiki/chapters/1.md", "候选")).resolves.toBe(true)
+    expect(oldHandler).not.toHaveBeenCalled()
+    expect(newHandler).toHaveBeenCalledTimes(1)
+    disposeNew()
+    await expect(requestEditorExternalChapterBodyUpdate("C:/project/wiki/chapters/1.md", "候选")).resolves.toBe(false)
+  })
+})

+ 18 - 0
src/lib/editor-external-update-session.ts

@@ -0,0 +1,18 @@
+type EditorExternalUpdateHandler = (path: string, candidateContent: string) => Promise<boolean>
+
+let activeHandler: EditorExternalUpdateHandler | null = null
+
+export function registerEditorExternalUpdateHandler(handler: EditorExternalUpdateHandler): () => void {
+  activeHandler = handler
+  return () => {
+    if (activeHandler === handler) activeHandler = null
+  }
+}
+
+export async function requestEditorExternalChapterBodyUpdate(
+  path: string,
+  candidateContent: string,
+): Promise<boolean> {
+  if (!activeHandler) return false
+  return activeHandler(path, candidateContent)
+}

+ 51 - 0
src/lib/novel/de-ai-batch/catalog.spec.ts

@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest"
+import { buildDeAiBatchCatalog } from "./catalog"
+import type { FileNode, WikiProject } from "@/types/wiki"
+
+const project: WikiProject = { id: "project-a", name: "当前作品", path: "C:/project" }
+const tree: FileNode[] = [{
+  name: "wiki",
+  path: "C:/project/wiki",
+  is_dir: true,
+  children: [{
+    name: "chapters",
+    path: "C:/project/wiki/chapters",
+    is_dir: true,
+    children: [
+      {
+        name: "作品甲",
+        path: "C:/project/wiki/chapters/作品甲",
+        is_dir: true,
+        children: [
+          { name: "第2章.md", path: "C:/project/wiki/chapters/作品甲/第2章.md", is_dir: false },
+          { name: "第1章.md", path: "C:/project/wiki/chapters/作品甲/第1章.md", is_dir: false },
+        ],
+      },
+      {
+        name: "作品乙",
+        path: "C:/project/wiki/chapters/作品乙",
+        is_dir: true,
+        children: [{ name: "第1章.md", path: "C:/project/wiki/chapters/作品乙/第1章.md", is_dir: false }],
+      },
+      { name: "说明.txt", path: "C:/project/wiki/chapters/说明.txt", is_dir: false },
+    ],
+  }],
+}]
+
+describe("de-ai batch catalog", () => {
+  it("按章节目录的一级子目录生成多作品目录并按章节号排序", () => {
+    const catalog = buildDeAiBatchCatalog(project, tree)
+
+    expect(catalog.map((work) => work.title)).toEqual(["作品甲", "作品乙"])
+    expect(catalog[0].chapters.map((chapter) => chapter.title)).toEqual(["第1章", "第2章"])
+  })
+
+  it("生成的作品和章节 ID 只能包含安全字符", () => {
+    const catalog = buildDeAiBatchCatalog(project, tree)
+
+    for (const work of catalog) {
+      expect(work.id).toMatch(/^[A-Za-z0-9_-]+$/)
+      for (const chapter of work.chapters) expect(chapter.id).toMatch(/^[A-Za-z0-9_-]+$/)
+    }
+  })
+})

+ 70 - 0
src/lib/novel/de-ai-batch/catalog.ts

@@ -0,0 +1,70 @@
+import { extractChapterNumber } from "@/lib/novel/chapter-utils"
+import { normalizePath } from "@/lib/path-utils"
+import type { FileNode, WikiProject } from "@/types/wiki"
+
+export interface DeAiBatchCatalogChapter {
+  id: string
+  title: string
+  order: number
+  sourcePath: string
+}
+
+export interface DeAiBatchCatalogWork {
+  id: string
+  title: string
+  chapters: DeAiBatchCatalogChapter[]
+}
+
+function flatten(nodes: FileNode[]): FileNode[] {
+  const result: FileNode[] = []
+  for (const node of nodes) {
+    if (node.is_dir) result.push(...flatten(node.children ?? []))
+    else result.push(node)
+  }
+  return result
+}
+
+function stableSafeId(prefix: string, value: string): string {
+  let hash = 2166136261
+  for (let index = 0; index < value.length; index += 1) {
+    hash ^= value.charCodeAt(index)
+    hash = Math.imul(hash, 16777619)
+  }
+  return `${prefix}-${(hash >>> 0).toString(36)}`
+}
+
+function withoutExtension(name: string): string {
+  return name.replace(/\.md$/i, "")
+}
+
+export function buildDeAiBatchCatalog(project: WikiProject, fileTree: FileNode[]): DeAiBatchCatalogWork[] {
+  const chapterRoot = `${normalizePath(project.path).replace(/\/$/, "")}/wiki/chapters/`
+  const groups = new Map<string, FileNode[]>()
+  for (const file of flatten(fileTree)) {
+    const path = normalizePath(file.path)
+    if (!path.toLowerCase().endsWith(".md") || !path.startsWith(chapterRoot)) continue
+    const relative = path.slice(chapterRoot.length)
+    const segments = relative.split("/").filter(Boolean)
+    if (segments.length === 0) continue
+    const groupTitle = segments.length > 1 ? segments[0] : project.name
+    const current = groups.get(groupTitle) ?? []
+    current.push(file)
+    groups.set(groupTitle, current)
+  }
+
+  return Array.from(groups.entries())
+    .map(([title, files]) => {
+      const workId = stableSafeId("work", `${project.id}:${title}`)
+      const chapters = files.map((file, index) => {
+        const chapterTitle = withoutExtension(file.name)
+        return {
+          id: stableSafeId("chapter", normalizePath(file.path)),
+          title: chapterTitle,
+          order: extractChapterNumber(chapterTitle) ?? index + 1,
+          sourcePath: normalizePath(file.path),
+        }
+      }).sort((a, b) => a.order - b.order || a.title.localeCompare(b.title, "zh-Hans-CN", { numeric: true }))
+      return { id: workId, title, chapters }
+    })
+    .sort((a, b) => a.title.localeCompare(b.title, "zh-Hans-CN", { numeric: true }))
+}

+ 204 - 0
src/lib/novel/de-ai-batch/chapter-apply.spec.ts

@@ -0,0 +1,204 @@
+import { describe, expect, it, vi } from "vitest"
+import {
+  applyOpenChapterBodyUpdate,
+  createDeAiBatchChapterApplier,
+} from "./chapter-apply"
+import { createChapterExternalUpdateCoordinator } from "@/lib/chapter-external-update-coordinator"
+
+const CURRENT_MARKDOWN = [
+  "---",
+  "type: chapter",
+  "chapter_number: 1",
+  "status: draft",
+  "custom: keep-me",
+  "---",
+  "# 第一章 原标题",
+  "",
+  "编辑器中的旧正文",
+].join("\n")
+
+const CANDIDATE = [
+  "---",
+  "status: malicious",
+  "---",
+  "# 不应替换原标题",
+  "",
+  "批量去 AI 味候选正文",
+].join("\n")
+
+describe("de-ai batch chapter apply", () => {
+  it("未打开章节时保留磁盘 frontmatter 和原标题,只替换正文", async () => {
+    const writeFileAtomic = vi.fn(async () => undefined)
+    const applier = createDeAiBatchChapterApplier({
+      requestOpenUpdate: vi.fn(async () => false),
+      readFile: vi.fn(async () => CURRENT_MARKDOWN),
+      writeFileAtomic,
+    })
+
+    await applier("C:/project/wiki/chapters/第1章.md", CANDIDATE)
+
+    expect(writeFileAtomic).toHaveBeenCalledTimes(1)
+    const written = writeFileAtomic.mock.calls[0][1]
+    expect(written).toContain("custom: keep-me")
+    expect(written).toContain("# 第一章 原标题")
+    expect(written).toContain("批量去 AI 味候选正文")
+    expect(written).not.toContain("status: malicious")
+    expect(written).not.toContain("# 不应替换原标题")
+  })
+
+  it("当前章节打开时先使旧自动保存失效,再写盘并同步编辑器,旧保存不能覆盖确认结果", async () => {
+    let saveGeneration = 7
+    const oldSaveGeneration = saveGeneration
+    let diskContent = CURRENT_MARKDOWN
+    let editorContent = CURRENT_MARKDOWN
+    let dataVersion = 0
+    const oldAutoSave = () => {
+      if (oldSaveGeneration === saveGeneration) diskContent = CURRENT_MARKDOWN
+    }
+
+    const applied = await applyOpenChapterBodyUpdate({
+      path: "C:/project/wiki/chapters/第1章.md",
+      candidateContent: CANDIDATE,
+      currentOpenPath: () => "C:/project/wiki/chapters/第1章.md",
+      currentMarkdown: () => editorContent,
+      invalidatePendingSave: () => { saveGeneration += 1 },
+      runExternalUpdate: async (_path, write) => { await write(); return 1 },
+      markEditorSession: vi.fn(),
+      writeFileAtomic: async (_path, content) => { diskContent = content },
+      commitEditor: (content) => { editorContent = content },
+      bumpDataVersion: () => { dataVersion += 1 },
+    })
+    oldAutoSave()
+
+    expect(applied).toBe(true)
+    expect(saveGeneration).toBe(8)
+    expect(editorContent).toBe(diskContent)
+    expect(diskContent).toContain("custom: keep-me")
+    expect(diskContent).toContain("# 第一章 原标题")
+    expect(diskContent).toContain("批量去 AI 味候选正文")
+    expect(diskContent).not.toContain("编辑器中的旧正文")
+    expect(dataVersion).toBe(1)
+  })
+
+  it("目标不是当前打开章节时不改变编辑器", async () => {
+    const writeFileAtomic = vi.fn()
+    const commitEditor = vi.fn()
+
+    await expect(applyOpenChapterBodyUpdate({
+      path: "C:/project/wiki/chapters/第2章.md",
+      candidateContent: CANDIDATE,
+      currentOpenPath: () => "C:/project/wiki/chapters/第1章.md",
+      currentMarkdown: () => CURRENT_MARKDOWN,
+      invalidatePendingSave: vi.fn(),
+      runExternalUpdate: async (_path, write) => { await write(); return 1 },
+      markEditorSession: vi.fn(),
+      writeFileAtomic,
+      commitEditor,
+      bumpDataVersion: vi.fn(),
+    })).resolves.toBe(false)
+
+    expect(writeFileAtomic).not.toHaveBeenCalled()
+    expect(commitEditor).not.toHaveBeenCalled()
+  })
+  it("写 A 期间切换到 B 时只完成 A 磁盘写入,不提交到 B 编辑器", async () => {
+    let openPath = "C:/project/wiki/chapters/第1章.md"
+    let saveGeneration = 11
+    const oldSaveGeneration = saveGeneration
+    let diskA = CURRENT_MARKDOWN
+    let diskB = "B 编辑器正文"
+    let releaseWrite!: () => void
+    let markWriteStarted!: () => void
+    const writeStarted = new Promise<void>((resolve) => { markWriteStarted = resolve })
+    const writeGate = new Promise<void>((resolve) => { releaseWrite = resolve })
+    const commitEditor = vi.fn((content: string) => { diskB = content })
+    const bumpDataVersion = vi.fn()
+    const oldAutoSaveA = () => {
+      if (oldSaveGeneration === saveGeneration) diskA = CURRENT_MARKDOWN
+    }
+
+    const applying = applyOpenChapterBodyUpdate({
+      path: "C:/project/wiki/chapters/第1章.md",
+      candidateContent: CANDIDATE,
+      currentOpenPath: () => openPath,
+      currentMarkdown: () => CURRENT_MARKDOWN,
+      invalidatePendingSave: () => { saveGeneration += 1 },
+      runExternalUpdate: async (_path, write) => { await write(); return 1 },
+      markEditorSession: vi.fn(),
+      writeFileAtomic: async (_path, content) => {
+        markWriteStarted()
+        await writeGate
+        diskA = content
+      },
+      commitEditor,
+      bumpDataVersion,
+    })
+
+    await writeStarted
+    openPath = "C:/project/wiki/chapters/第2章.md"
+    releaseWrite()
+    await expect(applying).resolves.toBe(true)
+    oldAutoSaveA()
+
+    expect(diskA).toContain("批量去 AI 味候选正文")
+    expect(diskB).toBe("B 编辑器正文")
+    expect(commitEditor).not.toHaveBeenCalled()
+    expect(bumpDataVersion).toHaveBeenCalledTimes(1)
+    expect(saveGeneration).toBe(12)
+  })
+
+  it("确认 A 写盘等待期间切 B 并触发 flush-before-leave 时,旧 A flush 不得覆盖候选", async () => {
+    const pathA = "C:/project/wiki/chapters/第1章.md"
+    const pathB = "C:/project/wiki/chapters/第2章.md"
+    const coordinator = createChapterExternalUpdateCoordinator()
+    coordinator.markEditorSession(pathA)
+    let openPath = pathA
+    let diskA = CURRENT_MARKDOWN
+    let editorB = "B 编辑器正文"
+    let releaseExternalWrite!: () => void
+    let markExternalWriteStarted!: () => void
+    const externalWriteStarted = new Promise<void>((resolve) => { markExternalWriteStarted = resolve })
+    const externalWriteGate = new Promise<void>((resolve) => { releaseExternalWrite = resolve })
+    const flushWrite = vi.fn(async () => { diskA = CURRENT_MARKDOWN })
+    const commitEditor = vi.fn((content: string) => { editorB = content })
+
+    const applying = applyOpenChapterBodyUpdate({
+      path: pathA,
+      candidateContent: CANDIDATE,
+      currentOpenPath: () => openPath,
+      currentMarkdown: () => CURRENT_MARKDOWN,
+      invalidatePendingSave: vi.fn(),
+      runExternalUpdate: coordinator.runExternalUpdate,
+      writeFileAtomic: async (_path, content) => {
+        markExternalWriteStarted()
+        await externalWriteGate
+        diskA = content
+      },
+      markEditorSession: coordinator.markEditorSession,
+      commitEditor,
+      bumpDataVersion: vi.fn(),
+    })
+
+    await externalWriteStarted
+    openPath = pathB
+    const flushing = coordinator.flushBeforeLeave(pathA, flushWrite)
+    releaseExternalWrite()
+    await Promise.all([applying, flushing])
+
+    expect(diskA).toContain("批量去 AI 味候选正文")
+    expect(flushWrite).not.toHaveBeenCalled()
+    expect(editorB).toBe("B 编辑器正文")
+    expect(commitEditor).not.toHaveBeenCalled()
+  })
+
+  it("没有外部更新时正常未保存编辑仍会执行 flush-before-leave", async () => {
+    const pathA = "C:/project/wiki/chapters/第1章.md"
+    const coordinator = createChapterExternalUpdateCoordinator()
+    coordinator.markEditorSession(pathA)
+    const flushWrite = vi.fn(async () => undefined)
+
+    await expect(coordinator.flushBeforeLeave(pathA, flushWrite)).resolves.toBe(true)
+
+    expect(flushWrite).toHaveBeenCalledTimes(1)
+  })
+})
+

+ 57 - 0
src/lib/novel/de-ai-batch/chapter-apply.ts

@@ -0,0 +1,57 @@
+import { readFile, writeFileAtomic } from "@/commands/fs"
+import { replaceWholeChapterBody } from "@/lib/chapter-selection"
+import { requestEditorExternalChapterBodyUpdate } from "@/lib/editor-external-update-session"
+import { normalizePath } from "@/lib/path-utils"
+
+export interface OpenChapterBodyUpdateInput {
+  path: string
+  candidateContent: string
+  currentOpenPath(): string | null
+  currentMarkdown(): string
+  invalidatePendingSave(): void
+  runExternalUpdate(path: string, write: () => Promise<void>): Promise<number>
+  markEditorSession(path: string, version?: number): void
+  writeFileAtomic(path: string, content: string): Promise<void>
+  commitEditor(content: string): void
+  bumpDataVersion(): void
+}
+
+export async function applyOpenChapterBodyUpdate(input: OpenChapterBodyUpdateInput): Promise<boolean> {
+  const targetPath = normalizePath(input.path)
+  const initialOpenPath = input.currentOpenPath()
+  if (!initialOpenPath || normalizePath(initialOpenPath) !== targetPath) return false
+  const merged = replaceWholeChapterBody(input.currentMarkdown(), input.candidateContent)
+  input.invalidatePendingSave()
+  const externalVersion = await input.runExternalUpdate(input.path, () =>
+    input.writeFileAtomic(input.path, merged),
+  )
+  input.bumpDataVersion()
+  const latestOpenPath = input.currentOpenPath()
+  if (latestOpenPath && normalizePath(latestOpenPath) === targetPath) {
+    input.commitEditor(merged)
+    input.markEditorSession(input.path, externalVersion)
+  }
+  return true
+}
+
+export interface DeAiBatchChapterApplierOptions {
+  requestOpenUpdate(path: string, candidateContent: string): Promise<boolean>
+  readFile(path: string): Promise<string>
+  writeFileAtomic(path: string, content: string): Promise<void>
+}
+
+export function createDeAiBatchChapterApplier(
+  options: DeAiBatchChapterApplierOptions = {
+    requestOpenUpdate: requestEditorExternalChapterBodyUpdate,
+    readFile,
+    writeFileAtomic,
+  },
+): (path: string, candidateContent: string) => Promise<void> {
+  return async (path, candidateContent) => {
+    if (await options.requestOpenUpdate(path, candidateContent)) return
+    const currentMarkdown = await options.readFile(path)
+    const merged = replaceWholeChapterBody(currentMarkdown, candidateContent)
+    await options.writeFileAtomic(path, merged)
+  }
+}
+

+ 73 - 0
src/lib/novel/de-ai-batch/chapter-path.ts

@@ -0,0 +1,73 @@
+import { normalizePath } from "@/lib/path-utils"
+
+const SOURCE_PATH_ERROR = "章节源文件不属于当前项目章节目录"
+
+type PathKind = "windows" | "unc" | "posix"
+
+interface ParsedAbsolutePath {
+  kind: PathKind
+  root: string
+  segments: string[]
+  caseInsensitive: boolean
+}
+
+function fail(): never {
+  throw new Error(SOURCE_PATH_ERROR)
+}
+
+function parseAbsolutePath(value: string): ParsedAbsolutePath {
+  const path = normalizePath(value.trim())
+  if (!path || path.includes("\0") || path.startsWith("//?/") || path.startsWith("//./")) fail()
+  const rawSegments = path.split("/")
+  if (rawSegments.some((segment) => segment === "..")) fail()
+
+  const drive = /^([A-Za-z]):\/(.*)$/.exec(path)
+  if (drive) {
+    return {
+      kind: "windows",
+      root: `${drive[1].toUpperCase()}:`,
+      segments: drive[2].split("/").filter((segment) => segment && segment !== "."),
+      caseInsensitive: true,
+    }
+  }
+
+  const unc = /^\/\/([^/]+)\/([^/]+)(?:\/(.*))?$/.exec(path)
+  if (unc) {
+    return {
+      kind: "unc",
+      root: `//${unc[1]}/${unc[2]}`,
+      segments: (unc[3] ?? "").split("/").filter((segment) => segment && segment !== "."),
+      caseInsensitive: true,
+    }
+  }
+
+  if (path.startsWith("/")) {
+    return {
+      kind: "posix",
+      root: "/",
+      segments: path.slice(1).split("/").filter((segment) => segment && segment !== "."),
+      caseInsensitive: false,
+    }
+  }
+
+  return fail()
+}
+
+function equalPart(left: string, right: string, caseInsensitive: boolean): boolean {
+  return caseInsensitive ? left.toLocaleLowerCase("en-US") === right.toLocaleLowerCase("en-US") : left === right
+}
+
+export function assertChapterSourcePath(projectPath: string, sourcePath: string): string {
+  const project = parseAbsolutePath(projectPath)
+  const source = parseAbsolutePath(sourcePath)
+  const rootSegments = [...project.segments, "wiki", "chapters"]
+  if (
+    project.kind !== source.kind
+    || !equalPart(project.root, source.root, project.caseInsensitive)
+    || source.segments.length <= rootSegments.length
+    || rootSegments.some((segment, index) => !equalPart(segment, source.segments[index], project.caseInsensitive))
+  ) fail()
+
+  const prefix = source.root === "/" ? "/" : `${source.root}/`
+  return `${prefix}${source.segments.join("/")}`
+}

+ 187 - 0
src/lib/novel/de-ai-batch/engine.spec.ts

@@ -0,0 +1,187 @@
+import { describe, expect, it, vi } from "vitest"
+import { createDeAiBatchEngine, type DeAiChapterRunner } from "./engine"
+import type { DeAiBatchChapter, DeAiBatchTask, DeAiBatchTaskRecord } from "./types"
+
+function deferred<T>() {
+  let resolve!: (value: T) => void
+  let reject!: (reason?: unknown) => void
+  const promise = new Promise<T>((res, rej) => {
+    resolve = res
+    reject = rej
+  })
+  return { promise, resolve, reject }
+}
+
+function record(): DeAiBatchTaskRecord {
+  const task: DeAiBatchTask = {
+    version: 1,
+    id: "task-a",
+    projectPath: "C:/project",
+    workId: "work-a",
+    workTitle: "作品甲",
+    modelKey: "openai/test",
+    skillId: "skill-a",
+    skillName: "自然改写",
+    skillContent: "规则",
+    status: "queued",
+    chapterIds: ["chapter-1", "chapter-2"],
+    error: null,
+    createdAt: 1,
+    startedAt: null,
+    completedAt: null,
+    updatedAt: 1,
+  }
+  const chapter = (id: string, order: number): DeAiBatchChapter => ({
+    version: 1,
+    id,
+    taskId: task.id,
+    title: `第${order}章`,
+    order,
+    sourcePath: `C:/project/chapters/${id}.md`,
+    sourceContent: `${id}原文`,
+    candidateContent: null,
+    status: "pending",
+    runId: null,
+    generation: 0,
+    error: null,
+    createdAt: 1,
+    updatedAt: 1,
+  })
+  return { task, chapters: [chapter("chapter-1", 1), chapter("chapter-2", 2)] }
+}
+
+function setup(runner: DeAiChapterRunner, now = (() => {
+  let value = 10
+  return () => value++
+})()) {
+  const storage = {
+    saveTask: vi.fn(async () => undefined),
+    saveChapter: vi.fn(async () => undefined),
+  }
+  const engine = createDeAiBatchEngine({ runner, storage, now, id: (() => {
+    let value = 0
+    return () => `run-${++value}`
+  })() })
+  return { engine, storage }
+}
+
+describe("de-ai batch engine", () => {
+  it("按章节独立调用模型并在每章完成后持久化候选", async () => {
+    const calls: string[] = []
+    const runner = vi.fn<DeAiChapterRunner>(async ({ task, chapter }) => {
+      calls.push(`${task.workId}:${chapter.id}`)
+      return `${chapter.sourceContent}-候选`
+    })
+    const { engine, storage } = setup(runner)
+
+    const result = await engine.runTask(record())
+
+    expect(calls).toEqual(["work-a:chapter-1", "work-a:chapter-2"])
+    expect(result.chapters.map((chapter) => [chapter.status, chapter.candidateContent])).toEqual([
+      ["ready", "chapter-1原文-候选"],
+      ["ready", "chapter-2原文-候选"],
+    ])
+    expect(result.task.status).toBe("reviewing")
+    expect(storage.saveChapter).toHaveBeenCalled()
+    expect(storage.saveTask).toHaveBeenLastCalledWith(expect.objectContaining({ status: "reviewing" }))
+  })
+
+  it("单章失败写入检查点但继续处理后续章节", async () => {
+    const runner = vi.fn<DeAiChapterRunner>(async ({ chapter }) => {
+      if (chapter.id === "chapter-1") throw new Error("模型暂时不可用")
+      return "第二章候选"
+    })
+    const { engine } = setup(runner)
+
+    const result = await engine.runTask(record())
+
+    expect(result.chapters[0]).toMatchObject({
+      status: "failed",
+      candidateContent: null,
+      error: "模型暂时不可用",
+    })
+    expect(result.chapters[1]).toMatchObject({ status: "ready", candidateContent: "第二章候选" })
+    expect(result.task.status).toBe("partial")
+    expect(runner).toHaveBeenCalledTimes(2)
+  })
+
+  it("取消只中止当前章节并将该章标记为 cancelled", async () => {
+    const started = deferred<void>()
+    const runner = vi.fn<DeAiChapterRunner>(async ({ signal }) => {
+      started.resolve()
+      await new Promise<void>((_resolve, reject) => {
+        signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true })
+      })
+      return "不会产生"
+    })
+    const { engine } = setup(runner)
+    const source = record()
+
+    const running = engine.runChapter(source, "chapter-1")
+    await started.promise
+    await engine.cancelChapter("task-a", "chapter-1")
+    const result = await running
+
+    expect(result).toMatchObject({ status: "cancelled", candidateContent: null, runId: null })
+    expect(source.chapters[1].status).toBe("pending")
+  })
+
+  it("重新生成失败时保留旧候选,成功前也不清空旧候选", async () => {
+    const pending = deferred<string>()
+    const runner = vi.fn<DeAiChapterRunner>(() => pending.promise)
+    const { engine } = setup(runner)
+    const source = record()
+    source.chapters[0] = { ...source.chapters[0], status: "ready", candidateContent: "旧候选" }
+
+    const running = engine.regenerateChapter(source, "chapter-1")
+    expect(source.chapters[0].candidateContent).toBe("旧候选")
+    pending.reject(new Error("重新生成失败"))
+    const result = await running
+
+    expect(result).toMatchObject({
+      status: "failed",
+      candidateContent: "旧候选",
+      error: "重新生成失败",
+    })
+  })
+
+  it("较早请求后返回时不会覆盖较新的重新生成结果", async () => {
+    const first = deferred<string>()
+    const second = deferred<string>()
+    const runner = vi.fn<DeAiChapterRunner>()
+      .mockImplementationOnce(() => first.promise)
+      .mockImplementationOnce(() => second.promise)
+    const { engine } = setup(runner)
+    const source = record()
+    source.chapters[0] = { ...source.chapters[0], status: "ready", candidateContent: "旧候选" }
+
+    const earlier = engine.regenerateChapter(source, "chapter-1")
+    const later = engine.regenerateChapter(source, "chapter-1")
+    second.resolve("新候选")
+    await later
+    first.resolve("陈旧候选")
+    await earlier
+
+    expect(source.chapters[0]).toMatchObject({ status: "ready", candidateContent: "新候选", runId: null })
+  })
+  it("dispose 中止当前章后不再启动后续章节", async () => {
+    const started = deferred<void>()
+    const runner = vi.fn<DeAiChapterRunner>(async ({ signal }) => {
+      started.resolve()
+      await new Promise<void>((_resolve, reject) => {
+        signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true })
+      })
+      return "不会产生"
+    })
+    const { engine } = setup(runner)
+    const source = record()
+
+    const running = engine.runTask(source)
+    await started.promise
+    engine.dispose()
+    await running
+
+    expect(runner).toHaveBeenCalledTimes(1)
+    expect(source.chapters[1].status).toBe("pending")
+  })
+})

+ 208 - 0
src/lib/novel/de-ai-batch/engine.ts

@@ -0,0 +1,208 @@
+import type { DeAiBatchStorage } from "./storage"
+import type {
+  DeAiBatchChapter,
+  DeAiBatchTask,
+  DeAiBatchTaskRecord,
+  DeAiBatchTaskStatus,
+} from "./types"
+
+export interface DeAiChapterRunnerInput {
+  task: DeAiBatchTask
+  chapter: DeAiBatchChapter
+  signal: AbortSignal
+}
+
+export type DeAiChapterRunner = (input: DeAiChapterRunnerInput) => Promise<string>
+
+export interface DeAiBatchEngineStorage {
+  saveTask(task: DeAiBatchTask): Promise<void>
+  saveChapter(chapter: DeAiBatchChapter, projectPath?: string): Promise<void>
+}
+
+export interface DeAiBatchEngineOptions {
+  runner: DeAiChapterRunner
+  storage: DeAiBatchEngineStorage | Pick<DeAiBatchStorage, "saveTask" | "saveChapter">
+  now?: () => number
+  id?: () => string
+  onChange?: (record: DeAiBatchTaskRecord) => void
+}
+
+interface ActiveRun {
+  record: DeAiBatchTaskRecord
+  runId: string
+  controller: AbortController
+}
+
+function messageOf(error: unknown): string {
+  return error instanceof Error ? error.message : String(error)
+}
+
+function runKey(taskId: string, chapterId: string): string {
+  return `${taskId}:${chapterId}`
+}
+
+function createRunId(): string {
+  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
+    return crypto.randomUUID()
+  }
+  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
+}
+
+function deriveTaskStatus(chapters: DeAiBatchChapter[]): DeAiBatchTaskStatus {
+  if (chapters.length > 0 && chapters.every((chapter) => chapter.status === "confirmed")) return "completed"
+  if (chapters.length > 0 && chapters.every((chapter) => chapter.status === "cancelled")) return "cancelled"
+  const hasFailed = chapters.some((chapter) => chapter.status === "failed")
+  const hasResult = chapters.some((chapter) => chapter.status === "ready" || chapter.status === "confirmed")
+  const hasCancelled = chapters.some((chapter) => chapter.status === "cancelled")
+  if (hasFailed && !hasResult) return "failed"
+  if (hasFailed || hasCancelled) return "partial"
+  return "reviewing"
+}
+
+export function createDeAiBatchEngine(options: DeAiBatchEngineOptions) {
+  const now = options.now ?? Date.now
+  const nextId = options.id ?? createRunId
+  const activeRuns = new Map<string, ActiveRun>()
+  const records = new Map<string, DeAiBatchTaskRecord>()
+  let disposed = false
+
+  function publish(record: DeAiBatchTaskRecord): void {
+    options.onChange?.(record)
+  }
+
+  function replaceChapter(record: DeAiBatchTaskRecord, chapter: DeAiBatchChapter): DeAiBatchChapter {
+    const index = record.chapters.findIndex((item) => item.id === chapter.id)
+    if (index < 0) throw new Error(`找不到章节:${chapter.id}`)
+    record.chapters[index] = chapter
+    publish(record)
+    return chapter
+  }
+
+  async function persistChapter(record: DeAiBatchTaskRecord, chapter: DeAiBatchChapter): Promise<void> {
+    await options.storage.saveChapter(chapter, record.task.projectPath)
+  }
+
+  async function runChapter(record: DeAiBatchTaskRecord, chapterId: string): Promise<DeAiBatchChapter> {
+    records.set(record.task.id, record)
+    const current = record.chapters.find((chapter) => chapter.id === chapterId)
+    if (!current) throw new Error(`找不到章节:${chapterId}`)
+    const key = runKey(record.task.id, chapterId)
+    activeRuns.get(key)?.controller.abort()
+    const controller = new AbortController()
+    const runId = nextId()
+    const generating = replaceChapter(record, {
+      ...current,
+      status: "generating",
+      runId,
+      generation: current.generation + 1,
+      error: null,
+      updatedAt: now(),
+    })
+    activeRuns.set(key, { record, runId, controller })
+    await persistChapter(record, generating)
+
+    try {
+      const candidate = await options.runner({
+        task: record.task,
+        chapter: generating,
+        signal: controller.signal,
+      })
+      if (activeRuns.get(key)?.runId !== runId) {
+        return record.chapters.find((chapter) => chapter.id === chapterId) ?? generating
+      }
+      if (!candidate.trim()) throw new Error("模型返回了空内容")
+      const ready = replaceChapter(record, {
+        ...generating,
+        candidateContent: candidate,
+        status: "ready",
+        runId: null,
+        error: null,
+        updatedAt: now(),
+      })
+      await persistChapter(record, ready)
+      return ready
+    } catch (error) {
+      if (activeRuns.get(key)?.runId !== runId) {
+        return record.chapters.find((chapter) => chapter.id === chapterId) ?? generating
+      }
+      const cancelled = controller.signal.aborted
+      const failed = replaceChapter(record, {
+        ...generating,
+        status: cancelled ? "cancelled" : "failed",
+        runId: null,
+        error: cancelled ? null : messageOf(error),
+        updatedAt: now(),
+      })
+      await persistChapter(record, failed)
+      return failed
+    } finally {
+      if (activeRuns.get(key)?.runId === runId) activeRuns.delete(key)
+    }
+  }
+
+  async function runTask(record: DeAiBatchTaskRecord): Promise<DeAiBatchTaskRecord> {
+    if (disposed) throw new Error("作品 Agent 已释放")
+    records.set(record.task.id, record)
+    const startedAt = record.task.startedAt ?? now()
+    record.task = {
+      ...record.task,
+      status: "running",
+      error: null,
+      startedAt,
+      completedAt: null,
+      updatedAt: now(),
+    }
+    publish(record)
+    await options.storage.saveTask(record.task)
+    for (const chapter of [...record.chapters].sort((a, b) => a.order - b.order)) {
+      if (chapter.status === "ready" || chapter.status === "confirmed" || chapter.status === "cancelled") continue
+      await runChapter(record, chapter.id)
+      if (disposed) return record
+    }
+    const status = deriveTaskStatus(record.chapters)
+    record.task = {
+      ...record.task,
+      status,
+      error: status === "failed" ? "全部章节处理失败" : null,
+      completedAt: status === "completed" || status === "cancelled" ? now() : null,
+      updatedAt: now(),
+    }
+    publish(record)
+    await options.storage.saveTask(record.task)
+    return record
+  }
+
+  function regenerateChapter(record: DeAiBatchTaskRecord, chapterId: string): Promise<DeAiBatchChapter> {
+    return runChapter(record, chapterId)
+  }
+
+  async function cancelChapter(taskId: string, chapterId: string): Promise<void> {
+    const key = runKey(taskId, chapterId)
+    const active = activeRuns.get(key)
+    const record = active?.record ?? records.get(taskId)
+    if (!record) return
+    active?.controller.abort()
+    activeRuns.delete(key)
+    const current = record.chapters.find((chapter) => chapter.id === chapterId)
+    if (!current || current.status === "confirmed") return
+    const cancelled = replaceChapter(record, {
+      ...current,
+      status: "cancelled",
+      runId: null,
+      error: null,
+      updatedAt: now(),
+    })
+    await persistChapter(record, cancelled)
+  }
+
+  function dispose(): void {
+    disposed = true
+    for (const active of activeRuns.values()) active.controller.abort()
+    activeRuns.clear()
+    records.clear()
+  }
+
+  return { runTask, runChapter, regenerateChapter, cancelChapter, dispose }
+}
+
+export type DeAiBatchEngine = ReturnType<typeof createDeAiBatchEngine>

+ 46 - 0
src/lib/novel/de-ai-batch/latest-value-save-queue.spec.ts

@@ -0,0 +1,46 @@
+import { describe, expect, it, vi } from "vitest"
+import { createLatestValueSaveQueue } from "./latest-value-save-queue"
+
+function deferred() {
+  let resolve!: () => void
+  const promise = new Promise<void>((done) => { resolve = done })
+  return { promise, resolve }
+}
+
+describe("latest value save queue", () => {
+  it("延迟保存期间合并变化并保证重启读取到最后值", async () => {
+    const first = deferred()
+    let persisted = 3
+    const save = vi.fn(async (value: number) => {
+      if (save.mock.calls.length === 1) await first.promise
+      persisted = value
+    })
+    const queue = createLatestValueSaveQueue(save)
+
+    const saving3 = queue.enqueue(3)
+    const saving4 = queue.enqueue(4)
+    const saving5 = queue.enqueue(5)
+    expect(save).toHaveBeenCalledTimes(1)
+    first.resolve()
+    await Promise.all([saving3, saving4, saving5])
+
+    expect(save.mock.calls.map(([value]) => value)).toEqual([3, 5])
+    expect(persisted).toBe(5)
+    const restartedConcurrency = persisted
+    expect(restartedConcurrency).toBe(5)
+  })
+
+  it("一次保存失败后仍继续保存等待中的最后值", async () => {
+    const save = vi.fn()
+      .mockRejectedValueOnce(new Error("首次保存失败"))
+      .mockResolvedValueOnce(undefined)
+    const queue = createLatestValueSaveQueue<number>(save)
+
+    const first = queue.enqueue(2)
+    const latest = queue.enqueue(4)
+
+    await expect(first).rejects.toThrow("首次保存失败")
+    await expect(latest).resolves.toBeUndefined()
+    expect(save.mock.calls.map(([value]) => value)).toEqual([2, 4])
+  })
+})

+ 44 - 0
src/lib/novel/de-ai-batch/latest-value-save-queue.ts

@@ -0,0 +1,44 @@
+interface PendingBatch<T> {
+  value: T
+  waiters: Array<{ resolve(): void; reject(error: unknown): void }>
+}
+
+export function createLatestValueSaveQueue<T>(save: (value: T) => Promise<void>) {
+  let pending: PendingBatch<T> | null = null
+  let draining = false
+
+  async function drain(): Promise<void> {
+    if (draining) return
+    draining = true
+    try {
+      while (pending) {
+        const batch = pending
+        pending = null
+        try {
+          await save(batch.value)
+          for (const waiter of batch.waiters) waiter.resolve()
+        } catch (error) {
+          for (const waiter of batch.waiters) waiter.reject(error)
+        }
+      }
+    } finally {
+      draining = false
+      if (pending) void drain()
+    }
+  }
+
+  function enqueue(value: T): Promise<void> {
+    const result = new Promise<void>((resolve, reject) => {
+      if (pending) {
+        pending.value = value
+        pending.waiters.push({ resolve, reject })
+      } else {
+        pending = { value, waiters: [{ resolve, reject }] }
+      }
+    })
+    void drain()
+    return result
+  }
+
+  return { enqueue }
+}

+ 109 - 0
src/lib/novel/de-ai-batch/llm-runner.spec.ts

@@ -0,0 +1,109 @@
+import { describe, expect, it, vi } from "vitest"
+import {
+  createDeAiBatchLlmRunner,
+  resolveBoundDeAiBatchModel,
+  resolveDeAiBatchModelKey,
+} from "./llm-runner"
+import type { DeAiBatchChapter, DeAiBatchTask } from "./types"
+
+const task = {
+  id: "task-a",
+  modelKey: "custom-bound/test-model",
+  skillContent: "自然改写规则",
+} as DeAiBatchTask
+const chapter = {
+  id: "chapter-a",
+  sourceContent: "需要处理的正文",
+} as DeAiBatchChapter
+
+const baseConfig = {
+  provider: "openai",
+  apiKey: "current-fallback-key",
+  model: "current-fallback-model",
+  ollamaUrl: "",
+  customEndpoint: "https://fallback.example/v1",
+  maxContextSize: 120000,
+}
+
+const providerConfigs = {
+  "custom-bound": {
+    enabled: true,
+    apiKey: "bound-key",
+    baseUrl: "https://bound.example/v1",
+    savedModels: [{ id: "saved-1", name: "绑定模型", model: "test-model", createdAt: 1 }],
+  },
+  "custom-default": {
+    enabled: true,
+    apiKey: "default-key",
+    baseUrl: "https://default.example/v1",
+    savedModels: [{ id: "saved-2", name: "默认模型", model: "default-model", createdAt: 1 }],
+  },
+}
+
+describe("de-ai batch llm runner", () => {
+  it("复用去 AI 味消息、任务绑定模型和 AbortSignal,并拼接流式结果", async () => {
+    const config = { provider: "openai", model: "test-model" }
+    const signal = new AbortController().signal
+    const resolveConfig = vi.fn(() => config as never)
+    const stream = vi.fn(async (_config, messages, callbacks, receivedSignal) => {
+      expect(messages[0].content).toContain("自然改写规则")
+      expect(messages[1].content).toContain("需要处理的正文")
+      expect(receivedSignal).toBe(signal)
+      callbacks.onToken("候选")
+      callbacks.onToken("正文")
+      callbacks.onDone()
+    })
+    const runner = createDeAiBatchLlmRunner({ resolveConfig, stream: stream as never })
+
+    await expect(runner({ task, chapter, signal })).resolves.toBe("候选正文")
+    expect(resolveConfig).toHaveBeenCalledWith(task)
+    expect(stream).toHaveBeenCalledWith(config, expect.any(Array), expect.any(Object), signal)
+  })
+
+  it("按任务模型、项目默认模型、聊天模型的顺序生成稳定 provider/model key", () => {
+    expect(resolveDeAiBatchModelKey({
+      taskModel: "custom-bound/test-model",
+      defaultModel: "custom-default/default-model",
+      chatModel: "",
+      baseConfig: baseConfig as never,
+      providerConfigs: providerConfigs as never,
+    })).toBe("custom-bound/test-model")
+
+    expect(resolveDeAiBatchModelKey({
+      taskModel: "missing/model",
+      defaultModel: "custom-default/default-model",
+      chatModel: "custom-bound/test-model",
+      baseConfig: baseConfig as never,
+      providerConfigs: providerConfigs as never,
+    })).toBe("custom-default/default-model")
+  })
+
+  it("按持久化 key 重新解析当前凭据,不持久化或回退到当前模型", () => {
+    const resolved = resolveBoundDeAiBatchModel(task, {
+      baseConfig: baseConfig as never,
+      providerConfigs: providerConfigs as never,
+    })
+
+    expect(resolved.model).toBe("test-model")
+    expect(resolved.apiKey).toBe("bound-key")
+    expect(resolved.customEndpoint).toBe("https://bound.example/v1")
+  })
+
+  it("恢复时任务绑定模型缺失会中文失败,不静默使用当前 fallback 模型", () => {
+    const missingTask = { ...task, modelKey: "removed-provider/test-model" }
+
+    expect(() => resolveBoundDeAiBatchModel(missingTask, {
+      baseConfig: baseConfig as never,
+      providerConfigs: providerConfigs as never,
+    })).toThrow("任务绑定模型“removed-provider/test-model”已不可用,请重新配置该模型后再继续")
+  })
+
+  it("流式错误会拒绝当前章节而不是吞掉", async () => {
+    const stream = vi.fn(async (_config, _messages, callbacks) => {
+      callbacks.onError(new Error("模型失败"))
+    })
+    const runner = createDeAiBatchLlmRunner({ resolveConfig: () => ({}) as never, stream: stream as never })
+
+    await expect(runner({ task, chapter, signal: new AbortController().signal })).rejects.toThrow("模型失败")
+  })
+})

+ 99 - 0
src/lib/novel/de-ai-batch/llm-runner.ts

@@ -0,0 +1,99 @@
+import { streamChat, type StreamCallbacks } from "@/lib/llm-client"
+import type { ChatMessage } from "@/lib/llm-providers"
+import { buildDeAiRewriteMessages } from "@/lib/novel/de-ai-adapter"
+import {
+  isModelKeyRegistered,
+  resolveModelConfig,
+  resolveUsableModelKey,
+} from "@/lib/novel/model-resolver"
+import { hasUsableLlm } from "@/lib/has-usable-llm"
+import { useWikiStore, type LlmConfig, type ProviderConfigs } from "@/stores/wiki-store"
+import type { DeAiChapterRunner } from "./engine"
+import type { DeAiBatchTask } from "./types"
+
+export interface DeAiBatchLlmRunnerOptions {
+  resolveConfig: (task: DeAiBatchTask) => LlmConfig
+  stream: (
+    config: LlmConfig,
+    messages: ChatMessage[],
+    callbacks: StreamCallbacks,
+    signal?: AbortSignal,
+  ) => Promise<void>
+}
+
+export interface DeAiBatchModelKeyInput {
+  taskModel: string
+  defaultModel: string
+  chatModel: string
+  baseConfig: LlmConfig
+  providerConfigs: ProviderConfigs
+}
+
+export function resolveDeAiBatchModelKey(input: DeAiBatchModelKeyInput): string {
+  for (const candidate of [input.taskModel, input.defaultModel, input.chatModel]) {
+    const key = resolveUsableModelKey(candidate, input.baseConfig, input.providerConfigs)
+    if (key) return key
+  }
+  return ""
+}
+
+export function resolveBoundDeAiBatchModel(
+  task: Pick<DeAiBatchTask, "modelKey">,
+  input: { baseConfig: LlmConfig; providerConfigs: ProviderConfigs },
+): LlmConfig {
+  const modelKey = task.modelKey.trim()
+  if (!modelKey || !isModelKeyRegistered(modelKey, input.providerConfigs)) {
+    throw new Error(`任务绑定模型“${modelKey || "未设置"}”已不可用,请重新配置该模型后再继续`)
+  }
+  const config = resolveModelConfig(modelKey, input.baseConfig, input.providerConfigs)
+  if (!hasUsableLlm(config, input.providerConfigs)) {
+    throw new Error(`任务绑定模型“${modelKey}”已不可用,请重新配置该模型后再继续`)
+  }
+  return config
+}
+
+export function getDeAiBatchTaskModelError(task: Pick<DeAiBatchTask, "modelKey">): string | null {
+  const state = useWikiStore.getState()
+  try {
+    resolveBoundDeAiBatchModel(task, {
+      baseConfig: state.llmConfig,
+      providerConfigs: state.providerConfigs,
+    })
+    return null
+  } catch (error) {
+    return error instanceof Error ? error.message : String(error)
+  }
+}
+
+export function createDeAiBatchLlmRunner(options: DeAiBatchLlmRunnerOptions): DeAiChapterRunner {
+  return async ({ task, chapter, signal }) => {
+    const config = options.resolveConfig(task)
+    let content = ""
+    let streamError: Error | null = null
+    await options.stream(
+      config,
+      buildDeAiRewriteMessages(chapter.sourceContent, task.skillContent),
+      {
+        onToken: (token) => { content += token },
+        onDone: () => undefined,
+        onError: (error) => { streamError = error },
+      },
+      signal,
+    )
+    if (streamError) throw streamError
+    return content
+  }
+}
+
+export function createDefaultDeAiBatchLlmRunner(): DeAiChapterRunner {
+  return createDeAiBatchLlmRunner({
+    resolveConfig: (task) => {
+      const state = useWikiStore.getState()
+      return resolveBoundDeAiBatchModel(task, {
+        baseConfig: state.llmConfig,
+        providerConfigs: state.providerConfigs,
+      })
+    },
+    stream: streamChat,
+  })
+}

+ 205 - 0
src/lib/novel/de-ai-batch/scheduler.spec.ts

@@ -0,0 +1,205 @@
+import { describe, expect, it, vi } from "vitest"
+import {
+  clampDeAiBatchConcurrency,
+  createDeAiBatchScheduler,
+  type DeAiBatchAgent,
+} from "./scheduler"
+import type { DeAiBatchTaskRecord } from "./types"
+
+function deferred<T>() {
+  let resolve!: (value: T) => void
+  let reject!: (reason?: unknown) => void
+  const promise = new Promise<T>((res, rej) => {
+    resolve = res
+    reject = rej
+  })
+  return { promise, resolve, reject }
+}
+
+function record(id: string): DeAiBatchTaskRecord {
+  return {
+    task: {
+      version: 1,
+      id,
+      projectPath: "C:/project",
+      workId: id,
+      workTitle: id,
+      modelKey: "openai/test",
+      skillId: null,
+      skillName: "默认",
+      skillContent: "规则",
+      status: "queued",
+      chapterIds: [],
+      error: null,
+      createdAt: Number(id.replace(/\D/g, "")) || 1,
+      startedAt: null,
+      completedAt: null,
+      updatedAt: 1,
+    },
+    chapters: [],
+  }
+}
+
+async function flush(): Promise<void> {
+  await new Promise((resolve) => setTimeout(resolve, 0))
+}
+
+describe("de-ai batch scheduler", () => {
+  it("默认同时运行 3 个作品 Agent,超限任务严格 FIFO 排队", async () => {
+    const gates = new Map<string, ReturnType<typeof deferred<DeAiBatchTaskRecord>>>()
+    const started: string[] = []
+    const createAgent = vi.fn((item: DeAiBatchTaskRecord): DeAiBatchAgent => {
+      const gate = deferred<DeAiBatchTaskRecord>()
+      gates.set(item.task.id, gate)
+      return {
+        run: vi.fn(async () => {
+          started.push(item.task.id)
+          return gate.promise
+        }),
+        dispose: vi.fn(),
+      }
+    })
+    const scheduler = createDeAiBatchScheduler({ createAgent })
+
+    scheduler.enqueue([record("task-1"), record("task-2"), record("task-3"), record("task-4"), record("task-5")])
+    await flush()
+
+    expect(started).toEqual(["task-1", "task-2", "task-3"])
+    expect(scheduler.getSnapshot()).toMatchObject({ activeTaskIds: ["task-1", "task-2", "task-3"], queuedTaskIds: ["task-4", "task-5"] })
+
+    gates.get("task-2")?.resolve(record("task-2"))
+    await flush()
+    expect(started).toEqual(["task-1", "task-2", "task-3", "task-4"])
+
+    gates.get("task-1")?.resolve(record("task-1"))
+    await flush()
+    expect(started).toEqual(["task-1", "task-2", "task-3", "task-4", "task-5"])
+  })
+
+  it("并发设置限制在 1 到 5,非整数向下取整", () => {
+    expect(clampDeAiBatchConcurrency(-1)).toBe(1)
+    expect(clampDeAiBatchConcurrency(1)).toBe(1)
+    expect(clampDeAiBatchConcurrency(3.9)).toBe(3)
+    expect(clampDeAiBatchConcurrency(5)).toBe(5)
+    expect(clampDeAiBatchConcurrency(9)).toBe(5)
+    expect(clampDeAiBatchConcurrency(Number.NaN)).toBe(3)
+  })
+
+  it("一个作品失败不会阻断其他作品并立即释放槽位", async () => {
+    const completed: string[] = []
+    const failed: string[] = []
+    const scheduler = createDeAiBatchScheduler({
+      concurrency: 1,
+      createAgent: (item) => ({
+        run: async () => {
+          if (item.task.id === "task-1") throw new Error("作品一失败")
+          completed.push(item.task.id)
+          return item
+        },
+        dispose: vi.fn(),
+      }),
+      onTaskError: (item, error) => failed.push(`${item.task.id}:${error.message}`),
+    })
+
+    scheduler.enqueue([record("task-1"), record("task-2")])
+    await scheduler.whenIdle()
+
+    expect(failed).toEqual(["task-1:作品一失败"])
+    expect(completed).toEqual(["task-2"])
+    expect(scheduler.getSnapshot()).toMatchObject({ activeTaskIds: [], queuedTaskIds: [] })
+  })
+
+  it("dispose 中止活动 Agent 且不再启动等待任务", async () => {
+    const gate = deferred<DeAiBatchTaskRecord>()
+    const agents: Array<{ id: string; dispose: ReturnType<typeof vi.fn> }> = []
+    const started: string[] = []
+    const scheduler = createDeAiBatchScheduler({
+      concurrency: 1,
+      createAgent: (item) => {
+        const dispose = vi.fn()
+        agents.push({ id: item.task.id, dispose })
+        return {
+          run: async () => {
+            started.push(item.task.id)
+            return gate.promise
+          },
+          dispose,
+        }
+      },
+    })
+
+    scheduler.enqueue([record("task-1"), record("task-2")])
+    await flush()
+    scheduler.dispose()
+    gate.resolve(record("task-1"))
+    await flush()
+
+    expect(agents[0].dispose).toHaveBeenCalledTimes(1)
+    expect(started).toEqual(["task-1"])
+    expect(scheduler.getSnapshot().disposed).toBe(true)
+  })
+
+  it("创建 scheduler 不会自动恢复传入的 interrupted 任务", async () => {
+    const createAgent = vi.fn()
+    const interrupted = record("task-1")
+    interrupted.task.status = "interrupted"
+
+    const scheduler = createDeAiBatchScheduler({ createAgent, initialRecords: [interrupted] })
+    await flush()
+
+    expect(createAgent).not.toHaveBeenCalled()
+    expect(scheduler.getSnapshot().queuedTaskIds).toEqual([])
+  })
+  it("取消指定作品会移出 FIFO 队列且不会影响其他作品启动", async () => {
+    const first = deferred<DeAiBatchTaskRecord>()
+    const started: string[] = []
+    const scheduler = createDeAiBatchScheduler({
+      concurrency: 1,
+      createAgent: (item) => ({
+        run: async () => {
+          started.push(item.task.id)
+          return item.task.id === "task-1" ? first.promise : item
+        },
+        dispose: vi.fn(),
+      }),
+    })
+
+    scheduler.enqueue([record("task-1"), record("task-2"), record("task-3")])
+    await flush()
+    expect(scheduler.cancel("task-2")).toBe(true)
+    first.resolve(record("task-1"))
+    await scheduler.whenIdle()
+
+    expect(started).toEqual(["task-1", "task-3"])
+  })
+  it("取消活动作品后只中止并保留槽位,原 run settle 后才启动下一作品", async () => {
+    const first = deferred<DeAiBatchTaskRecord>()
+    const completed = vi.fn()
+    const started: string[] = []
+    const scheduler = createDeAiBatchScheduler({
+      concurrency: 1,
+      onTaskComplete: completed,
+      createAgent: (item) => ({
+        run: async () => {
+          started.push(item.task.id)
+          return item.task.id === "task-1" ? first.promise : item
+        },
+        dispose: vi.fn(),
+      }),
+    })
+
+    scheduler.enqueue([record("task-1"), record("task-2")])
+    await flush()
+    expect(scheduler.cancel("task-1")).toBe(true)
+    await flush()
+    expect(started).toEqual(["task-1"]);
+    expect(scheduler.getSnapshot()).toMatchObject({ activeTaskIds: ["task-1"], queuedTaskIds: ["task-2"] });
+    first.resolve(record("task-1"))
+    await scheduler.whenIdle()
+    await flush()
+
+    expect(started).toEqual(["task-1", "task-2"])
+    expect(completed).toHaveBeenCalledTimes(1)
+    expect(completed).toHaveBeenCalledWith(expect.objectContaining({ task: expect.objectContaining({ id: "task-2" }) }))
+  })
+})

+ 155 - 0
src/lib/novel/de-ai-batch/scheduler.ts

@@ -0,0 +1,155 @@
+import type { DeAiBatchTaskRecord } from "./types"
+
+export interface DeAiBatchAgent {
+  run(): Promise<DeAiBatchTaskRecord>
+  dispose(): void
+}
+
+export interface DeAiBatchSchedulerSnapshot {
+  concurrency: number
+  activeTaskIds: string[]
+  queuedTaskIds: string[]
+  disposed: boolean
+}
+
+export interface DeAiBatchSchedulerOptions {
+  concurrency?: number
+  createAgent(record: DeAiBatchTaskRecord): DeAiBatchAgent
+  initialRecords?: DeAiBatchTaskRecord[]
+  onChange?: (snapshot: DeAiBatchSchedulerSnapshot) => void
+  onTaskComplete?: (record: DeAiBatchTaskRecord) => void
+  onTaskError?: (record: DeAiBatchTaskRecord, error: Error) => void
+}
+
+interface ActiveAgent {
+  agent: DeAiBatchAgent
+  disposed: boolean
+  cancelled: boolean
+}
+
+export function clampDeAiBatchConcurrency(value: number): number {
+  if (!Number.isFinite(value)) return 3
+  return Math.min(5, Math.max(1, Math.floor(value)))
+}
+
+function toError(error: unknown): Error {
+  return error instanceof Error ? error : new Error(String(error))
+}
+
+export function createDeAiBatchScheduler(options: DeAiBatchSchedulerOptions) {
+  let concurrency = clampDeAiBatchConcurrency(options.concurrency ?? 3)
+  let disposed = false
+  const queue: DeAiBatchTaskRecord[] = []
+  const active = new Map<string, ActiveAgent>()
+  const knownTaskIds = new Set<string>()
+  const idleWaiters = new Set<() => void>()
+
+  function getSnapshot(): DeAiBatchSchedulerSnapshot {
+    return {
+      concurrency,
+      activeTaskIds: Array.from(active.keys()),
+      queuedTaskIds: queue.map((record) => record.task.id),
+      disposed,
+    }
+  }
+
+  function publish(): void {
+    options.onChange?.(getSnapshot())
+  }
+
+  function resolveIdle(): void {
+    if (!disposed && (queue.length > 0 || active.size > 0)) return
+    for (const resolve of idleWaiters) resolve()
+    idleWaiters.clear()
+  }
+
+  function disposeAgent(entry: ActiveAgent): void {
+    if (entry.disposed) return
+    entry.disposed = true
+    entry.agent.dispose()
+  }
+
+  function pump(): void {
+    if (disposed) {
+      resolveIdle()
+      return
+    }
+    while (active.size < concurrency && queue.length > 0) {
+      const record = queue.shift()!
+      const id = record.task.id
+      const entry: ActiveAgent = {
+        agent: options.createAgent(record),
+        disposed: false,
+        cancelled: false,
+      }
+      active.set(id, entry)
+      publish()
+      void entry.agent.run()
+        .then((result) => { if (!entry.cancelled) options.onTaskComplete?.(result) })
+        .catch((error) => { if (!entry.cancelled) options.onTaskError?.(record, toError(error)) })
+        .finally(() => {
+          disposeAgent(entry)
+          if (active.get(id) !== entry) return
+          active.delete(id)
+          knownTaskIds.delete(id)
+          publish()
+          pump()
+          resolveIdle()
+        })
+    }
+    publish()
+    resolveIdle()
+  }
+
+  function enqueue(records: DeAiBatchTaskRecord | DeAiBatchTaskRecord[]): void {
+    if (disposed) return
+    const items = Array.isArray(records) ? records : [records]
+    for (const record of items) {
+      if (knownTaskIds.has(record.task.id)) continue
+      knownTaskIds.add(record.task.id)
+      queue.push(record)
+    }
+    publish()
+    pump()
+  }
+
+  function cancel(taskId: string): boolean {
+    const queuedIndex = queue.findIndex((record) => record.task.id === taskId)
+    if (queuedIndex >= 0) {
+      queue.splice(queuedIndex, 1)
+      knownTaskIds.delete(taskId)
+      publish()
+      resolveIdle()
+      return true
+    }
+    const entry = active.get(taskId)
+    if (!entry) return false
+    entry.cancelled = true
+    disposeAgent(entry)
+    publish()
+    return true
+  }
+  function setConcurrency(value: number): void {
+    concurrency = clampDeAiBatchConcurrency(value)
+    publish()
+    pump()
+  }
+
+  function whenIdle(): Promise<void> {
+    if (disposed || (queue.length === 0 && active.size === 0)) return Promise.resolve()
+    return new Promise((resolve) => idleWaiters.add(resolve))
+  }
+
+  function dispose(): void {
+    if (disposed) return
+    disposed = true
+    queue.length = 0
+    for (const entry of active.values()) disposeAgent(entry)
+    publish()
+    resolveIdle()
+  }
+
+  return { enqueue, cancel, setConcurrency, getSnapshot, whenIdle, dispose }
+}
+
+export type DeAiBatchScheduler = ReturnType<typeof createDeAiBatchScheduler>

+ 210 - 0
src/lib/novel/de-ai-batch/storage.spec.ts

@@ -0,0 +1,210 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import {
+  createDeAiBatchStorage,
+  type DeAiBatchStorageIo,
+} from "./storage"
+import type { CreateDeAiBatchTaskInput, DeAiBatchChapter, DeAiBatchTask } from "./types"
+
+function normalize(path: string): string {
+  return path.replace(/\\/g, "/").replace(/\/+$/g, "")
+}
+
+function createMemoryIo() {
+  const files = new Map<string, string>()
+  const directories = new Set<string>()
+  const atomicWrites: string[] = []
+  const io: DeAiBatchStorageIo = {
+    createDirectory: vi.fn(async (path) => {
+      directories.add(normalize(path))
+    }),
+    writeFileAtomic: vi.fn(async (path, content) => {
+      const key = normalize(path)
+      files.set(key, content)
+      atomicWrites.push(key)
+    }),
+    readFile: vi.fn(async (path) => {
+      const key = normalize(path)
+      const value = files.get(key)
+      if (value === undefined) throw new Error(`missing: ${key}`)
+      return value
+    }),
+    listDirectory: vi.fn(async (path) => {
+      const root = `${normalize(path)}/`
+      const names = new Set<string>()
+      for (const entry of [...files.keys(), ...directories]) {
+        if (!entry.startsWith(root)) continue
+        const name = entry.slice(root.length).split("/")[0]
+        if (name) names.add(name)
+      }
+      return Array.from(names).map((name) => ({ name, path: `${root}${name}` }))
+    }),
+  }
+  return { io, files, atomicWrites }
+}
+
+function taskInput(overrides: Partial<CreateDeAiBatchTaskInput> = {}): CreateDeAiBatchTaskInput {
+  return {
+    projectPath: "C:/作品库/甲",
+    workId: "work-a",
+    workTitle: "作品甲",
+    modelKey: "openai/gpt-test",
+    skillId: "built-in:comprehensive",
+    skillName: "综合去 AI 味",
+    skillContent: "保留剧情并自然改写",
+    chapters: [
+      {
+        id: "chapter-1",
+        title: "第一章",
+        order: 1,
+        sourcePath: "C:/作品库/甲/wiki/chapters/第一章.md",
+        sourceContent: "第一章原文",
+      },
+      {
+        id: "chapter-2",
+        title: "第二章",
+        order: 2,
+        sourcePath: "C:/作品库/甲/wiki/chapters/第二章.md",
+        sourceContent: "第二章原文",
+      },
+    ],
+    ...overrides,
+  }
+}
+
+function runningTask(task: DeAiBatchTask): DeAiBatchTask {
+  return { ...task, status: "running", startedAt: 20, updatedAt: 20 }
+}
+
+function generatingChapter(chapter: DeAiBatchChapter): DeAiBatchChapter {
+  return {
+    ...chapter,
+    status: "generating",
+    runId: "run-before-close",
+    updatedAt: 20,
+  }
+}
+
+describe("de-ai batch storage", () => {
+  let memory: ReturnType<typeof createMemoryIo>
+
+  beforeEach(() => {
+    memory = createMemoryIo()
+  })
+
+  it("原子持久化作品任务、章节检查点和不可变原文副本", async () => {
+    const storage = createDeAiBatchStorage(memory.io, { now: () => 10, id: () => "task-a" })
+
+    const created = await storage.createTask(taskInput())
+    const loaded = await storage.loadTask("C:/作品库/甲", "task-a")
+
+    expect(created.task).toMatchObject({
+      id: "task-a",
+      workId: "work-a",
+      workTitle: "作品甲",
+      status: "queued",
+      chapterIds: ["chapter-1", "chapter-2"],
+    })
+    expect(loaded?.chapters.map((chapter) => ({
+      id: chapter.id,
+      status: chapter.status,
+      sourceContent: chapter.sourceContent,
+      candidateContent: chapter.candidateContent,
+    }))).toEqual([
+      { id: "chapter-1", status: "pending", sourceContent: "第一章原文", candidateContent: null },
+      { id: "chapter-2", status: "pending", sourceContent: "第二章原文", candidateContent: null },
+    ])
+    expect(memory.atomicWrites).toEqual(expect.arrayContaining([
+      "C:/作品库/甲/.qmai/de-ai-batch/tasks/task-a/task.json",
+      "C:/作品库/甲/.qmai/de-ai-batch/tasks/task-a/chapters/chapter-1.json",
+      "C:/作品库/甲/.qmai/de-ai-batch/tasks/task-a/chapters/chapter-2.json",
+    ]))
+    expect(memory.io.writeFileAtomic).toHaveBeenCalledTimes(3)
+  })
+
+  it("重启加载时把运行中的任务和章节改为 interrupted 且保留候选结果", async () => {
+    const storage = createDeAiBatchStorage(memory.io, { now: () => 10, id: () => "task-a" })
+    const created = await storage.createTask(taskInput())
+    await storage.saveTask(runningTask(created.task))
+    await storage.saveChapter(generatingChapter({
+      ...created.chapters[0],
+      candidateContent: "上次完整候选",
+    }))
+
+    const restarted = createDeAiBatchStorage(memory.io, { now: () => 30, id: () => "unused" })
+    const loaded = await restarted.loadProject("C:/作品库/甲")
+
+    expect(loaded).toHaveLength(1)
+    expect(loaded[0].task).toMatchObject({ status: "interrupted", error: "软件上次关闭时任务尚未完成" })
+    expect(loaded[0].chapters[0]).toMatchObject({
+      status: "pending",
+      runId: null,
+      candidateContent: "上次完整候选",
+      error: "软件上次关闭时章节尚未完成,可继续处理",
+    })
+  })
+
+  it("仅凭持久化原文副本即可再次加载,不读取原始章节文件", async () => {
+    const storage = createDeAiBatchStorage(memory.io, { now: () => 10, id: () => "task-a" })
+    await storage.createTask(taskInput())
+    vi.mocked(memory.io.readFile).mockClear()
+
+    const loaded = await storage.loadTask("C:/作品库/甲", "task-a")
+
+    expect(loaded?.chapters[0].sourceContent).toBe("第一章原文")
+    expect(memory.io.readFile).not.toHaveBeenCalledWith("C:/作品库/甲/wiki/chapters/第一章.md")
+  })
+
+  it("拒绝包含路径穿越字符的任务或章节 ID", async () => {
+    const storage = createDeAiBatchStorage(memory.io, { now: () => 10, id: () => "../task" })
+
+    await expect(storage.createTask(taskInput())).rejects.toThrow("任务 ID 不合法")
+
+    const validStorage = createDeAiBatchStorage(memory.io, { now: () => 10, id: () => "task-a" })
+    await expect(validStorage.createTask(taskInput({
+      chapters: [{ ...taskInput().chapters[0], id: "../chapter" }],
+    }))).rejects.toThrow("章节 ID 不合法")
+  })
+  it.each([
+    ["父目录穿越", "C:/作品库/甲/wiki/chapters/../机密.md"],
+    ["章节目录前缀伪装", "C:/作品库/甲/wiki/chapters-evil/第一章.md"],
+    ["跨盘路径", "D:/作品库/甲/wiki/chapters/第一章.md"],
+    ["UNC 路径", "//server/share/第一章.md"],
+    ["大小写前缀伪装", "c:/作品库/甲/WIKI/CHAPTERS-EVIL/第一章.md"],
+  ])("创建任务时拒绝%s的 sourcePath", async (_label, sourcePath) => {
+    const storage = createDeAiBatchStorage(memory.io, { now: () => 10, id: () => "task-a" })
+    await expect(storage.createTask(taskInput({ chapters: [{ ...taskInput().chapters[0], sourcePath }] })))
+      .rejects.toThrow("章节源文件不属于当前项目章节目录")
+  })
+
+  it("允许 Windows 路径大小写不同但仍真实位于当前章节目录", async () => {
+    const storage = createDeAiBatchStorage(memory.io, { now: () => 10, id: () => "task-a" })
+    await expect(storage.createTask(taskInput({
+      chapters: [{ ...taskInput().chapters[0], sourcePath: "c:/作品库/甲/WIKI/CHAPTERS/第一章.md" }],
+    }))).resolves.toBeTruthy()
+  })
+
+  it("加载时拒绝 checkpoint 篡改后越出真实项目章节目录的 sourcePath", async () => {
+    const storage = createDeAiBatchStorage(memory.io, { now: () => 10, id: () => "task-a" })
+    await storage.createTask(taskInput())
+    const checkpointPath = "C:/作品库/甲/.qmai/de-ai-batch/tasks/task-a/chapters/chapter-1.json"
+    const checkpoint = JSON.parse(memory.files.get(checkpointPath)!) as DeAiBatchChapter
+    memory.files.set(checkpointPath, JSON.stringify({ ...checkpoint, sourcePath: "C:/作品库/甲/wiki/chapters/../../项目外.md" }))
+    await expect(storage.loadTask("C:/作品库/甲", "task-a"))
+      .rejects.toThrow("章节源文件不属于当前项目章节目录")
+  })
+  it("重启加载时把仍在排队的 queued 任务转为 interrupted 并保留原文副本", async () => {
+    const storage = createDeAiBatchStorage(memory.io, { now: () => 10, id: () => "task-queued" })
+    await storage.createTask(taskInput())
+
+    const restarted = createDeAiBatchStorage(memory.io, { now: () => 40, id: () => "unused" })
+    const loaded = await restarted.loadProject("C:/作品库/甲")
+
+    expect(loaded[0].task).toMatchObject({
+      id: "task-queued",
+      status: "interrupted",
+      error: "软件上次关闭时任务尚未开始,可继续处理",
+    })
+    expect(loaded[0].chapters.map((chapter) => chapter.sourceContent)).toEqual(["第一章原文", "第二章原文"])
+  })
+})
+

+ 255 - 0
src/lib/novel/de-ai-batch/storage.ts

@@ -0,0 +1,255 @@
+import {
+  createDirectory,
+  listDirectory,
+  readFile,
+  writeFileAtomic,
+} from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+import { assertChapterSourcePath } from "./chapter-path"
+import type {
+  CreateDeAiBatchTaskInput,
+  DeAiBatchChapter,
+  DeAiBatchTask,
+  DeAiBatchTaskRecord,
+} from "./types"
+
+const TASK_ID_PATTERN = /^[A-Za-z0-9_-]+$/
+const TASK_INTERRUPTED_ERROR = "软件上次关闭时任务尚未完成"
+const QUEUED_INTERRUPTED_ERROR = "软件上次关闭时任务尚未开始,可继续处理"
+const CHAPTER_INTERRUPTED_ERROR = "软件上次关闭时章节尚未完成,可继续处理"
+
+export interface DeAiBatchStorageEntry {
+  name: string
+  path: string
+}
+
+export interface DeAiBatchStorageIo {
+  createDirectory(path: string): Promise<void>
+  writeFileAtomic(path: string, content: string): Promise<void>
+  readFile(path: string): Promise<string>
+  listDirectory(path: string): Promise<DeAiBatchStorageEntry[]>
+}
+
+export interface DeAiBatchStorageOptions {
+  now?: () => number
+  id?: () => string
+}
+
+const defaultIo: DeAiBatchStorageIo = {
+  createDirectory,
+  writeFileAtomic,
+  readFile,
+  listDirectory: async (path) => listDirectory(path, { maxDepth: 1 }),
+}
+
+function joinPath(...parts: string[]): string {
+  return normalizePath(parts.join("/")).replace(/\/{2,}/g, "/")
+}
+
+function rootPath(projectPath: string): string {
+  return joinPath(projectPath, ".qmai", "de-ai-batch", "tasks")
+}
+
+function taskPath(projectPath: string, taskId: string): string {
+  return joinPath(rootPath(projectPath), taskId)
+}
+
+function taskFilePath(projectPath: string, taskId: string): string {
+  return joinPath(taskPath(projectPath, taskId), "task.json")
+}
+
+function chapterFilePath(projectPath: string, taskId: string, chapterId: string): string {
+  return joinPath(taskPath(projectPath, taskId), "chapters", `${chapterId}.json`)
+}
+
+function assertId(kind: "任务" | "章节", id: string): void {
+  if (!TASK_ID_PATTERN.test(id)) throw new Error(`${kind} ID 不合法:${id || "空值"}`)
+}
+
+function safeParse<T>(content: string, label: string): T {
+  try {
+    return JSON.parse(content) as T
+  } catch {
+    throw new Error(`${label}数据损坏`)
+  }
+}
+
+function createTaskId(): string {
+  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
+    return `de-ai-${crypto.randomUUID()}`
+  }
+  return `de-ai-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
+}
+
+export function createDeAiBatchStorage(
+  io: DeAiBatchStorageIo = defaultIo,
+  options: DeAiBatchStorageOptions = {},
+) {
+  const now = options.now ?? Date.now
+  const nextId = options.id ?? createTaskId
+  let saveChain: Promise<void> = Promise.resolve()
+
+  function serialize(write: () => Promise<void>): Promise<void> {
+    const next = saveChain.then(write, write)
+    saveChain = next.catch(() => undefined)
+    return next
+  }
+
+  async function ensureTaskDirectories(projectPath: string, taskId: string): Promise<void> {
+    await io.createDirectory(joinPath(projectPath, ".qmai"))
+    await io.createDirectory(joinPath(projectPath, ".qmai", "de-ai-batch"))
+    await io.createDirectory(rootPath(projectPath))
+    await io.createDirectory(taskPath(projectPath, taskId))
+    await io.createDirectory(joinPath(taskPath(projectPath, taskId), "chapters"))
+  }
+
+  async function saveTask(task: DeAiBatchTask): Promise<void> {
+    assertId("任务", task.id)
+    await serialize(async () => {
+      await ensureTaskDirectories(task.projectPath, task.id)
+      await io.writeFileAtomic(taskFilePath(task.projectPath, task.id), JSON.stringify(task, null, 2))
+    })
+  }
+
+  async function saveChapter(chapter: DeAiBatchChapter, projectPath?: string): Promise<void> {
+    assertId("任务", chapter.taskId)
+    assertId("章节", chapter.id)
+    const targetProjectPath = projectPath ?? activeProjectPaths.get(chapter.taskId)
+    if (!targetProjectPath) throw new Error(`找不到章节所属项目:${chapter.taskId}`)
+    await serialize(async () => {
+      await ensureTaskDirectories(targetProjectPath, chapter.taskId)
+      await io.writeFileAtomic(
+        chapterFilePath(targetProjectPath, chapter.taskId, chapter.id),
+        JSON.stringify(chapter, null, 2),
+      )
+    })
+  }
+
+  const activeProjectPaths = new Map<string, string>()
+
+  async function createTask(input: CreateDeAiBatchTaskInput): Promise<DeAiBatchTaskRecord> {
+    const id = nextId()
+    assertId("任务", id)
+    if (input.chapters.length === 0) throw new Error("请至少选择一个章节")
+    const chapterIds = new Set<string>()
+    for (const chapter of input.chapters) {
+      assertId("章节", chapter.id)
+      if (chapterIds.has(chapter.id)) throw new Error(`章节 ID 重复:${chapter.id}`)
+      chapterIds.add(chapter.id)
+    }
+    const timestamp = now()
+    const task: DeAiBatchTask = {
+      version: 1,
+      id,
+      projectPath: normalizePath(input.projectPath),
+      workId: input.workId,
+      workTitle: input.workTitle,
+      modelKey: input.modelKey,
+      skillId: input.skillId,
+      skillName: input.skillName,
+      skillContent: input.skillContent,
+      status: "queued",
+      chapterIds: input.chapters.map((chapter) => chapter.id),
+      error: null,
+      createdAt: timestamp,
+      startedAt: null,
+      completedAt: null,
+      updatedAt: timestamp,
+    }
+    const chapters: DeAiBatchChapter[] = input.chapters.map((chapter) => ({
+      version: 1,
+      id: chapter.id,
+      taskId: id,
+      title: chapter.title,
+      order: chapter.order,
+      sourcePath: assertChapterSourcePath(task.projectPath, chapter.sourcePath),
+      sourceContent: chapter.sourceContent,
+      candidateContent: null,
+      status: "pending",
+      runId: null,
+      generation: 0,
+      error: null,
+      createdAt: timestamp,
+      updatedAt: timestamp,
+    }))
+    activeProjectPaths.set(id, task.projectPath)
+    await ensureTaskDirectories(task.projectPath, id)
+    for (const chapter of chapters) await saveChapter(chapter, task.projectPath)
+    await saveTask(task)
+    return { task, chapters }
+  }
+
+  async function loadTask(projectPath: string, taskId: string): Promise<DeAiBatchTaskRecord | null> {
+    assertId("任务", taskId)
+    let task: DeAiBatchTask
+    try {
+      task = safeParse<DeAiBatchTask>(await io.readFile(taskFilePath(projectPath, taskId)), "任务")
+    } catch (error) {
+      if (error instanceof Error && error.message.startsWith("missing:")) return null
+      throw error
+    }
+    if (task.id !== taskId || normalizePath(task.projectPath) !== normalizePath(projectPath)) {
+      throw new Error(`任务身份不匹配:${taskId}`)
+    }
+    activeProjectPaths.set(taskId, normalizePath(projectPath))
+    const chapters: DeAiBatchChapter[] = []
+    for (const chapterId of task.chapterIds) {
+      assertId("章节", chapterId)
+      const chapter = safeParse<DeAiBatchChapter>(
+        await io.readFile(chapterFilePath(projectPath, taskId, chapterId)),
+        "章节",
+      )
+      assertChapterSourcePath(projectPath, chapter.sourcePath)
+      if (chapter.id !== chapterId || chapter.taskId !== taskId) {
+        throw new Error(`章节身份不匹配:${chapterId}`)
+      }
+      chapters.push(chapter)
+    }
+    return { task, chapters: chapters.sort((a, b) => a.order - b.order) }
+  }
+
+  async function loadProject(projectPath: string): Promise<DeAiBatchTaskRecord[]> {
+    let entries: DeAiBatchStorageEntry[]
+    try {
+      entries = await io.listDirectory(rootPath(projectPath))
+    } catch {
+      return []
+    }
+    const records: DeAiBatchTaskRecord[] = []
+    for (const entry of entries) {
+      if (!TASK_ID_PATTERN.test(entry.name)) continue
+      const record = await loadTask(projectPath, entry.name)
+      if (!record) continue
+      let changed = false
+      if (record.task.status === "running" || record.task.status === "queued") {
+        record.task = {
+          ...record.task,
+          status: "interrupted",
+          error: record.task.status === "queued" ? QUEUED_INTERRUPTED_ERROR : TASK_INTERRUPTED_ERROR,
+          completedAt: null,
+          updatedAt: now(),
+        }
+        changed = true
+      }
+      for (let index = 0; index < record.chapters.length; index += 1) {
+        const chapter = record.chapters[index]
+        if (chapter.status !== "generating") continue
+        record.chapters[index] = {
+          ...chapter,
+          status: "pending",
+          runId: null,
+          error: CHAPTER_INTERRUPTED_ERROR,
+          updatedAt: now(),
+        }
+        await saveChapter(record.chapters[index], projectPath)
+      }
+      if (changed) await saveTask(record.task)
+      records.push(record)
+    }
+    return records.sort((a, b) => a.task.createdAt - b.task.createdAt)
+  }
+
+  return { createTask, saveTask, saveChapter, loadTask, loadProject }
+}
+
+export type DeAiBatchStorage = ReturnType<typeof createDeAiBatchStorage>

+ 77 - 0
src/lib/novel/de-ai-batch/types.ts

@@ -0,0 +1,77 @@
+export type DeAiBatchTaskStatus =
+  | "queued"
+  | "running"
+  | "reviewing"
+  | "partial"
+  | "interrupted"
+  | "failed"
+  | "cancelled"
+  | "completed"
+
+export type DeAiBatchChapterStatus =
+  | "pending"
+  | "generating"
+  | "ready"
+  | "confirmed"
+  | "failed"
+  | "cancelled"
+
+export interface DeAiBatchChapterInput {
+  id: string
+  title: string
+  order: number
+  sourcePath: string
+  sourceContent: string
+}
+
+export interface CreateDeAiBatchTaskInput {
+  projectPath: string
+  workId: string
+  workTitle: string
+  modelKey: string
+  skillId: string | null
+  skillName: string
+  skillContent: string
+  chapters: DeAiBatchChapterInput[]
+}
+
+export interface DeAiBatchTask {
+  version: 1
+  id: string
+  projectPath: string
+  workId: string
+  workTitle: string
+  modelKey: string
+  skillId: string | null
+  skillName: string
+  skillContent: string
+  status: DeAiBatchTaskStatus
+  chapterIds: string[]
+  error: string | null
+  createdAt: number
+  startedAt: number | null
+  completedAt: number | null
+  updatedAt: number
+}
+
+export interface DeAiBatchChapter {
+  version: 1
+  id: string
+  taskId: string
+  title: string
+  order: number
+  sourcePath: string
+  sourceContent: string
+  candidateContent: string | null
+  status: DeAiBatchChapterStatus
+  runId: string | null
+  generation: number
+  error: string | null
+  createdAt: number
+  updatedAt: number
+}
+
+export interface DeAiBatchTaskRecord {
+  task: DeAiBatchTask
+  chapters: DeAiBatchChapter[]
+}

+ 1 - 0
src/lib/project-store.ts

@@ -726,6 +726,7 @@ function normalizeNovelConfig(
     summaryModel: config.summaryModel ?? DEFAULT_NOVEL_CONFIG.summaryModel,
     extractModel: config.extractModel ?? DEFAULT_NOVEL_CONFIG.extractModel,
     deAiModel: config.deAiModel ?? DEFAULT_NOVEL_CONFIG.deAiModel,
+    deAiBatchConcurrency: Math.max(1, Math.min(5, Math.floor(config.deAiBatchConcurrency ?? DEFAULT_NOVEL_CONFIG.deAiBatchConcurrency))),
     communitySummaryEnabled: config.communitySummaryEnabled ?? DEFAULT_NOVEL_CONFIG.communitySummaryEnabled,
     communitySummaryInterval: Math.max(1, Math.min(50, config.communitySummaryInterval ?? DEFAULT_NOVEL_CONFIG.communitySummaryInterval)),
     communitySummaryAsync: config.communitySummaryAsync ?? DEFAULT_NOVEL_CONFIG.communitySummaryAsync,

+ 390 - 0
src/stores/de-ai-batch-store.spec.ts

@@ -0,0 +1,390 @@
+import { describe, expect, it, vi } from "vitest"
+import { createDeAiBatchStore } from "./de-ai-batch-store"
+import type { DeAiChapterRunner } from "@/lib/novel/de-ai-batch/engine"
+import type {
+  CreateDeAiBatchTaskInput,
+  DeAiBatchChapter,
+  DeAiBatchTask,
+  DeAiBatchTaskRecord,
+} from "@/lib/novel/de-ai-batch/types"
+
+function input(workId: string, chapterIds = ["chapter-1", "chapter-2"]): CreateDeAiBatchTaskInput {
+  return {
+    projectPath: "C:/project",
+    workId,
+    workTitle: `作品-${workId}`,
+    modelKey: "openai/test",
+    skillId: "skill-a",
+    skillName: "自然改写",
+    skillContent: "规则",
+    chapters: chapterIds.map((id, index) => ({
+      id,
+      title: `第${index + 1}章`,
+      order: index + 1,
+      sourcePath: `C:/project/wiki/chapters/${workId}/${id}.md`,
+      sourceContent: `${workId}-${id}-原文`,
+    })),
+  }
+}
+
+function toRecord(source: CreateDeAiBatchTaskInput, id = `task-${source.workId}`): DeAiBatchTaskRecord {
+  const now = 1
+  const task: DeAiBatchTask = {
+    version: 1,
+    id,
+    projectPath: source.projectPath,
+    workId: source.workId,
+    workTitle: source.workTitle,
+    modelKey: source.modelKey,
+    skillId: source.skillId,
+    skillName: source.skillName,
+    skillContent: source.skillContent,
+    status: "queued",
+    chapterIds: source.chapters.map((chapter) => chapter.id),
+    error: null,
+    createdAt: now,
+    startedAt: null,
+    completedAt: null,
+    updatedAt: now,
+  }
+  const chapters: DeAiBatchChapter[] = source.chapters.map((chapter) => ({
+    version: 1,
+    id: chapter.id,
+    taskId: id,
+    title: chapter.title,
+    order: chapter.order,
+    sourcePath: chapter.sourcePath,
+    sourceContent: chapter.sourceContent,
+    candidateContent: null,
+    status: "pending",
+    runId: null,
+    generation: 0,
+    error: null,
+    createdAt: now,
+    updatedAt: now,
+  }))
+  return { task, chapters }
+}
+
+function setup(options: {
+  loaded?: DeAiBatchTaskRecord[]
+  runner?: DeAiChapterRunner
+  validateTaskModel?: (task: DeAiBatchTask) => string | null
+} = {}) {
+  const records = new Map<string, DeAiBatchTaskRecord>()
+  for (const item of options.loaded ?? []) records.set(item.task.id, structuredClone(item))
+  const storage = {
+    loadProject: vi.fn(async (projectPath: string) => Array.from(records.values())
+      .filter((item) => item.task.projectPath === projectPath)
+      .map((item) => structuredClone(item))),
+    createTask: vi.fn(async (source: CreateDeAiBatchTaskInput) => {
+      const item = toRecord(source)
+      records.set(item.task.id, structuredClone(item))
+      return item
+    }),
+    saveTask: vi.fn(async (task: DeAiBatchTask) => {
+      const current = records.get(task.id)
+      if (current) current.task = structuredClone(task)
+    }),
+    saveChapter: vi.fn(async (chapter: DeAiBatchChapter) => {
+      const current = records.get(chapter.taskId)
+      if (!current) return
+      const index = current.chapters.findIndex((item) => item.id === chapter.id)
+      if (index >= 0) current.chapters[index] = structuredClone(chapter)
+    }),
+  }
+  const runner = options.runner ?? vi.fn<DeAiChapterRunner>(async ({ chapter }) => `${chapter.sourceContent}-候选`)
+  const applyChapter = vi.fn(async () => undefined)
+  const store = createDeAiBatchStore({ storage, runner, applyChapter, validateTaskModel: options.validateTaskModel ?? (() => null), now: (() => {
+    let value = 10
+    return () => value++
+  })() })
+  return { store, storage, runner, applyChapter }
+}
+
+async function waitFor(assertion: () => void): Promise<void> {
+  const started = Date.now()
+  while (true) {
+    try {
+      assertion()
+      return
+    } catch (error) {
+      if (Date.now() - started > 1000) throw error
+      await new Promise((resolve) => setTimeout(resolve, 0))
+    }
+  }
+}
+
+describe("de-ai batch store", () => {
+  it("初始化只加载并展示 interrupted 任务,不自动继续", async () => {
+    const restored = toRecord(input("a"))
+    restored.task.status = "interrupted"
+    const { store, runner } = setup({ loaded: [restored] })
+
+    await store.getState().initializeProject("C:/project")
+
+    expect(store.getState().records).toHaveLength(1)
+    expect(store.getState().records[0].task.status).toBe("interrupted")
+    expect(runner).not.toHaveBeenCalled()
+  })
+
+  it("新批次按作品创建独立任务并交给 scheduler", async () => {
+    const { store, storage, runner } = setup()
+    await store.getState().initializeProject("C:/project")
+
+    await store.getState().createBatch([input("a"), input("b")])
+    await waitFor(() => {
+      expect(store.getState().records.map((item) => item.task.status)).toEqual(["reviewing", "reviewing"])
+    })
+
+    expect(storage.createTask).toHaveBeenCalledTimes(2)
+    expect(new Set(vi.mocked(runner).mock.calls.map(([call]) => call.task.workId))).toEqual(new Set(["a", "b"]))
+  })
+
+  it("继续 interrupted 任务时跳过已生成和已确认章节", async () => {
+    const restored = toRecord(input("a", ["chapter-1", "chapter-2", "chapter-3"]))
+    restored.task.status = "interrupted"
+    restored.chapters[0] = { ...restored.chapters[0], status: "ready", candidateContent: "已有候选" }
+    restored.chapters[1] = { ...restored.chapters[1], status: "confirmed", candidateContent: "已确认候选" }
+    const { store, runner } = setup({ loaded: [restored] })
+    await store.getState().initializeProject("C:/project")
+
+    expect(await store.getState().continueTask(restored.task.id)).toBe(true)
+    await waitFor(() => expect(store.getState().records[0].task.status).toBe("reviewing"))
+
+    expect(runner).toHaveBeenCalledTimes(1)
+    expect(vi.mocked(runner).mock.calls[0][0].chapter.id).toBe("chapter-3")
+  })
+
+  it("completed 任务不允许整批继续", async () => {
+    const restored = toRecord(input("a"))
+    restored.task.status = "completed"
+    restored.chapters = restored.chapters.map((chapter) => ({ ...chapter, status: "confirmed" }))
+    const { store, runner } = setup({ loaded: [restored] })
+    await store.getState().initializeProject("C:/project")
+
+    expect(await store.getState().continueTask(restored.task.id)).toBe(false)
+    expect(runner).not.toHaveBeenCalled()
+  })
+
+  it.each(["queued", "running", "reviewing", "cancelled", "completed"] as const)(
+    "%s 任务不在继续白名单中",
+    async (status) => {
+      const restored = toRecord(input("a"))
+      restored.task.status = status
+      const { store, runner } = setup({ loaded: [restored] })
+      await store.getState().initializeProject("C:/project")
+      expect(await store.getState().continueTask(restored.task.id)).toBe(false)
+      expect(runner).not.toHaveBeenCalled()
+    },
+  )
+
+  it("同一章节重复确认时只执行一次写回", async () => {
+    let release!: () => void
+    const applyGate = new Promise<void>((resolve) => { release = resolve })
+    const restored = toRecord(input("a"))
+    restored.task.status = "reviewing"
+    restored.chapters[0] = { ...restored.chapters[0], status: "ready", candidateContent: "候选" }
+    const { store, applyChapter } = setup({ loaded: [restored] })
+    vi.mocked(applyChapter).mockImplementationOnce(async () => applyGate)
+    await store.getState().initializeProject("C:/project")
+    const first = store.getState().confirmChapter(restored.task.id, "chapter-1")
+    const second = store.getState().confirmChapter(restored.task.id, "chapter-1")
+    expect(await second).toBe(false)
+    expect(applyChapter).toHaveBeenCalledTimes(1)
+    release()
+    expect(await first).toBe(true)
+    expect(store.getState().records[0].chapters[0].status).toBe("confirmed")
+  })
+
+  it("同一章节重新生成期间拒绝取消", async () => {
+    let release!: (value: string) => void
+    const gate = new Promise<string>((resolve) => { release = resolve })
+    const restored = toRecord(input("a"))
+    restored.task.status = "reviewing"
+    restored.chapters[0] = { ...restored.chapters[0], status: "ready", candidateContent: "旧候选" }
+    const runner = vi.fn<DeAiChapterRunner>(async () => gate)
+    const { store } = setup({ loaded: [restored], runner })
+    await store.getState().initializeProject("C:/project")
+    const regenerating = store.getState().regenerateChapter(restored.task.id, "chapter-1")
+    expect(await store.getState().cancelChapter(restored.task.id, "chapter-1")).toBe(false)
+    release("新候选")
+    expect(await regenerating).toBe(true)
+    expect(store.getState().records[0].chapters[0]).toMatchObject({ status: "ready", candidateContent: "新候选" })
+  })
+
+  it("最终确认写回前再次拒绝被篡改到项目外的 sourcePath", async () => {
+    const restored = toRecord(input("a"))
+    restored.task.status = "reviewing"
+    restored.chapters[0] = { ...restored.chapters[0], status: "ready", candidateContent: "候选", sourcePath: "C:/project/wiki/chapters/../../项目外.md" }
+    const { store, applyChapter } = setup({ loaded: [restored] })
+    await store.getState().initializeProject("C:/project")
+    await expect(store.getState().confirmChapter(restored.task.id, "chapter-1"))
+      .rejects.toThrow("章节源文件不属于当前项目章节目录")
+    expect(applyChapter).not.toHaveBeenCalled()
+  })
+  it("支持指定章重新生成、取消当前章和打开审核", async () => {
+    const restored = toRecord(input("a"))
+    restored.task.status = "reviewing"
+    restored.chapters[0] = { ...restored.chapters[0], status: "ready", candidateContent: "旧候选" }
+    const { store, runner } = setup({ loaded: [restored] })
+    await store.getState().initializeProject("C:/project")
+
+    store.getState().openReview(restored.task.id, "chapter-1")
+    expect(store.getState()).toMatchObject({ reviewOpen: true, reviewTaskId: restored.task.id, reviewChapterId: "chapter-1" })
+
+    await store.getState().regenerateChapter(restored.task.id, "chapter-1")
+    expect(store.getState().records[0].chapters[0].candidateContent).toContain("候选")
+    expect(runner).toHaveBeenCalledTimes(1)
+
+    await store.getState().cancelChapter(restored.task.id, "chapter-2")
+    expect(store.getState().records[0].chapters[1].status).toBe("cancelled")
+  })
+
+  it("确认只覆盖当前章并持久化确认状态", async () => {
+    const restored = toRecord(input("a"))
+    restored.task.status = "reviewing"
+    restored.chapters = restored.chapters.map((chapter, index) => ({
+      ...chapter,
+      status: "ready",
+      candidateContent: `候选-${index + 1}`,
+    }))
+    const { store, applyChapter, storage } = setup({ loaded: [restored] })
+    await store.getState().initializeProject("C:/project")
+
+    expect(await store.getState().confirmChapter(restored.task.id, "chapter-1")).toBe(true)
+
+    expect(applyChapter).toHaveBeenCalledTimes(1)
+    expect(applyChapter).toHaveBeenCalledWith("C:/project/wiki/chapters/a/chapter-1.md", "候选-1")
+    expect(store.getState().records[0].chapters.map((chapter) => chapter.status)).toEqual(["confirmed", "ready"])
+    expect(storage.saveChapter).toHaveBeenCalledWith(expect.objectContaining({ id: "chapter-1", status: "confirmed" }), "C:/project")
+  })
+
+  it("取消批次只停止该作品,其他作品继续", async () => {
+    const { store } = setup()
+    await store.getState().initializeProject("C:/project")
+    await store.getState().createBatch([input("a"), input("b")])
+    await store.getState().cancelTask("task-a")
+
+    expect(store.getState().records.find((item) => item.task.id === "task-a")?.task.status).toBe("cancelled")
+    expect(store.getState().records.find((item) => item.task.id === "task-b")?.task.status).not.toBe("cancelled")
+  })
+
+  it("恢复初始化前校验任务绑定模型,缺失时中文失败且不自动换模型", async () => {
+    const restored = toRecord(input("a"))
+    restored.task.status = "interrupted"
+    const error = "任务绑定模型“custom-a/removed”已不可用,请重新配置该模型后再继续"
+    const { store, storage, runner } = setup({
+      loaded: [restored],
+      validateTaskModel: () => error,
+    })
+
+    await store.getState().initializeProject("C:/project")
+
+    expect(store.getState().records[0].task).toMatchObject({ status: "failed", error })
+    expect(storage.saveTask).toHaveBeenCalledWith(expect.objectContaining({ id: restored.task.id, status: "failed", error }))
+    expect(runner).not.toHaveBeenCalled()
+  })
+
+  it("setConcurrency 立即更新当前 scheduler 而不重建项目", async () => {
+    const { store } = setup()
+    await store.getState().initializeProject("C:/project")
+
+    store.getState().setConcurrency(5)
+
+    expect(store.getState().concurrency).toBe(5)
+    expect(store.getState().scheduler.concurrency).toBe(5)
+  })
+
+  it("确认等待期间切换项目后旧操作失效且不会插入旧 record", async () => {
+    let release!: () => void
+    const gate = new Promise<void>((resolve) => { release = resolve })
+    const restored = toRecord(input("a"))
+    restored.task.status = "reviewing"
+    restored.chapters[0] = { ...restored.chapters[0], status: "ready", candidateContent: "候选" }
+    const { store, applyChapter, storage } = setup({ loaded: [restored] })
+    vi.mocked(applyChapter).mockImplementationOnce(async () => gate)
+    await store.getState().initializeProject("C:/project")
+
+    const confirming = store.getState().confirmChapter(restored.task.id, "chapter-1")
+    await store.getState().initializeProject("C:/other")
+    release()
+
+    expect(await confirming).toBe(false)
+    expect(store.getState()).toMatchObject({ projectPath: "C:/other", records: [] })
+    expect(storage.saveChapter).not.toHaveBeenCalledWith(expect.objectContaining({ status: "confirmed" }), expect.anything())
+  })
+
+  it("重新生成等待期间 dispose 后旧结果不会重新插入 record", async () => {
+    let release!: (value: string) => void
+    const gate = new Promise<string>((resolve) => { release = resolve })
+    const restored = toRecord(input("a"))
+    restored.task.status = "reviewing"
+    restored.chapters[0] = { ...restored.chapters[0], status: "ready", candidateContent: "旧候选" }
+    const { store } = setup({ loaded: [restored], runner: vi.fn(async () => gate) })
+    await store.getState().initializeProject("C:/project")
+
+    const regenerating = store.getState().regenerateChapter(restored.task.id, "chapter-1")
+    store.getState().dispose()
+    await store.getState().initializeProject("C:/other")
+    release("迟到候选")
+
+    expect(await regenerating).toBe(false)
+    expect(store.getState()).toMatchObject({ projectPath: "C:/other", records: [] })
+  })
+
+  it("章节取消等待期间切换项目后不会把旧 record 插入新项目", async () => {
+    let release!: () => void
+    const gate = new Promise<void>((resolve) => { release = resolve })
+    const restored = toRecord(input("a"))
+    restored.task.status = "reviewing"
+    const { store, storage } = setup({ loaded: [restored] })
+    vi.mocked(storage.saveChapter).mockImplementationOnce(async () => gate)
+    await store.getState().initializeProject("C:/project")
+
+    const cancelling = store.getState().cancelChapter(restored.task.id, "chapter-1")
+    await waitFor(() => expect(storage.saveChapter).toHaveBeenCalled())
+    await store.getState().initializeProject("C:/other")
+    release()
+
+    expect(await cancelling).toBe(false)
+    expect(store.getState()).toMatchObject({ projectPath: "C:/other", records: [] })
+  })
+  it("章节确认进行中拒绝整批取消,保持磁盘与状态一致", async () => {
+    let release!: () => void
+    const gate = new Promise<void>((resolve) => { release = resolve })
+    const restored = toRecord(input("a"))
+    restored.task.status = "reviewing"
+    restored.chapters[0] = { ...restored.chapters[0], status: "ready", candidateContent: "候选" }
+    const { store, applyChapter } = setup({ loaded: [restored] })
+    vi.mocked(applyChapter).mockImplementationOnce(async () => gate)
+    await store.getState().initializeProject("C:/project")
+
+    const confirming = store.getState().confirmChapter(restored.task.id, "chapter-1")
+    expect(await store.getState().cancelTask(restored.task.id)).toBe(false)
+    release()
+    expect(await confirming).toBe(true)
+    expect(store.getState().records[0]).toMatchObject({
+      task: { status: "reviewing" },
+      chapters: [expect.objectContaining({ id: "chapter-1", status: "confirmed" }), expect.anything()],
+    })
+  })
+
+  it("整批取消持有任务锁期间拒绝章节确认", async () => {
+    let release!: () => void
+    const gate = new Promise<void>((resolve) => { release = resolve })
+    const restored = toRecord(input("a"))
+    restored.task.status = "reviewing"
+    restored.chapters[0] = { ...restored.chapters[0], status: "ready", candidateContent: "候选" }
+    const { store, storage, applyChapter } = setup({ loaded: [restored] })
+    vi.mocked(storage.saveChapter).mockImplementationOnce(async () => gate)
+    await store.getState().initializeProject("C:/project")
+
+    const cancelling = store.getState().cancelTask(restored.task.id)
+    await waitFor(() => expect(storage.saveChapter).toHaveBeenCalled())
+    expect(await store.getState().confirmChapter(restored.task.id, "chapter-1")).toBe(false)
+    expect(applyChapter).not.toHaveBeenCalled()
+    release()
+    expect(await cancelling).toBe(true)
+    expect(store.getState().records[0].task.status).toBe("cancelled")
+  })})

+ 455 - 0
src/stores/de-ai-batch-store.ts

@@ -0,0 +1,455 @@
+import { create } from "zustand"
+import { createDeAiBatchChapterApplier } from "@/lib/novel/de-ai-batch/chapter-apply"
+import { assertChapterSourcePath } from "@/lib/novel/de-ai-batch/chapter-path"
+import { createDefaultDeAiBatchLlmRunner, getDeAiBatchTaskModelError } from "@/lib/novel/de-ai-batch/llm-runner"
+import {
+  createDeAiBatchEngine,
+  type DeAiBatchEngine,
+  type DeAiBatchEngineStorage,
+  type DeAiChapterRunner,
+} from "@/lib/novel/de-ai-batch/engine"
+import {
+  createDeAiBatchScheduler,
+  type DeAiBatchScheduler,
+  type DeAiBatchSchedulerSnapshot,
+} from "@/lib/novel/de-ai-batch/scheduler"
+import {
+  createDeAiBatchStorage,
+  type DeAiBatchStorage,
+} from "@/lib/novel/de-ai-batch/storage"
+import type {
+  CreateDeAiBatchTaskInput,
+  DeAiBatchChapter,
+  DeAiBatchTask,
+  DeAiBatchTaskRecord,
+} from "@/lib/novel/de-ai-batch/types"
+
+const EMPTY_SCHEDULER: DeAiBatchSchedulerSnapshot = {
+  concurrency: 3,
+  activeTaskIds: [],
+  queuedTaskIds: [],
+  disposed: false,
+}
+
+interface StoreStorage extends DeAiBatchEngineStorage {
+  loadProject(projectPath: string): Promise<DeAiBatchTaskRecord[]>
+  createTask(input: CreateDeAiBatchTaskInput): Promise<DeAiBatchTaskRecord>
+}
+
+interface ProjectContext {
+  generation: number
+  projectPath: string
+}
+
+export interface CreateDeAiBatchStoreOptions {
+  storage?: StoreStorage | DeAiBatchStorage
+  runner: DeAiChapterRunner
+  validateTaskModel?: (task: DeAiBatchTask) => string | null
+  applyChapter?: (path: string, content: string) => Promise<void>
+  concurrency?: number
+  now?: () => number
+}
+
+export interface DeAiBatchState {
+  projectPath: string | null
+  records: DeAiBatchTaskRecord[]
+  concurrency: number
+  scheduler: DeAiBatchSchedulerSnapshot
+  panelCollapsed: boolean
+  reviewOpen: boolean
+  reviewTaskId: string | null
+  reviewChapterId: string | null
+  initializeProject(projectPath: string): Promise<void>
+  createBatch(inputs: CreateDeAiBatchTaskInput[]): Promise<void>
+  continueTask(taskId: string): Promise<boolean>
+  regenerateChapter(taskId: string, chapterId: string): Promise<boolean>
+  cancelChapter(taskId: string, chapterId: string): Promise<boolean>
+  confirmChapter(taskId: string, chapterId: string): Promise<boolean>
+  cancelTask(taskId: string): Promise<boolean>
+  openReview(taskId: string, chapterId?: string): void
+  closeReview(): void
+  setReviewChapter(chapterId: string): void
+  setPanelCollapsed(collapsed: boolean): void
+  setConcurrency(value: number): void
+  dispose(): void
+}
+
+function taskAfterChapterChange(
+  task: DeAiBatchTask,
+  chapters: DeAiBatchChapter[],
+  now: number,
+): DeAiBatchTask {
+  const allConfirmed = chapters.length > 0 && chapters.every((chapter) => chapter.status === "confirmed")
+  const hasFailedOrCancelled = chapters.some((chapter) => chapter.status === "failed" || chapter.status === "cancelled")
+  return {
+    ...task,
+    status: allConfirmed ? "completed" : hasFailedOrCancelled ? "partial" : "reviewing",
+    completedAt: allConfirmed ? now : null,
+    error: null,
+    updatedAt: now,
+  }
+}
+
+export function createDeAiBatchStore(options: CreateDeAiBatchStoreOptions) {
+  const storage = options.storage ?? createDeAiBatchStorage()
+  const applyChapter = options.applyChapter ?? createDeAiBatchChapterApplier()
+  const now = options.now ?? Date.now
+  const validateTaskModel = options.validateTaskModel ?? getDeAiBatchTaskModelError
+  let scheduler: DeAiBatchScheduler | null = null
+  const engines = new Map<string, DeAiBatchEngine>()
+  let generation = 0
+  const chapterActions = new Map<string, symbol>()
+  const taskActions = new Map<string, symbol>()
+
+  return create<DeAiBatchState>((set, get) => {
+    function captureProjectContext(projectPath: string): ProjectContext | null {
+      return get().projectPath === projectPath ? { generation, projectPath } : null
+    }
+
+    function isProjectContextCurrent(context: ProjectContext): boolean {
+      return generation === context.generation && get().projectPath === context.projectPath
+    }
+
+    function upsertRecord(record: DeAiBatchTaskRecord, context: ProjectContext): void {
+      if (!isProjectContextCurrent(context)) return
+      set((state) => {
+        if (generation !== context.generation || state.projectPath !== context.projectPath) return state
+        const index = state.records.findIndex((item) => item.task.id === record.task.id)
+        const records = [...state.records]
+        if (index >= 0) records[index] = { task: { ...record.task }, chapters: [...record.chapters] }
+        else records.push({ task: { ...record.task }, chapters: [...record.chapters] })
+        return { records }
+      })
+    }
+
+    function chapterActionKey(taskId: string, chapterId: string): string {
+      return `${taskId}:${chapterId}`
+    }
+
+    function beginChapterAction(taskId: string, chapterId: string): symbol | null {
+      const key = chapterActionKey(taskId, chapterId)
+      if (taskActions.has(taskId) || chapterActions.has(key)) return null
+      const token = Symbol(key)
+      chapterActions.set(key, token)
+      return token
+    }
+
+    function endChapterAction(taskId: string, chapterId: string, token: symbol): void {
+      const key = chapterActionKey(taskId, chapterId)
+      if (chapterActions.get(key) === token) chapterActions.delete(key)
+    }
+
+    function beginTaskAction(taskId: string): symbol | null {
+      if (taskActions.has(taskId)) return null
+      if (Array.from(chapterActions.keys()).some((key) => key.startsWith(`${taskId}:`))) return null
+      const token = Symbol(taskId)
+      taskActions.set(taskId, token)
+      return token
+    }
+
+    function endTaskAction(taskId: string, token: symbol): void {
+      if (taskActions.get(taskId) === token) taskActions.delete(taskId)
+    }
+
+    function findRecord(taskId: string): DeAiBatchTaskRecord | null {
+      return get().records.find((record) => record.task.id === taskId) ?? null
+    }
+
+    function getEngine(record: DeAiBatchTaskRecord, context: ProjectContext): DeAiBatchEngine {
+      const existing = engines.get(record.task.id)
+      if (existing) return existing
+      const guardedStorage: DeAiBatchEngineStorage = {
+        saveTask: async (task) => {
+          if (!isProjectContextCurrent(context)) return
+          await storage.saveTask(task)
+        },
+        saveChapter: async (chapter, projectPath) => {
+          if (!isProjectContextCurrent(context)) return
+          await storage.saveChapter(chapter, projectPath ?? context.projectPath)
+        },
+      }
+      const engine = createDeAiBatchEngine({
+        runner: options.runner,
+        storage: guardedStorage,
+        now,
+        onChange: (changed) => upsertRecord(changed, context),
+      })
+      engines.set(record.task.id, engine)
+      return engine
+    }
+
+    function makeScheduler(concurrency: number, context: ProjectContext): DeAiBatchScheduler {
+      return createDeAiBatchScheduler({
+        concurrency,
+        createAgent: (record) => {
+          const engine = getEngine(record, context)
+          return {
+            run: () => engine.runTask(record),
+            dispose: () => {
+              engine.dispose()
+              if (engines.get(record.task.id) === engine) engines.delete(record.task.id)
+            },
+          }
+        },
+        onChange: (snapshot) => {
+          if (isProjectContextCurrent(context)) set({ scheduler: snapshot })
+        },
+        onTaskComplete: (record) => upsertRecord(record, context),
+        onTaskError: (record, error) => {
+          if (!isProjectContextCurrent(context)) return
+          const failed: DeAiBatchTaskRecord = {
+            ...record,
+            task: { ...record.task, status: "failed", error: error.message, updatedAt: now() },
+          }
+          upsertRecord(failed, context)
+          if (isProjectContextCurrent(context)) void storage.saveTask(failed.task)
+        },
+      })
+    }
+
+    async function initializeProject(projectPath: string): Promise<void> {
+      const context: ProjectContext = { generation: ++generation, projectPath }
+      scheduler?.dispose()
+      scheduler = null
+      for (const engine of engines.values()) engine.dispose()
+      engines.clear()
+      chapterActions.clear()
+      taskActions.clear()
+      set({
+        projectPath,
+        records: [],
+        scheduler: { ...EMPTY_SCHEDULER, concurrency: get().concurrency },
+        reviewOpen: false,
+        reviewTaskId: null,
+        reviewChapterId: null,
+      })
+      const loaded = await storage.loadProject(projectPath)
+      if (!isProjectContextCurrent(context)) return
+      for (const record of loaded) {
+        const needsModel = record.chapters.some((chapter) =>
+          chapter.status === "pending" || chapter.status === "generating" || chapter.status === "failed",
+        )
+        if (!needsModel || record.task.status === "completed" || record.task.status === "cancelled") continue
+        const modelError = validateTaskModel(record.task)
+        if (!modelError) continue
+        if (!isProjectContextCurrent(context)) return
+        record.task = {
+          ...record.task,
+          status: "failed",
+          error: modelError,
+          updatedAt: now(),
+        }
+        await storage.saveTask(record.task)
+        if (!isProjectContextCurrent(context)) return
+      }
+      if (!isProjectContextCurrent(context)) return
+      scheduler = makeScheduler(get().concurrency, context)
+      set({ records: loaded, scheduler: scheduler.getSnapshot() })
+    }
+
+    async function createBatch(inputs: CreateDeAiBatchTaskInput[]): Promise<void> {
+      if (inputs.length === 0) return
+      const projectPath = get().projectPath
+      if (!projectPath) throw new Error("??????")
+      if (inputs.some((input) => input.projectPath !== projectPath)) throw new Error("???????????")
+      const context = captureProjectContext(projectPath)
+      if (!context) return
+      if (!scheduler) scheduler = makeScheduler(get().concurrency, context)
+      const records: DeAiBatchTaskRecord[] = []
+      for (const input of inputs) {
+        const record = await storage.createTask(input)
+        if (!isProjectContextCurrent(context)) return
+        records.push(record)
+      }
+      if (!isProjectContextCurrent(context)) return
+      set((state) => state.projectPath === projectPath ? { records: [...state.records, ...records] } : state)
+      scheduler?.enqueue(records)
+    }
+
+    async function continueTask(taskId: string): Promise<boolean> {
+      const token = beginTaskAction(taskId)
+      if (!token) return false
+      try {
+        const record = findRecord(taskId)
+        if (!record || !(["interrupted", "failed", "partial"] as DeAiBatchTask["status"][]).includes(record.task.status)) return false
+        const context = captureProjectContext(record.task.projectPath)
+        if (!context) return false
+        const modelError = validateTaskModel(record.task)
+        if (modelError) {
+          record.task = { ...record.task, status: "failed", error: modelError, updatedAt: now() }
+          if (!isProjectContextCurrent(context)) return false
+          await storage.saveTask(record.task)
+          if (!isProjectContextCurrent(context)) return false
+          upsertRecord(record, context)
+          return false
+        }
+        if (!isProjectContextCurrent(context)) return false
+        if (!scheduler) scheduler = makeScheduler(get().concurrency, context)
+        scheduler.enqueue(record)
+        return true
+      } finally {
+        endTaskAction(taskId, token)
+      }
+    }
+
+    async function regenerateChapter(taskId: string, chapterId: string): Promise<boolean> {
+      const token = beginChapterAction(taskId, chapterId)
+      if (!token) return false
+      try {
+        const record = findRecord(taskId)
+        if (!record) return false
+        const context = captureProjectContext(record.task.projectPath)
+        if (!context) return false
+        const chapter = record.chapters.find((item) => item.id === chapterId)
+        if (!chapter || chapter.status === "confirmed" || chapter.status === "generating") return false
+        await getEngine(record, context).regenerateChapter(record, chapterId)
+        if (!isProjectContextCurrent(context)) return false
+        const latest = record.chapters.find((item) => item.id === chapterId)
+        if (!latest || latest.status === "confirmed") return false
+        record.task = taskAfterChapterChange(record.task, record.chapters, now())
+        await storage.saveTask(record.task)
+        if (!isProjectContextCurrent(context)) return false
+        upsertRecord(record, context)
+        return true
+      } finally {
+        endChapterAction(taskId, chapterId, token)
+      }
+    }
+
+    async function cancelChapter(taskId: string, chapterId: string): Promise<boolean> {
+      const token = beginChapterAction(taskId, chapterId)
+      if (!token) return false
+      try {
+        const record = findRecord(taskId)
+        if (!record) return false
+        const context = captureProjectContext(record.task.projectPath)
+        if (!context) return false
+        const current = record.chapters.find((item) => item.id === chapterId)
+        if (!current || current.status === "confirmed" || current.status === "cancelled") return false
+        const expectedGeneration = current.generation
+        await getEngine(record, context).cancelChapter(taskId, chapterId)
+        if (!isProjectContextCurrent(context)) return false
+        const afterEngine = record.chapters.find((item) => item.id === chapterId)
+        if (!afterEngine || afterEngine.status === "confirmed" || afterEngine.generation !== expectedGeneration) return false
+        if (afterEngine.status !== "cancelled") {
+          const cancelled: DeAiBatchChapter = { ...afterEngine, status: "cancelled", runId: null, error: null, updatedAt: now() }
+          record.chapters[record.chapters.findIndex((item) => item.id === chapterId)] = cancelled
+          await storage.saveChapter(cancelled, record.task.projectPath)
+          if (!isProjectContextCurrent(context)) return false
+        }
+        record.task = taskAfterChapterChange(record.task, record.chapters, now())
+        await storage.saveTask(record.task)
+        if (!isProjectContextCurrent(context)) return false
+        upsertRecord(record, context)
+        return true
+      } finally {
+        endChapterAction(taskId, chapterId, token)
+      }
+    }
+
+    async function confirmChapter(taskId: string, chapterId: string): Promise<boolean> {
+      const token = beginChapterAction(taskId, chapterId)
+      if (!token) return false
+      try {
+        const record = findRecord(taskId)
+        if (!record) return false
+        const context = captureProjectContext(record.task.projectPath)
+        if (!context) return false
+        const index = record.chapters.findIndex((item) => item.id === chapterId)
+        const chapter = record.chapters[index]
+        if (!chapter || chapter.status !== "ready" || !chapter.candidateContent) return false
+        const expectedGeneration = chapter.generation
+        const expectedCandidate = chapter.candidateContent
+        const safeSourcePath = assertChapterSourcePath(record.task.projectPath, chapter.sourcePath)
+        await applyChapter(safeSourcePath, expectedCandidate)
+        if (!isProjectContextCurrent(context)) return false
+        const latest = record.chapters.find((item) => item.id === chapterId)
+        if (!latest || latest.status !== "ready" || latest.generation !== expectedGeneration || latest.candidateContent !== expectedCandidate) return false
+        const confirmed: DeAiBatchChapter = { ...latest, status: "confirmed", error: null, updatedAt: now() }
+        record.chapters[index] = confirmed
+        record.task = taskAfterChapterChange(record.task, record.chapters, now())
+        await storage.saveChapter(confirmed, record.task.projectPath)
+        if (!isProjectContextCurrent(context)) return false
+        await storage.saveTask(record.task)
+        if (!isProjectContextCurrent(context)) return false
+        upsertRecord(record, context)
+        return true
+      } finally {
+        endChapterAction(taskId, chapterId, token)
+      }
+    }
+
+    async function cancelTask(taskId: string): Promise<boolean> {
+      const token = beginTaskAction(taskId)
+      if (!token) return false
+      try {
+        const record = findRecord(taskId)
+        if (!record || record.task.status === "completed") return false
+        const context = captureProjectContext(record.task.projectPath)
+        if (!context) return false
+        scheduler?.cancel(taskId)
+        for (const chapter of record.chapters) {
+          if (chapter.status === "confirmed" || chapter.status === "cancelled") continue
+          const index = record.chapters.findIndex((item) => item.id === chapter.id)
+          const cancelled: DeAiBatchChapter = { ...record.chapters[index], status: "cancelled", runId: null, error: null, updatedAt: now() }
+          record.chapters[index] = cancelled
+          await storage.saveChapter(cancelled, record.task.projectPath)
+          if (!isProjectContextCurrent(context)) return false
+        }
+        record.task = { ...record.task, status: "cancelled", error: null, completedAt: now(), updatedAt: now() }
+        await storage.saveTask(record.task)
+        if (!isProjectContextCurrent(context)) return false
+        upsertRecord(record, context)
+        return true
+      } finally {
+        endTaskAction(taskId, token)
+      }
+    }
+
+    return {
+      projectPath: null,
+      records: [],
+      concurrency: options.concurrency ?? 3,
+      scheduler: { ...EMPTY_SCHEDULER, concurrency: options.concurrency ?? 3 },
+      panelCollapsed: false,
+      reviewOpen: false,
+      reviewTaskId: null,
+      reviewChapterId: null,
+      initializeProject,
+      createBatch,
+      continueTask,
+      regenerateChapter,
+      cancelChapter,
+      confirmChapter,
+      cancelTask,
+      openReview: (taskId, chapterId) => {
+        if (taskActions.has(taskId)) return
+        const record = findRecord(taskId)
+        if (!record) return
+        const targetChapterId = chapterId ?? record.chapters[0]?.id ?? null
+        set({ reviewOpen: true, reviewTaskId: taskId, reviewChapterId: targetChapterId })
+      },
+      closeReview: () => set({ reviewOpen: false }),
+      setReviewChapter: (reviewChapterId) => set({ reviewChapterId }),
+      setPanelCollapsed: (panelCollapsed) => set({ panelCollapsed }),
+      setConcurrency: (value) => {
+        scheduler?.setConcurrency(value)
+        const concurrency = scheduler?.getSnapshot().concurrency ?? Math.min(5, Math.max(1, Math.floor(value)))
+        set({ concurrency, scheduler: scheduler?.getSnapshot() ?? { ...get().scheduler, concurrency } })
+      },
+      dispose: () => {
+        generation += 1
+        chapterActions.clear()
+        taskActions.clear()
+        scheduler?.dispose()
+        scheduler = null
+        for (const engine of engines.values()) engine.dispose()
+        engines.clear()
+      },
+    }
+  })
+}
+
+export const useDeAiBatchStore = createDeAiBatchStore({
+  runner: createDefaultDeAiBatchLlmRunner(),
+})

+ 3 - 0
src/stores/wiki-store.ts

@@ -319,6 +319,8 @@ export interface NovelConfig {
   extractModel: string
   /** 去 AI 味:章节预览去 AI 味、深度生成阶段6。空字符串表示跟随默认模型。 */
   deAiModel: string
+  /** 批量去 AI 味同时运行的作品 Agent 数,范围 1–5。 */
+  deAiBatchConcurrency: number
   /** 社区摘要自动提取:开启后每 N 章用 LLM 为图谱社区生成叙事摘要,用于回答全局性问题(默认开)。 */
   communitySummaryEnabled: boolean
   /** 社区摘要提取间隔:每摄取多少章后自动重建一次社区摘要(默认 5)。 */
@@ -346,6 +348,7 @@ export const DEFAULT_NOVEL_CONFIG: NovelConfig = {
   summaryModel: "",
   extractModel: "",
   deAiModel: "",
+  deAiBatchConcurrency: 3,
   communitySummaryEnabled: true,
   communitySummaryInterval: 5,
   communitySummaryAsync: true,