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

Merge pull request #29 from darknessomi/update/chat-history-input-height

feat: 完善 AI 会话、拆书任务与严格写作工作流
Mochocyang 3 месяцев назад
Родитель
Сommit
28b2f4d8fc
32 измененных файлов с 1016 добавлено и 65 удалено
  1. 35 0
      src/components/chat/chat-panel.mount.spec.tsx
  2. 37 1
      src/components/chat/chat-panel.tsx
  3. 56 0
      src/components/common/conversation-history-clear-dialog.spec.tsx
  4. 45 0
      src/components/common/conversation-history-clear-dialog.tsx
  5. 41 0
      src/components/novel/book-analysis-import-task-panel.spec.tsx
  6. 79 4
      src/components/novel/book-analysis-import-task-panel.tsx
  7. 14 0
      src/components/novel/book-analysis-view.spec.ts
  8. 12 0
      src/components/novel/book-analysis-view.tsx
  9. 1 0
      src/components/novel/hooks/use-character-extraction.ts
  10. 25 0
      src/components/reference/ReferenceInput.spec.tsx
  11. 5 3
      src/components/reference/ReferenceInput.tsx
  12. 7 0
      src/components/sources/outline-chat-panel.spec.tsx
  13. 40 0
      src/components/sources/outline-chat-panel.tsx
  14. 65 2
      src/hooks/use-agent-config.spec.ts
  15. 11 5
      src/hooks/use-agent-config.ts
  16. 6 0
      src/i18n/en.json
  17. 6 0
      src/i18n/zh.json
  18. 4 2
      src/lib/agent/tools/index.ts
  19. 6 0
      src/lib/agent/workflow-trace.spec.ts
  20. 12 0
      src/lib/agent/workflow-trace.ts
  21. 13 0
      src/lib/novel/book-analysis/batch-import-scheduler.spec.ts
  22. 20 0
      src/lib/novel/book-analysis/batch-import-scheduler.ts
  23. 31 0
      src/lib/novel/book-analysis/batch-import-storage.spec.ts
  24. 53 0
      src/lib/novel/book-analysis/batch-import-storage.ts
  25. 57 2
      src/lib/novel/book-analysis/character-extraction-engine.spec.ts
  26. 56 38
      src/lib/novel/book-analysis/character-extraction-engine.ts
  27. 19 0
      src/lib/novel/book-analysis/library-store.spec.ts
  28. 46 0
      src/lib/novel/book-analysis/library-store.ts
  29. 82 0
      src/lib/novel/deep-chapter-generation.spec.ts
  30. 9 8
      src/lib/novel/deep-chapter-generation.ts
  31. 65 0
      src/stores/book-analysis-import-store.spec.ts
  32. 58 0
      src/stores/book-analysis-import-store.ts

+ 35 - 0
src/components/chat/chat-panel.mount.spec.tsx

@@ -6,6 +6,7 @@ import { clearTaskBreakpoint, loadTaskBreakpoint } from "@/lib/agent/task-breakp
 import type { Conversation } from "@/stores/chat-store"
 import type { Conversation } from "@/stores/chat-store"
 import { useChatStore } from "@/stores/chat-store"
 import { useChatStore } from "@/stores/chat-store"
 import { chatConversationRunRegistry } from "@/lib/conversation-run-registry"
 import { chatConversationRunRegistry } from "@/lib/conversation-run-registry"
+import { deleteFile } from "@/commands/fs"
 
 
 const breakpoint = {
 const breakpoint = {
   taskId: "task_resume_1",
   taskId: "task_resume_1",
@@ -232,6 +233,40 @@ describe("ChatPanel mount 基础设施", () => {
     await view.unmount()
     await view.unmount()
   })
   })
 
 
+  it("一键清理只删除历史会话并保留顶部当前会话", async () => {
+    const deleteFileMock = vi.mocked(deleteFile)
+    deleteFileMock.mockClear()
+    const view = await renderChatPanel({
+      activeConversationId: "conv-current",
+      conversations: [
+        conversation("conv-current", "当前会话", Date.now()),
+        conversation("conv-history-a", "历史会话 A", 100),
+        conversation("conv-history-b", "历史会话 B", 200),
+      ],
+      messages: [
+        { id: "current", role: "user", content: "保留", timestamp: 300, conversationId: "conv-current" },
+        { id: "history-a", role: "user", content: "删除 A", timestamp: 100, conversationId: "conv-history-a" },
+        { id: "history-b", role: "assistant", content: "删除 B", timestamp: 200, conversationId: "conv-history-b" },
+      ],
+    })
+
+    await click(view.container.querySelector('[aria-label="novel.chat.conversationHistory"]'))
+    await act(async () => {
+      await new Promise((resolve) => setTimeout(resolve, 20))
+    })
+    await click(document.body.querySelector('[aria-label="chat.clearHistory"]'))
+    expect(document.body.textContent).toContain("chat.clearHistoryTitle")
+    await click(Array.from(document.body.querySelectorAll("button"))
+      .find((button) => button.textContent === "chat.clearHistoryConfirm") ?? null)
+
+    const state = useChatStore.getState()
+    expect(state.conversations.map((item) => item.id)).toEqual(["conv-current"])
+    expect(state.messages.map((item) => item.id)).toEqual(["current"])
+    expect(deleteFileMock).toHaveBeenCalledWith("C:/QMAI_C/QMAI-main/.qmai/chats/conv-history-a.json")
+    expect(deleteFileMock).toHaveBeenCalledWith("C:/QMAI_C/QMAI-main/.qmai/chats/conv-history-b.json")
+    await view.unmount()
+  })
+
   it.todo("PrePlugin 链触发:发送消息后 pipeline 执行")
   it.todo("PrePlugin 链触发:发送消息后 pipeline 执行")
   it.todo("Stage C 对话框:standard 模式 chapter_plan 触发对话框")
   it.todo("Stage C 对话框:standard 模式 chapter_plan 触发对话框")
   it.todo("Stage C 跳过:fast 模式直接生成正文")
   it.todo("Stage C 跳过:fast 模式直接生成正文")

+ 37 - 1
src/components/chat/chat-panel.tsx

@@ -25,6 +25,7 @@ import { ReferenceInput, type InsertReferenceTokens } from "@/components/referen
 import { ReferencePickerDialog } from "@/components/reference/ReferencePickerDialog"
 import { ReferencePickerDialog } from "@/components/reference/ReferencePickerDialog"
 import { ConversationRunStatusIcon } from "@/components/common/conversation-run-status-icon"
 import { ConversationRunStatusIcon } from "@/components/common/conversation-run-status-icon"
 import { ConversationDeleteConfirmDialog } from "@/components/common/conversation-delete-confirm-dialog"
 import { ConversationDeleteConfirmDialog } from "@/components/common/conversation-delete-confirm-dialog"
+import { ConversationHistoryClearDialog } from "@/components/common/conversation-history-clear-dialog"
 import { chatConversationRunRegistry } from "@/lib/conversation-run-registry"
 import { chatConversationRunRegistry } from "@/lib/conversation-run-registry"
 import { toast } from "@/lib/toast"
 import { toast } from "@/lib/toast"
 import {
 import {
@@ -470,6 +471,7 @@ function ConversationTabs({ onBeforeDelete }: { onBeforeDelete: (conversationId:
   const historyDropdownRef = useRef<HTMLDivElement | null>(null)
   const historyDropdownRef = useRef<HTMLDivElement | null>(null)
   const [historyDropdownStyle, setHistoryDropdownStyle] = useState<CSSProperties | null>(null)
   const [historyDropdownStyle, setHistoryDropdownStyle] = useState<CSSProperties | null>(null)
   const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
   const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null)
+  const [pendingClearHistoryIds, setPendingClearHistoryIds] = useState<string[] | null>(null)
 
 
   const isStreamingConversation = (convId: string) => runStates[convId]?.status === "running"
   const isStreamingConversation = (convId: string) => runStates[convId]?.status === "running"
   const { topConversations, historyConversations } = splitConversationToolbarItems(
   const { topConversations, historyConversations } = splitConversationToolbarItems(
@@ -592,6 +594,16 @@ function ConversationTabs({ onBeforeDelete }: { onBeforeDelete: (conversationId:
     deleteConversationNow(convId)
     deleteConversationNow(convId)
   }
   }
 
 
+  function requestClearHistory() {
+    setPendingClearHistoryIds(historyConversations.map((conversation) => conversation.id))
+    setHistoryOpen(false)
+  }
+
+  function clearHistoryNow() {
+    pendingClearHistoryIds?.forEach((conversationId) => deleteConversationNow(conversationId))
+    setPendingClearHistoryIds(null)
+  }
+
   function renderConversationChip(conv: { id: string; title: string; updatedAt: number }) {
   function renderConversationChip(conv: { id: string; title: string; updatedAt: number }) {
     const isActive = conv.id === activeConversationId
     const isActive = conv.id === activeConversationId
     const runState = runStates[conv.id]
     const runState = runStates[conv.id]
@@ -704,6 +716,24 @@ function ConversationTabs({ onBeforeDelete }: { onBeforeDelete: (conversationId:
                 className="fixed z-50 max-h-[60vh] w-72 overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg"
                 className="fixed z-50 max-h-[60vh] w-72 overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg"
                 style={historyDropdownStyle}
                 style={historyDropdownStyle}
               >
               >
+              {historyCount > 0 && (
+                <div className="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background px-2 py-1.5">
+                  <span className="text-xs text-muted-foreground">
+                    {t("chat.historyConversationCount", { count: historyCount })}
+                  </span>
+                  <Button
+                    type="button"
+                    variant="ghost"
+                    size="sm"
+                    className="h-7 px-2 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive"
+                    aria-label={t("chat.clearHistory")}
+                    onClick={requestClearHistory}
+                  >
+                    <Trash2 className="h-3.5 w-3.5" />
+                    {t("chat.clearHistory")}
+                  </Button>
+                </div>
+              )}
               {historyCount === 0 ? (
               {historyCount === 0 ? (
                 <div className="px-2 py-3 text-center text-xs text-muted-foreground">
                 <div className="px-2 py-3 text-center text-xs text-muted-foreground">
                   {t(novelMode ? "novel.chat.noHistoryConversations" : "chat.noHistoryConversations")}
                   {t(novelMode ? "novel.chat.noHistoryConversations" : "chat.noHistoryConversations")}
@@ -724,6 +754,12 @@ function ConversationTabs({ onBeforeDelete }: { onBeforeDelete: (conversationId:
         setPendingDeleteId(null)
         setPendingDeleteId(null)
       }}
       }}
     />
     />
+    <ConversationHistoryClearDialog
+      open={pendingClearHistoryIds !== null}
+      count={pendingClearHistoryIds?.length ?? 0}
+      onCancel={() => setPendingClearHistoryIds(null)}
+      onConfirm={clearHistoryNow}
+    />
     </>
     </>
   )
   )
 }
 }
@@ -1242,7 +1278,7 @@ export function ChatPanel() {
         return
         return
       }
       }
       if (!agentSupportsTools) {
       if (!agentSupportsTools) {
-        setDeAiSkillWarningMessage("当前模型不支持Agent功能,请更换模型")
+        setDeAiSkillWarningMessage("Agent 调度模型不支持工具调用,请更换小说设置中的默认模型")
         return
         return
       }
       }
       if (!agentSkillConfigLoaded || !agentConfig) {
       if (!agentSkillConfigLoaded || !agentConfig) {

+ 56 - 0
src/components/common/conversation-history-clear-dialog.spec.tsx

@@ -0,0 +1,56 @@
+// @vitest-environment jsdom
+
+import "@/i18n"
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { ConversationHistoryClearDialog } from "./conversation-history-clear-dialog"
+
+let host: HTMLDivElement
+let root: Root
+
+beforeEach(() => {
+  host = document.createElement("div")
+  document.body.appendChild(host)
+  root = createRoot(host)
+})
+
+afterEach(() => {
+  act(() => root.unmount())
+  host.remove()
+})
+
+function findButton(text: string) {
+  return Array.from(document.body.querySelectorAll("button"))
+    .find((button) => button.textContent?.includes(text))
+}
+
+describe("ConversationHistoryClearDialog", () => {
+  it("shows the irreversible deletion count and handles cancel", async () => {
+    const onCancel = vi.fn()
+    const onConfirm = vi.fn()
+    await act(async () => root.render(
+      <ConversationHistoryClearDialog open count={3} onCancel={onCancel} onConfirm={onConfirm} />,
+    ))
+
+    expect(document.body.textContent).toContain("清理会话历史?")
+    expect(document.body.textContent).toContain("将永久删除 3 条历史会话及其消息,此操作无法撤销。")
+    await act(async () => findButton("取消")?.dispatchEvent(new MouseEvent("click", { bubbles: true })))
+
+    expect(onCancel).toHaveBeenCalledTimes(1)
+    expect(onConfirm).not.toHaveBeenCalled()
+  })
+
+  it("confirms clearing history", async () => {
+    const onCancel = vi.fn()
+    const onConfirm = vi.fn()
+    await act(async () => root.render(
+      <ConversationHistoryClearDialog open count={2} onCancel={onCancel} onConfirm={onConfirm} />,
+    ))
+
+    await act(async () => findButton("确认清理")?.dispatchEvent(new MouseEvent("click", { bubbles: true })))
+
+    expect(onConfirm).toHaveBeenCalledTimes(1)
+    expect(onCancel).not.toHaveBeenCalled()
+  })
+})

+ 45 - 0
src/components/common/conversation-history-clear-dialog.tsx

@@ -0,0 +1,45 @@
+import { useTranslation } from "react-i18next"
+import { Button } from "@/components/ui/button"
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog"
+
+interface ConversationHistoryClearDialogProps {
+  open: boolean
+  count: number
+  onCancel: () => void
+  onConfirm: () => void
+}
+
+export function ConversationHistoryClearDialog({
+  open,
+  count,
+  onCancel,
+  onConfirm,
+}: ConversationHistoryClearDialogProps) {
+  const { t } = useTranslation()
+
+  return (
+    <Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onCancel() }}>
+      <DialogContent showCloseButton={false}>
+        <DialogHeader>
+          <DialogTitle>{t("chat.clearHistoryTitle")}</DialogTitle>
+          <DialogDescription>{t("chat.clearHistoryDescription", { count })}</DialogDescription>
+        </DialogHeader>
+        <DialogFooter>
+          <Button type="button" variant="outline" onClick={onCancel}>
+            {t("chat.clearHistoryCancel")}
+          </Button>
+          <Button type="button" variant="destructive" onClick={onConfirm}>
+            {t("chat.clearHistoryConfirm")}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  )
+}

+ 41 - 0
src/components/novel/book-analysis-import-task-panel.spec.tsx

@@ -49,6 +49,8 @@ const callbacks = () => ({
   onRegenerate: vi.fn(),
   onRegenerate: vi.fn(),
   onCancel: vi.fn(),
   onCancel: vi.fn(),
   onCancelAllQueued: vi.fn(),
   onCancelAllQueued: vi.fn(),
+  onDeleteFailed: vi.fn(),
+  onRenameCompleted: vi.fn(),
   onOpenBook: vi.fn(),
   onOpenBook: vi.fn(),
 })
 })
 
 
@@ -315,6 +317,45 @@ describe("BookAnalysisImportTaskPanel", () => {
     ])
     ])
   })
   })
 
 
+  it("确认后删除失败任务", () => {
+    const confirm = vi.spyOn(window, "confirm").mockReturnValue(true)
+    const task = createTask("failed")
+    const props = renderPanel([task])
+
+    clickButton("删除")
+
+    expect(confirm).toHaveBeenCalledWith("确定删除导入失败的任务《作品-task-failed》吗?")
+    expect(props.onDeleteFailed).toHaveBeenCalledWith(task.id)
+  })
+
+  it("已完成任务通过行内输入触发重命名", () => {
+    const task = createTask("completed")
+    const props = renderPanel([task])
+
+    clickButton("重命名")
+    const input = host.querySelector(`input[aria-label="输入作品《${task.finalTitle}》的新名称"]`) as HTMLInputElement
+    expect(input).not.toBeNull()
+    expect(input.value).toBe(task.finalTitle)
+    act(() => {
+      Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, "  新名字  ")
+      input.dispatchEvent(new Event("input", { bubbles: true }))
+    })
+    clickButton("保存")
+
+    expect(props.onRenameCompleted).toHaveBeenCalledWith(task.id, "新名字")
+  })
+
+  it("行内重命名可以取消且不会提交", () => {
+    const task = createTask("completed")
+    const props = renderPanel([task])
+
+    clickButton("重命名")
+    clickButton("取消")
+
+    expect(host.querySelector("input")).toBeNull()
+    expect(props.onRenameCompleted).not.toHaveBeenCalled()
+  })
+
   it("折叠状态完全受控,任务进度更新不会自行展开", () => {
   it("折叠状态完全受控,任务进度更新不会自行展开", () => {
     const props = renderPanel([createTask("splitting")])
     const props = renderPanel([createTask("splitting")])
     expect(host.textContent).toContain("作品-task-splitting")
     expect(host.textContent).toContain("作品-task-splitting")

+ 79 - 4
src/components/novel/book-analysis-import-task-panel.tsx

@@ -1,5 +1,7 @@
+import { useState } from "react"
 import { ChevronDown, ChevronRight } from "lucide-react"
 import { ChevronDown, ChevronRight } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
 import type {
 import type {
   BatchImportBatch,
   BatchImportBatch,
   BatchImportTask,
   BatchImportTask,
@@ -15,6 +17,8 @@ interface BookAnalysisImportTaskPanelProps {
   onRegenerate: (taskId: string) => void
   onRegenerate: (taskId: string) => void
   onCancel: (taskId: string) => void
   onCancel: (taskId: string) => void
   onCancelAllQueued: (batchId: string) => void
   onCancelAllQueued: (batchId: string) => void
+  onDeleteFailed: (taskId: string) => void
+  onRenameCompleted: (taskId: string, title: string) => void
   onOpenBook: (bookId: string) => void
   onOpenBook: (bookId: string) => void
 }
 }
 
 
@@ -53,10 +57,21 @@ function TaskActions({
   onRegenerate,
   onRegenerate,
   onCancel,
   onCancel,
   onOpenBook,
   onOpenBook,
-}: Pick<BookAnalysisImportTaskPanelProps, "onContinue" | "onRegenerate" | "onCancel" | "onOpenBook"> & {
+  onDeleteFailed,
+  onRenameCompleted,
+}: Pick<BookAnalysisImportTaskPanelProps, "onContinue" | "onRegenerate" | "onCancel" | "onOpenBook" | "onDeleteFailed" | "onRenameCompleted"> & {
   task: BatchImportTask
   task: BatchImportTask
 }) {
 }) {
   const title = getTaskTitle(task)
   const title = getTaskTitle(task)
+  const [renaming, setRenaming] = useState(false)
+  const [renameValue, setRenameValue] = useState("")
+
+  const submitRename = () => {
+    const nextTitle = renameValue.trim()
+    if (!nextTitle || nextTitle === title) return
+    onRenameCompleted(task.id, nextTitle)
+    setRenaming(false)
+  }
 
 
   if (["queued", "copying", "splitting"].includes(task.status)) {
   if (["queued", "copying", "splitting"].includes(task.status)) {
     return (
     return (
@@ -80,6 +95,18 @@ function TaskActions({
         >
         >
           重新生成
           重新生成
         </Button>
         </Button>
+        {task.status === "failed" && (
+          <Button
+            size="xs"
+            variant="outline"
+            aria-label={`删除失败任务《${title}》`}
+            onClick={() => {
+              if (window.confirm(`确定删除导入失败的任务《${title}》吗?`)) onDeleteFailed(task.id)
+            }}
+          >
+            删除
+          </Button>
+        )}
       </div>
       </div>
     )
     )
   }
   }
@@ -98,10 +125,54 @@ function TaskActions({
   }
   }
 
 
   if (task.status === "completed") {
   if (task.status === "completed") {
+    if (renaming) {
+      return (
+        <form
+          className="flex min-w-0 shrink items-center gap-1"
+          onSubmit={(event) => {
+            event.preventDefault()
+            submitRename()
+          }}
+        >
+          <Input
+            autoFocus
+            className="h-7 w-40"
+            value={renameValue}
+            aria-label={`输入作品《${title}》的新名称`}
+            onChange={(event) => setRenameValue(event.target.value)}
+            onKeyDown={(event) => {
+              if (event.key === "Escape") {
+                event.preventDefault()
+                setRenaming(false)
+              }
+            }}
+          />
+          <Button size="xs" type="submit" disabled={!renameValue.trim() || renameValue.trim() === title}>
+            保存
+          </Button>
+          <Button size="xs" type="button" variant="ghost" onClick={() => setRenaming(false)}>
+            取消
+          </Button>
+        </form>
+      )
+    }
     return (
     return (
-      <Button size="xs" variant="outline" aria-label={`打开作品《${title}》`} onClick={() => onOpenBook(task.bookId)}>
-        打开作品
-      </Button>
+      <div className="flex shrink-0 items-center gap-1">
+        <Button
+          size="xs"
+          variant="outline"
+          aria-label={`重命名作品《${title}》`}
+          onClick={() => {
+            setRenameValue(title)
+            setRenaming(true)
+          }}
+        >
+          重命名
+        </Button>
+        <Button size="xs" variant="outline" aria-label={`打开作品《${title}》`} onClick={() => onOpenBook(task.bookId)}>
+          打开作品
+        </Button>
+      </div>
     )
     )
   }
   }
 
 
@@ -164,6 +235,8 @@ export function BookAnalysisImportTaskPanel({
   onRegenerate,
   onRegenerate,
   onCancel,
   onCancel,
   onCancelAllQueued,
   onCancelAllQueued,
+  onDeleteFailed,
+  onRenameCompleted,
   onOpenBook,
   onOpenBook,
 }: BookAnalysisImportTaskPanelProps) {
 }: BookAnalysisImportTaskPanelProps) {
   if (tasks.length === 0) return null
   if (tasks.length === 0) return null
@@ -274,6 +347,8 @@ export function BookAnalysisImportTaskPanel({
                         onRegenerate={onRegenerate}
                         onRegenerate={onRegenerate}
                         onCancel={onCancel}
                         onCancel={onCancel}
                         onOpenBook={onOpenBook}
                         onOpenBook={onOpenBook}
+                        onDeleteFailed={onDeleteFailed}
+                        onRenameCompleted={onRenameCompleted}
                       />
                       />
                     </article>
                     </article>
                   )
                   )

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

@@ -43,6 +43,8 @@ const mocks = vi.hoisted(() => {
   const regenerateTask = vi.fn()
   const regenerateTask = vi.fn()
   const cancelImportTask = vi.fn()
   const cancelImportTask = vi.fn()
   const cancelAllQueued = vi.fn()
   const cancelAllQueued = vi.fn()
+  const deleteFailedTask = vi.fn()
+  const renameCompletedTask = vi.fn()
   const dispose = vi.fn()
   const dispose = vi.fn()
   const setPanelCollapsed = vi.fn()
   const setPanelCollapsed = vi.fn()
   const triggerSidebarRefresh = vi.fn()
   const triggerSidebarRefresh = vi.fn()
@@ -101,6 +103,8 @@ const mocks = vi.hoisted(() => {
       regenerateTask,
       regenerateTask,
       cancelTask: cancelImportTask,
       cancelTask: cancelImportTask,
       cancelAllQueued,
       cancelAllQueued,
+      deleteFailedTask,
+      renameCompletedTask,
       setPanelCollapsed,
       setPanelCollapsed,
       dispose,
       dispose,
     },
     },
@@ -110,6 +114,8 @@ const mocks = vi.hoisted(() => {
     regenerateTask,
     regenerateTask,
     cancelImportTask,
     cancelImportTask,
     cancelAllQueued,
     cancelAllQueued,
+    deleteFailedTask,
+    renameCompletedTask,
     dispose,
     dispose,
     setPanelCollapsed,
     setPanelCollapsed,
     triggerSidebarRefresh,
     triggerSidebarRefresh,
@@ -215,6 +221,8 @@ vi.mock("./book-analysis-import-task-panel", async () => {
         React.createElement("button", { type: "button", onClick: () => props.onRegenerate("task-regenerate") }, "重新生成任务"),
         React.createElement("button", { type: "button", onClick: () => props.onRegenerate("task-regenerate") }, "重新生成任务"),
         React.createElement("button", { type: "button", onClick: () => props.onCancel("task-cancel") }, "取消任务"),
         React.createElement("button", { type: "button", onClick: () => props.onCancel("task-cancel") }, "取消任务"),
         React.createElement("button", { type: "button", onClick: () => props.onCancelAllQueued("batch-1") }, "取消批次等待"),
         React.createElement("button", { type: "button", onClick: () => props.onCancelAllQueued("batch-1") }, "取消批次等待"),
+        React.createElement("button", { type: "button", onClick: () => props.onDeleteFailed("task-failed") }, "删除失败任务"),
+        React.createElement("button", { type: "button", onClick: () => props.onRenameCompleted("task-completed", "新名字") }, "重命名完成任务"),
         React.createElement("button", { type: "button", onClick: () => props.onOpenBook("book-1") }, "打开导入作品"),
         React.createElement("button", { type: "button", onClick: () => props.onOpenBook("book-1") }, "打开导入作品"),
       )
       )
     },
     },
@@ -359,6 +367,8 @@ beforeEach(() => {
   mocks.regenerateTask.mockReset().mockResolvedValue(undefined)
   mocks.regenerateTask.mockReset().mockResolvedValue(undefined)
   mocks.cancelImportTask.mockReset().mockResolvedValue(undefined)
   mocks.cancelImportTask.mockReset().mockResolvedValue(undefined)
   mocks.cancelAllQueued.mockReset().mockResolvedValue(undefined)
   mocks.cancelAllQueued.mockReset().mockResolvedValue(undefined)
+  mocks.deleteFailedTask.mockReset().mockResolvedValue(undefined)
+  mocks.renameCompletedTask.mockReset().mockResolvedValue(undefined)
   mocks.dispose.mockReset().mockResolvedValue(undefined)
   mocks.dispose.mockReset().mockResolvedValue(undefined)
   mocks.setPanelCollapsed.mockReset()
   mocks.setPanelCollapsed.mockReset()
   mocks.triggerSidebarRefresh.mockReset().mockImplementation(() => { mocks.oldState.sidebarRefreshCounter += 1 })
   mocks.triggerSidebarRefresh.mockReset().mockImplementation(() => { mocks.oldState.sidebarRefreshCounter += 1 })
@@ -479,12 +489,16 @@ describe("BookAnalysisView 批量导入运行时接线", () => {
     await clickButton("重新生成任务")
     await clickButton("重新生成任务")
     await clickButton("取消任务")
     await clickButton("取消任务")
     await clickButton("取消批次等待")
     await clickButton("取消批次等待")
+    await clickButton("删除失败任务")
+    await clickButton("重命名完成任务")
 
 
     expect(mocks.setPanelCollapsed).toHaveBeenCalledWith(true)
     expect(mocks.setPanelCollapsed).toHaveBeenCalledWith(true)
     expect(mocks.continueTask).toHaveBeenCalledWith("task-continue")
     expect(mocks.continueTask).toHaveBeenCalledWith("task-continue")
     expect(mocks.regenerateTask).toHaveBeenCalledWith("task-regenerate")
     expect(mocks.regenerateTask).toHaveBeenCalledWith("task-regenerate")
     expect(mocks.cancelImportTask).toHaveBeenCalledWith("task-cancel")
     expect(mocks.cancelImportTask).toHaveBeenCalledWith("task-cancel")
     expect(mocks.cancelAllQueued).toHaveBeenCalledWith("batch-1")
     expect(mocks.cancelAllQueued).toHaveBeenCalledWith("batch-1")
+    expect(mocks.deleteFailedTask).toHaveBeenCalledWith("task-failed")
+    expect(mocks.renameCompletedTask).toHaveBeenCalledWith("task-completed", "新名字")
   })
   })
 
 
   it("打开 A 后普通选择 B 不回跳并同步状态", async () => {
   it("打开 A 后普通选择 B 不回跳并同步状态", async () => {

+ 12 - 0
src/components/novel/book-analysis-view.tsx

@@ -104,6 +104,8 @@ export function BookAnalysisView() {
   const regenerateImportTask = useBookAnalysisImportStore((s) => s.regenerateTask)
   const regenerateImportTask = useBookAnalysisImportStore((s) => s.regenerateTask)
   const cancelImportTask = useBookAnalysisImportStore((s) => s.cancelTask)
   const cancelImportTask = useBookAnalysisImportStore((s) => s.cancelTask)
   const cancelAllQueuedImportTasks = useBookAnalysisImportStore((s) => s.cancelAllQueued)
   const cancelAllQueuedImportTasks = useBookAnalysisImportStore((s) => s.cancelAllQueued)
+  const deleteFailedImportTask = useBookAnalysisImportStore((s) => s.deleteFailedTask)
+  const renameCompletedImportTask = useBookAnalysisImportStore((s) => s.renameCompletedTask)
   const setImportPanelCollapsed = useBookAnalysisImportStore((s) => s.setPanelCollapsed)
   const setImportPanelCollapsed = useBookAnalysisImportStore((s) => s.setPanelCollapsed)
   const disposeImportStore = useBookAnalysisImportStore((s) => s.dispose)
   const disposeImportStore = useBookAnalysisImportStore((s) => s.dispose)
   const importRevisionBaselineRef = useRef({ projectPath: currentProject?.path ?? null, revision: importRevision })
   const importRevisionBaselineRef = useRef({ projectPath: currentProject?.path ?? null, revision: importRevision })
@@ -471,6 +473,14 @@ export function BookAnalysisView() {
     runImportTaskAction(() => cancelAllQueuedImportTasks(batchId), "取消等待任务失败")
     runImportTaskAction(() => cancelAllQueuedImportTasks(batchId), "取消等待任务失败")
   }, [cancelAllQueuedImportTasks, runImportTaskAction])
   }, [cancelAllQueuedImportTasks, runImportTaskAction])
 
 
+  const handleDeleteFailedImportTask = useCallback((taskId: string) => {
+    runImportTaskAction(() => deleteFailedImportTask(taskId), "删除失败任务失败")
+  }, [deleteFailedImportTask, runImportTaskAction])
+
+  const handleRenameCompletedImportTask = useCallback((taskId: string, title: string) => {
+    runImportTaskAction(() => renameCompletedImportTask(taskId, title), "重命名作品失败")
+  }, [renameCompletedImportTask, runImportTaskAction])
+
   const handleSelectBook = useCallback((bookId: string) => {
   const handleSelectBook = useCallback((bookId: string) => {
     const book = libraryState.books.find((item) => item.id === bookId)
     const book = libraryState.books.find((item) => item.id === bookId)
     setSelectedBookId(bookId)
     setSelectedBookId(bookId)
@@ -509,6 +519,8 @@ export function BookAnalysisView() {
           onRegenerate={handleRegenerateImportTask}
           onRegenerate={handleRegenerateImportTask}
           onCancel={handleCancelImportTask}
           onCancel={handleCancelImportTask}
           onCancelAllQueued={handleCancelAllQueuedImportTasks}
           onCancelAllQueued={handleCancelAllQueuedImportTasks}
+          onDeleteFailed={handleDeleteFailedImportTask}
+          onRenameCompleted={handleRenameCompletedImportTask}
           onOpenBook={handleOpenImportedBook}
           onOpenBook={handleOpenImportedBook}
         />
         />
       }
       }

+ 1 - 0
src/components/novel/hooks/use-character-extraction.ts

@@ -92,6 +92,7 @@ export function useCharacterExtraction({
         depth,
         depth,
         bookTitle: metadata.title,
         bookTitle: metadata.title,
         bookAuthor: metadata.author,
         bookAuthor: metadata.author,
+        targetCharacters: userPicked,
         onProgress: (progress) => {
         onProgress: (progress) => {
           updateTaskProgress(taskId, {
           updateTaskProgress(taskId, {
             stage: progress.stage as any,
             stage: progress.stage as any,

+ 25 - 0
src/components/reference/ReferenceInput.spec.tsx

@@ -29,6 +29,7 @@ function dispatchTextareaInput(element: HTMLTextAreaElement, text: string) {
 }
 }
 
 
 beforeEach(() => {
 beforeEach(() => {
+  localStorage.clear()
   host = document.createElement("div")
   host = document.createElement("div")
   document.body.appendChild(host)
   document.body.appendChild(host)
   root = createRoot(host)
   root = createRoot(host)
@@ -42,6 +43,30 @@ afterEach(() => {
 })
 })
 
 
 describe("ReferenceInput", () => {
 describe("ReferenceInput", () => {
+  it("keeps the input at least as tall as the workflow-mode dropdown", async () => {
+    localStorage.setItem("qmai-reference-input-height", "48")
+
+    await act(async () => {
+      root.render(<ReferenceInput tokens={[]} onSubmit={vi.fn()} />)
+    })
+
+    const editor = host.querySelector("textarea") as HTMLTextAreaElement
+    expect(editor.style.height).toBe("192px")
+    expect(editor.style.maxHeight).toBe("192px")
+  })
+
+  it("allows resizing up to 300px and clamps larger saved heights", async () => {
+    localStorage.setItem("qmai-reference-input-height", "480")
+
+    await act(async () => {
+      root.render(<ReferenceInput tokens={[]} onSubmit={vi.fn()} />)
+    })
+
+    const editor = host.querySelector("textarea") as HTMLTextAreaElement
+    expect(editor.style.height).toBe("300px")
+    expect(editor.style.maxHeight).toBe("300px")
+  })
+
   it("triggers reference picker from @ key and button", async () => {
   it("triggers reference picker from @ key and button", async () => {
     const onAtTrigger = vi.fn()
     const onAtTrigger = vi.fn()
 
 

+ 5 - 3
src/components/reference/ReferenceInput.tsx

@@ -15,9 +15,11 @@ import { ReferenceChip } from "./ReferenceChip"
 export type InsertReferenceTokens = ((tokens: ReferenceToken[]) => void) | null
 export type InsertReferenceTokens = ((tokens: ReferenceToken[]) => void) | null
 
 
 const REFERENCE_INPUT_HEIGHT_KEY = "qmai-reference-input-height"
 const REFERENCE_INPUT_HEIGHT_KEY = "qmai-reference-input-height"
-const DEFAULT_REFERENCE_INPUT_HEIGHT = 56
-const MIN_REFERENCE_INPUT_HEIGHT = 48
-const MAX_REFERENCE_INPUT_HEIGHT = 220
+// Keep enough space below the workflow-mode trigger for its three-option menu.
+// Saved heights from older versions are clamped to this new lower bound.
+const DEFAULT_REFERENCE_INPUT_HEIGHT = 192
+const MIN_REFERENCE_INPUT_HEIGHT = 192
+const MAX_REFERENCE_INPUT_HEIGHT = 300
 
 
 interface ReferenceInputProps {
 interface ReferenceInputProps {
   value?: string
   value?: string

+ 7 - 0
src/components/sources/outline-chat-panel.spec.tsx

@@ -414,6 +414,13 @@ describe("OutlineChatPanel controls", () => {
     expect(source).not.toContain("conversations.map((conv) => (")
     expect(source).not.toContain("conversations.map((conv) => (")
   })
   })
 
 
+  it("provides one-click clearing for outline conversation history", () => {
+    expect(source).toContain('aria-label="一键清理会话历史"')
+    expect(source).toContain("requestClearHistory")
+    expect(source).toContain("confirmClearHistory")
+    expect(source).toContain("<ConversationHistoryClearDialog")
+  })
+
   it("passes confirm and reject handlers into the outline tool workflow", () => {
   it("passes confirm and reject handlers into the outline tool workflow", () => {
     expect(source).toContain("handleConfirmToolSave")
     expect(source).toContain("handleConfirmToolSave")
     expect(source).toContain("handleRejectTool")
     expect(source).toContain("handleRejectTool")

+ 40 - 0
src/components/sources/outline-chat-panel.tsx

@@ -199,6 +199,7 @@ import { IntentOptionsCard } from "@/components/sources/outline-intent-options-c
 import { NextStepCard } from "@/components/sources/outline-next-step-card";
 import { NextStepCard } from "@/components/sources/outline-next-step-card";
 import { ConversationRunStatusIcon } from "@/components/common/conversation-run-status-icon";
 import { ConversationRunStatusIcon } from "@/components/common/conversation-run-status-icon";
 import { ConversationDeleteConfirmDialog } from "@/components/common/conversation-delete-confirm-dialog";
 import { ConversationDeleteConfirmDialog } from "@/components/common/conversation-delete-confirm-dialog";
+import { ConversationHistoryClearDialog } from "@/components/common/conversation-history-clear-dialog";
 import {
 import {
   canCreateNewConversation,
   canCreateNewConversation,
   EMPTY_CONVERSATION_CREATE_REASON,
   EMPTY_CONVERSATION_CREATE_REASON,
@@ -1253,6 +1254,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const [hoveredConversationId, setHoveredConversationId] = useState<string | null>(null);
   const [hoveredConversationId, setHoveredConversationId] = useState<string | null>(null);
   const [historyOpen, setHistoryOpen] = useState(false);
   const [historyOpen, setHistoryOpen] = useState(false);
   const [pendingDeleteConversationId, setPendingDeleteConversationId] = useState<string | null>(null);
   const [pendingDeleteConversationId, setPendingDeleteConversationId] = useState<string | null>(null);
+  const [pendingClearHistoryIds, setPendingClearHistoryIds] = useState<string[] | null>(null);
   const historyRef = useRef<HTMLDivElement | null>(null);
   const historyRef = useRef<HTMLDivElement | null>(null);
   const historyButtonRef = useRef<HTMLButtonElement | null>(null);
   const historyButtonRef = useRef<HTMLButtonElement | null>(null);
   const historyDropdownRef = useRef<HTMLDivElement | null>(null);
   const historyDropdownRef = useRef<HTMLDivElement | null>(null);
@@ -3550,6 +3552,24 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     setPendingDeleteConversationId(null);
     setPendingDeleteConversationId(null);
   }, [clearStreamingContent, deleteConversation, pendingDeleteConversationId, stopConversationRun]);
   }, [clearStreamingContent, deleteConversation, pendingDeleteConversationId, stopConversationRun]);
 
 
+  const requestClearHistory = useCallback(() => {
+    setPendingClearHistoryIds(historyConversations.map((conversation) => conversation.id));
+    setHistoryOpen(false);
+  }, [historyConversations]);
+
+  const confirmClearHistory = useCallback(() => {
+    for (const conversationId of pendingClearHistoryIds ?? []) {
+      const runningState = useOutlineChatStore.getState().runStates[conversationId];
+      outlineConversationRunRegistry.abort(conversationId);
+      if (runningState?.status === "running" && runningState.runId) {
+        stopConversationRun(conversationId, runningState.runId);
+      }
+      clearStreamingContent(conversationId);
+      deleteConversation(conversationId);
+    }
+    setPendingClearHistoryIds(null);
+  }, [clearStreamingContent, deleteConversation, pendingClearHistoryIds, stopConversationRun]);
+
   const outlineRunLimitReached = activeConversationId
   const outlineRunLimitReached = activeConversationId
     ? !isStreaming && !canStartConversationRun(activeConversationId)
     ? !isStreaming && !canStartConversationRun(activeConversationId)
     : Object.values(runStates).filter((state) => state.status === "running").length >= 3;
     : Object.values(runStates).filter((state) => state.status === "running").length >= 3;
@@ -3668,6 +3688,20 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                   className="fixed z-50 max-h-[60vh] w-72 overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg"
                   className="fixed z-50 max-h-[60vh] w-72 overflow-y-auto rounded-md border border-border bg-background p-1 shadow-lg"
                   style={historyDropdownStyle}
                   style={historyDropdownStyle}
                 >
                 >
+                  {historyCount > 0 ? (
+                    <div className="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background px-2 py-1.5">
+                      <span className="text-xs text-muted-foreground">共 {historyCount} 条</span>
+                      <button
+                        type="button"
+                        aria-label="一键清理会话历史"
+                        onClick={requestClearHistory}
+                        className="inline-flex h-7 items-center gap-1 rounded px-2 text-xs text-destructive hover:bg-destructive/10"
+                      >
+                        <Trash2 className="h-3.5 w-3.5" />
+                        一键清理
+                      </button>
+                    </div>
+                  ) : null}
                   {historyCount === 0 ? (
                   {historyCount === 0 ? (
                     <div className="px-2 py-3 text-center text-xs text-muted-foreground">
                     <div className="px-2 py-3 text-center text-xs text-muted-foreground">
                       暂无历史大纲对话
                       暂无历史大纲对话
@@ -3889,6 +3923,12 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           onCancel={() => setPendingDeleteConversationId(null)}
           onCancel={() => setPendingDeleteConversationId(null)}
           onConfirm={confirmDeleteRunningConversation}
           onConfirm={confirmDeleteRunningConversation}
         />
         />
+        <ConversationHistoryClearDialog
+          open={pendingClearHistoryIds !== null}
+          count={pendingClearHistoryIds?.length ?? 0}
+          onCancel={() => setPendingClearHistoryIds(null)}
+          onConfirm={confirmClearHistory}
+        />
         {saveConfirmState ? (
         {saveConfirmState ? (
           <OutlineSaveConfirmDialog
           <OutlineSaveConfirmDialog
             open
             open

+ 65 - 2
src/hooks/use-agent-config.spec.ts

@@ -3,7 +3,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
 import React from "react"
 import React from "react"
 import { createRoot } from "react-dom/client"
 import { createRoot } from "react-dom/client"
 import { act } from "react"
 import { act } from "react"
-import type { LlmConfig, ProviderConfigs, SearchApiConfig } from "@/stores/wiki-store"
+import { DEFAULT_NOVEL_CONFIG, type LlmConfig, type NovelConfig, type ProviderConfigs, type SearchApiConfig } from "@/stores/wiki-store"
 import type { WikiProject } from "@/types/wiki"
 import type { WikiProject } from "@/types/wiki"
 import type { Conversation, DisplayMessage } from "@/stores/chat-store"
 import type { Conversation, DisplayMessage } from "@/stores/chat-store"
 import type { OutlineChatConversation } from "@/stores/outline-chat-store"
 import type { OutlineChatConversation } from "@/stores/outline-chat-store"
@@ -29,6 +29,8 @@ interface StoreStates {
     dataVersion: number
     dataVersion: number
     llmConfig: LlmConfig
     llmConfig: LlmConfig
     providerConfigs: ProviderConfigs
     providerConfigs: ProviderConfigs
+    defaultLlmModel: string
+    novelConfig: NovelConfig
     searchApiConfig: SearchApiConfig
     searchApiConfig: SearchApiConfig
     mcpConfig: McpConfig
     mcpConfig: McpConfig
     aiWorkflowMode: AiWorkflowMode
     aiWorkflowMode: AiWorkflowMode
@@ -57,7 +59,15 @@ async function renderHook(systemPrompt: string, overrides: StoreStates & {
     project: null as WikiProject | null,
     project: null as WikiProject | null,
     dataVersion: 0,
     dataVersion: 0,
     llmConfig: baseLlmConfig,
     llmConfig: baseLlmConfig,
-    providerConfigs: {} as ProviderConfigs,
+    providerConfigs: {
+      openai: {
+        enabled: true,
+        apiKey: "test-key",
+        savedModels: [{ id: "gpt-4o", name: "GPT-4o", model: "gpt-4o", createdAt: 1 }],
+      },
+    } as ProviderConfigs,
+    defaultLlmModel: "",
+    novelConfig: { ...DEFAULT_NOVEL_CONFIG },
     searchApiConfig: {
     searchApiConfig: {
       provider: "none",
       provider: "none",
       apiKey: "",
       apiKey: "",
@@ -260,6 +270,59 @@ describe("useAgentConfig", () => {
     await cleanup()
     await cleanup()
   }, 15000)
   }, 15000)
 
 
+  it("uses the default model for Agent orchestration while preserving the chat model for chapter writing", async () => {
+    const providerConfigs: ProviderConfigs = {
+      custom: {
+        enabled: true,
+        apiKey: "test-key",
+        savedModels: [
+          { id: "writer", name: "Writer", model: "writer-model", createdAt: 1 },
+          { id: "workflow", name: "Workflow", model: "workflow-model", createdAt: 2 },
+        ],
+      },
+    }
+    const { result, cleanup } = await renderHook("test prompt", {
+      wiki: {
+        aiChatModel: "custom/writer-model",
+        defaultLlmModel: "custom/workflow-model",
+        novelConfig: { ...DEFAULT_NOVEL_CONFIG, defaultLlmModel: "custom/workflow-model" },
+        providerConfigs,
+        project: { path: "/tmp/project" } as WikiProject,
+      },
+      skillConfig: {
+        version: 1,
+        defaultSkillId: "built-in:comprehensive",
+        disabledSkillIds: [],
+        projectSkills: [],
+        builtInSkillOverrides: [],
+        lastChapterDeAiSkillId: null,
+      },
+    })
+
+    expect(result.config?.modelId).toBe("workflow-model")
+    expect(result.config?.llmConfig.model).toBe("workflow-model")
+
+    const workflowTool = result.registry.get("run_chapter_workflow")
+    const deepChapterModule = await import("@/lib/novel/deep-chapter-generation")
+    const runDeepChapterGeneration = vi.mocked(deepChapterModule.runDeepChapterGeneration)
+    runDeepChapterGeneration.mockResolvedValueOnce({
+      finalContent: "正文",
+      taskBrief: "任务书",
+      draftContent: "初稿",
+      reviewResults: [],
+      revised: false,
+    })
+    await workflowTool?.execute({ userRequest: "写第三章" })
+    expect(runDeepChapterGeneration).toHaveBeenCalledWith(
+      expect.objectContaining({ llmConfig: expect.objectContaining({ model: "writer-model" }) }),
+      expect.any(Object),
+      undefined,
+      undefined,
+    )
+
+    await cleanup()
+  }, 15000)
+
   it("passes Web Search settings into the web_search agent tool", async () => {
   it("passes Web Search settings into the web_search agent tool", async () => {
     webSearchMock.mockResolvedValueOnce([])
     webSearchMock.mockResolvedValueOnce([])
     const searchApiConfig: SearchApiConfig = {
     const searchApiConfig: SearchApiConfig = {

+ 11 - 5
src/hooks/use-agent-config.ts

@@ -5,7 +5,7 @@ import { useOutlineChatStore } from "@/stores/outline-chat-store"
 import { loadDeAiSkillConfig, type DeAiSkillConfig } from "@/lib/novel/de-ai-skill-library"
 import { loadDeAiSkillConfig, type DeAiSkillConfig } from "@/lib/novel/de-ai-skill-library"
 import { loadAllLinkedSkillsContent, loadUserSkillConfig, resolveEnabledWritingSkills } from "@/lib/novel/user-skill-store"
 import { loadAllLinkedSkillsContent, loadUserSkillConfig, resolveEnabledWritingSkills } from "@/lib/novel/user-skill-store"
 import type { UserSkill } from "@/lib/novel/skill-library"
 import type { UserSkill } from "@/lib/novel/skill-library"
-import { resolveModelConfig } from "@/lib/novel/model-resolver"
+import { resolveDefaultModel, resolveModelConfig } from "@/lib/novel/model-resolver"
 import { runDeepChapterGeneration } from "@/lib/novel/deep-chapter-generation"
 import { runDeepChapterGeneration } from "@/lib/novel/deep-chapter-generation"
 import { normalizePath } from "@/lib/path-utils"
 import { normalizePath } from "@/lib/path-utils"
 import { ToolRegistry } from "@/lib/agent/registry"
 import { ToolRegistry } from "@/lib/agent/registry"
@@ -28,6 +28,8 @@ export interface UseAgentConfigResult {
 
 
 export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => string | undefined): UseAgentConfigResult {
 export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => string | undefined): UseAgentConfigResult {
   const aiChatModel = useWikiStore((s) => s.aiChatModel)
   const aiChatModel = useWikiStore((s) => s.aiChatModel)
+  const defaultLlmModel = useWikiStore((s) => s.defaultLlmModel)
+  const novelDefaultLlmModel = useWikiStore((s) => s.novelConfig.defaultLlmModel)
   const projectPath = useWikiStore((s) => s.project?.path)
   const projectPath = useWikiStore((s) => s.project?.path)
   const dataVersion = useWikiStore((s) => s.dataVersion)
   const dataVersion = useWikiStore((s) => s.dataVersion)
   const baseLlmConfig = useWikiStore((s) => s.llmConfig)
   const baseLlmConfig = useWikiStore((s) => s.llmConfig)
@@ -108,7 +110,8 @@ export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => st
   )
   )
 
 
   return useMemo(() => {
   return useMemo(() => {
-    const supportsTools = modelSupportsTools(aiChatModel)
+    const agentLlmConfig = resolveDefaultModel(baseLlmConfig)
+    const supportsTools = modelSupportsTools(agentLlmConfig.model)
 
 
     if (!supportsTools || !projectPath || !skillConfigLoaded) {
     if (!supportsTools || !projectPath || !skillConfigLoaded) {
       return {
       return {
@@ -123,7 +126,7 @@ export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => st
       }
       }
     }
     }
 
 
-    const llmConfig = resolveModelConfig(aiChatModel, baseLlmConfig, providerConfigs)
+    const chapterWritingLlmConfig = resolveModelConfig(aiChatModel, baseLlmConfig, providerConfigs)
     const registry = new ToolRegistry()
     const registry = new ToolRegistry()
     const wikiPath = `${normalizePath(projectPath)}/wiki`
     const wikiPath = `${normalizePath(projectPath)}/wiki`
     const novelMode = useWikiStore.getState().novelMode
     const novelMode = useWikiStore.getState().novelMode
@@ -131,7 +134,7 @@ export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => st
       ? new RealMcpConnector(mcpConfig)
       ? new RealMcpConnector(mcpConfig)
       : undefined
       : undefined
     const mcpRuntime = buildMcpRuntime(mcpConfig, undefined, realMcpConnector)
     const mcpRuntime = buildMcpRuntime(mcpConfig, undefined, realMcpConnector)
-    const config = buildAgentConfig(aiChatModel, systemPrompt, registry, {
+    const config = buildAgentConfig(agentLlmConfig.model, systemPrompt, registry, {
       wikiPath,
       wikiPath,
       getSkillConfig,
       getSkillConfig,
       getUserSkills,
       getUserSkills,
@@ -139,7 +142,8 @@ export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => st
       getChatConversations,
       getChatConversations,
       getOutlineConversations,
       getOutlineConversations,
       mcpTools: mcpRuntime.mcpTools,
       mcpTools: mcpRuntime.mcpTools,
-      llmConfig,
+      llmConfig: agentLlmConfig,
+      chapterWritingLlmConfig,
       aiWorkflowMode,
       aiWorkflowMode,
       runDeepChapterGeneration,
       runDeepChapterGeneration,
       draftMode: novelMode,
       draftMode: novelMode,
@@ -160,6 +164,8 @@ export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => st
     }
     }
   }, [
   }, [
     aiChatModel,
     aiChatModel,
+    defaultLlmModel,
+    novelDefaultLlmModel,
     projectPath,
     projectPath,
     skillConfigLoaded,
     skillConfigLoaded,
     baseLlmConfig,
     baseLlmConfig,

+ 6 - 0
src/i18n/en.json

@@ -363,6 +363,12 @@
     "clickNewChatToBegin": "Click ‘New Chat’ to begin",
     "clickNewChatToBegin": "Click ‘New Chat’ to begin",
     "conversationHistory": "History",
     "conversationHistory": "History",
     "noHistoryConversations": "No history yet",
     "noHistoryConversations": "No history yet",
+    "historyConversationCount": "{{count}} total",
+    "clearHistory": "Clear all",
+    "clearHistoryTitle": "Clear conversation history?",
+    "clearHistoryDescription": "This permanently deletes {{count}} historical conversations and their messages. This action cannot be undone.",
+    "clearHistoryCancel": "Cancel",
+    "clearHistoryConfirm": "Clear history",
     "modelAnswering": "Model is answering…",
     "modelAnswering": "Model is answering…",
     "send": "Send",
     "send": "Send",
     "thinking": "Thinking…",
     "thinking": "Thinking…",

+ 6 - 0
src/i18n/zh.json

@@ -253,6 +253,12 @@
     "clickNewChatToBegin": "点击「新建对话」开始",
     "clickNewChatToBegin": "点击「新建对话」开始",
     "conversationHistory": "会话历史",
     "conversationHistory": "会话历史",
     "noHistoryConversations": "暂无历史会话",
     "noHistoryConversations": "暂无历史会话",
+    "historyConversationCount": "共 {{count}} 条",
+    "clearHistory": "一键清理",
+    "clearHistoryTitle": "清理会话历史?",
+    "clearHistoryDescription": "将永久删除 {{count}} 条历史会话及其消息,此操作无法撤销。",
+    "clearHistoryCancel": "取消",
+    "clearHistoryConfirm": "确认清理",
     "typeAMessage": "输入消息...",
     "typeAMessage": "输入消息...",
     "selectModel": "选择模型",
     "selectModel": "选择模型",
     "searchModel": "搜索模型...",
     "searchModel": "搜索模型...",

+ 4 - 2
src/lib/agent/tools/index.ts

@@ -53,6 +53,8 @@ export interface ToolFactoryOptions {
   enabledToolNames?: string[]
   enabledToolNames?: string[]
   disabledTools?: string[]
   disabledTools?: string[]
   llmConfig?: LlmConfig
   llmConfig?: LlmConfig
+  /** 章节正文专用模型。Agent 调度可使用默认模型,但正文仍必须使用聊天框模型。 */
+  chapterWritingLlmConfig?: LlmConfig
   aiWorkflowMode?: LegacyAiWorkflowMode
   aiWorkflowMode?: LegacyAiWorkflowMode
   runDeepChapterGeneration?: RunDeepChapterGeneration
   runDeepChapterGeneration?: RunDeepChapterGeneration
   onToolEvent?: (event: AgentToolEvent) => void
   onToolEvent?: (event: AgentToolEvent) => void
@@ -98,13 +100,13 @@ export function registerAllBuiltInTools(registry: ToolRegistry, options: ToolFac
   if (
   if (
     shouldRegister("run_chapter_workflow") &&
     shouldRegister("run_chapter_workflow") &&
     options.projectPath &&
     options.projectPath &&
-    options.llmConfig &&
+    (options.chapterWritingLlmConfig || options.llmConfig) &&
     options.aiWorkflowMode &&
     options.aiWorkflowMode &&
     options.runDeepChapterGeneration
     options.runDeepChapterGeneration
   ) {
   ) {
     registry.register(createRunChapterWorkflowTool({
     registry.register(createRunChapterWorkflowTool({
       projectPath: options.projectPath,
       projectPath: options.projectPath,
-      llmConfig: options.llmConfig,
+      llmConfig: options.chapterWritingLlmConfig || options.llmConfig!,
       aiWorkflowMode: options.aiWorkflowMode,
       aiWorkflowMode: options.aiWorkflowMode,
       runDeepChapterGeneration: options.runDeepChapterGeneration,
       runDeepChapterGeneration: options.runDeepChapterGeneration,
       onToolEvent: options.onToolEvent,
       onToolEvent: options.onToolEvent,

+ 6 - 0
src/lib/agent/workflow-trace.spec.ts

@@ -139,5 +139,11 @@ describe("getWorkflowToolDescription", () => {
     expect(getWorkflowToolDescription(call({ id: "draft", name: "chapter_draft", params: { chars: 3200 } }))).toBe("生成章节正文初稿")
     expect(getWorkflowToolDescription(call({ id: "draft", name: "chapter_draft", params: { chars: 3200 } }))).toBe("生成章节正文初稿")
     expect(getWorkflowToolDescription(call({ id: "review", name: "chapter_review" }))).toBe("执行 AI 审稿")
     expect(getWorkflowToolDescription(call({ id: "review", name: "chapter_review" }))).toBe("执行 AI 审稿")
     expect(getWorkflowToolDescription(call({ id: "polish", name: "chapter_final_polish" }))).toBe("简单审查与去AI味")
     expect(getWorkflowToolDescription(call({ id: "polish", name: "chapter_final_polish" }))).toBe("简单审查与去AI味")
+    expect(getWorkflowToolDescription(call({ id: "report", name: "chapter_execution_report" }))).toBe("检查章节执行清单")
+    expect(getWorkflowToolDescription(call({ id: "repair", name: "chapter_execution_repair" }))).toBe("返修执行清单失败项")
+    expect(getWorkflowToolDescription(call({ id: "recheck", name: "chapter_execution_recheck" }))).toBe("复检章节执行清单")
+    expect(getWorkflowToolDescription(call({ id: "compliance", name: "chapter_plan_compliance" }))).toBe("检查章节计划履约度")
+    expect(getWorkflowToolDescription(call({ id: "deviation-repair", name: "chapter_plan_deviation_repair" }))).toBe("返修章节计划偏离点")
+    expect(getWorkflowToolDescription(call({ id: "deviation-recheck", name: "chapter_plan_deviation_recheck" }))).toBe("复检章节计划履约度")
   })
   })
 })
 })

+ 12 - 0
src/lib/agent/workflow-trace.ts

@@ -190,6 +190,18 @@ export function getWorkflowToolDescription(call: WorkflowToolCall): string {
       return "返修后角色一致性复审"
       return "返修后角色一致性复审"
     case "chapter_final_polish":
     case "chapter_final_polish":
       return "简单审查与去AI味"
       return "简单审查与去AI味"
+    case "chapter_execution_report":
+      return "检查章节执行清单"
+    case "chapter_execution_repair":
+      return "返修执行清单失败项"
+    case "chapter_execution_recheck":
+      return "复检章节执行清单"
+    case "chapter_plan_compliance":
+      return "检查章节计划履约度"
+    case "chapter_plan_deviation_repair":
+      return "返修章节计划偏离点"
+    case "chapter_plan_deviation_recheck":
+      return "复检章节计划履约度"
     case "chapter_complete":
     case "chapter_complete":
       return "完成多任务写作循环"
       return "完成多任务写作循环"
     default:
     default:

+ 13 - 0
src/lib/novel/book-analysis/batch-import-scheduler.spec.ts

@@ -642,4 +642,17 @@ describe("createBatchImportScheduler", () => {
     expect(mocks.saveBatchImportTask.mock.calls.at(-1)?.[0].status).toBe("cancelled")
     expect(mocks.saveBatchImportTask.mock.calls.at(-1)?.[0].status).toBe("cancelled")
     expect(mocks.saveBatchImportTask.mock.calls.some(([task]) => task.status === "completed")).toBe(false)
     expect(mocks.saveBatchImportTask.mock.calls.some(([task]) => task.status === "completed")).toBe(false)
   })
   })
+
+  it("可同步重命名终态任务并彻底忘记已删除任务", () => {
+    const scheduler = createBatchImportScheduler({ projectPath: PROJECT_PATH })
+    const getTasks = latestTasks(scheduler)
+    const completed = makeTask("task-1", { status: "completed", finalTitle: "旧名字" })
+    scheduler.enqueue([completed])
+
+    scheduler.syncTerminalTask({ ...completed, finalTitle: "新名字" })
+    expect(getTasks()[0].finalTitle).toBe("新名字")
+
+    scheduler.forgetTerminalTask(completed.id)
+    expect(getTasks()).toEqual([])
+  })
 })
 })

+ 20 - 0
src/lib/novel/book-analysis/batch-import-scheduler.ts

@@ -11,6 +11,8 @@ export interface BatchImportScheduler {
   regenerateTask(taskId: string): Promise<void>
   regenerateTask(taskId: string): Promise<void>
   cancelTask(taskId: string): Promise<void>
   cancelTask(taskId: string): Promise<void>
   cancelAllQueued(batchId: string): Promise<void>
   cancelAllQueued(batchId: string): Promise<void>
+  syncTerminalTask(task: BatchImportTask): void
+  forgetTerminalTask(taskId: string): void
   subscribe(listener: (tasks: BatchImportTask[]) => void): () => void
   subscribe(listener: (tasks: BatchImportTask[]) => void): () => void
   dispose(): Promise<void>
   dispose(): Promise<void>
 }
 }
@@ -306,6 +308,22 @@ export function createBatchImportScheduler(options: {
     }
     }
   }
   }
 
 
+  function syncTerminalTask(task: BatchImportTask): void {
+    const current = tasks.get(task.id)
+    if (!current || !["failed", "cancelled", "skipped", "completed"].includes(task.status)) return
+    tasks.set(task.id, task)
+    notify()
+  }
+
+  function forgetTerminalTask(taskId: string): void {
+    const task = tasks.get(taskId)
+    if (!task || !["failed", "cancelled", "skipped", "completed"].includes(task.status)) return
+    // 终态会在 executeTask 的 finally 释放 runningIds;通知到 UI 后用户可能立即删除,
+    // 此时允许先忘记任务,finally 不再依赖 tasks 中的这条记录。
+    tasks.delete(taskId)
+    notify()
+  }
+
   function subscribe(listener: (tasks: BatchImportTask[]) => void): () => void {
   function subscribe(listener: (tasks: BatchImportTask[]) => void): () => void {
     if (disposed) return () => undefined
     if (disposed) return () => undefined
     listeners.add(listener)
     listeners.add(listener)
@@ -345,6 +363,8 @@ export function createBatchImportScheduler(options: {
     regenerateTask: (taskId) => trackMutation(() => regenerateTask(taskId)),
     regenerateTask: (taskId) => trackMutation(() => regenerateTask(taskId)),
     cancelTask: (taskId) => disposed ? Promise.resolve() : trackMutation(() => cancelTask(taskId)),
     cancelTask: (taskId) => disposed ? Promise.resolve() : trackMutation(() => cancelTask(taskId)),
     cancelAllQueued: (batchId) => disposed ? Promise.resolve() : trackMutation(() => cancelAllQueued(batchId)),
     cancelAllQueued: (batchId) => disposed ? Promise.resolve() : trackMutation(() => cancelAllQueued(batchId)),
+    syncTerminalTask,
+    forgetTerminalTask,
     subscribe,
     subscribe,
     dispose,
     dispose,
   }
   }

+ 31 - 0
src/lib/novel/book-analysis/batch-import-storage.spec.ts

@@ -31,6 +31,7 @@ vi.mock("@/commands/fs", () => ({
 import {
 import {
   cacheTaskSource,
   cacheTaskSource,
   cleanupCompletedTaskWorkspaceUnlocked,
   cleanupCompletedTaskWorkspaceUnlocked,
+  deleteFailedBatchImportTask,
   importTaskDir,
   importTaskDir,
   importTasksRoot,
   importTasksRoot,
   loadBatchImportBatches,
   loadBatchImportBatches,
@@ -124,6 +125,36 @@ describe("batch import storage", () => {
     )
     )
   })
   })
 
 
+  it("删除失败任务时同步移除批次引用和空批次", async () => {
+    const failed = makeTask({ status: "failed", error: "导入失败", completedAt: 200 })
+    seedTask(failed)
+    const root = importTasksRoot(failed.projectPath)
+    fsMocks.directories.add(root)
+    fsMocks.files.set(`${root}/batches.json`, JSON.stringify([{
+      version: 1,
+      id: failed.batchId,
+      projectPath: failed.projectPath,
+      taskIds: [failed.id],
+      createdAt: 100,
+      updatedAt: 100,
+    }]))
+
+    await deleteFailedBatchImportTask(failed.projectPath, failed.id)
+
+    expect(fsMocks.files.has(`${importTaskDir(failed.projectPath, failed.id)}/task.json`)).toBe(false)
+    expect(JSON.parse(fsMocks.files.get(`${root}/batches.json`)!)).toEqual([])
+  })
+
+  it("拒绝删除非失败任务", async () => {
+    const completed = makeTask({ status: "completed", completedAt: 200 })
+    seedTask(completed)
+
+    await expect(deleteFailedBatchImportTask(completed.projectPath, completed.id)).rejects.toThrow(
+      "只能删除导入失败的任务",
+    )
+    expect(fsMocks.files.has(`${importTaskDir(completed.projectPath, completed.id)}/task.json`)).toBe(true)
+  })
+
   it("任务根目录不存在时返回空列表", async () => {
   it("任务根目录不存在时返回空列表", async () => {
     await expect(loadBatchImportTasks("E:/Novel")).resolves.toEqual([])
     await expect(loadBatchImportTasks("E:/Novel")).resolves.toEqual([])
     expect(fsMocks.listDirectory).not.toHaveBeenCalled()
     expect(fsMocks.listDirectory).not.toHaveBeenCalled()

+ 53 - 0
src/lib/novel/book-analysis/batch-import-storage.ts

@@ -306,6 +306,59 @@ export async function saveBatchImportBatch(batch: BatchImportBatch): Promise<voi
   })
   })
 }
 }
 
 
+export async function deleteFailedBatchImportTask(
+  projectPath: string,
+  taskId: string,
+): Promise<void> {
+  assertValidTaskId(taskId)
+  const projectKey = normalizeProjectKey(projectPath)
+  await withBatchImportTaskLock(projectKey, taskId, async () => {
+    const taskPath = taskFilePath(projectKey, taskId)
+    if (!(await fileExists(taskPath))) throw new Error(`找不到批量导入任务:${taskId}`)
+    const parsed = JSON.parse(await readFile(taskPath)) as unknown
+    const validationError = validatePersistedTask(parsed, projectKey, taskId)
+    if (validationError) throw new Error(validationError)
+    if ((parsed as BatchImportTask).status !== "failed") {
+      throw new Error("只能删除导入失败的任务")
+    }
+
+    await withProjectLock(projectKey, async () => {
+      const batchesPath = `${importTasksRoot(projectKey)}/batches.json`
+      const hadBatchesFile = await fileExists(batchesPath)
+      const originalRaw = hadBatchesFile ? await readFile(batchesPath) : null
+      let batches: BatchImportBatch[] = []
+      if (originalRaw !== null) {
+        const original = JSON.parse(originalRaw) as unknown
+        if (!Array.isArray(original)) throw new Error("批量导入批次数据无效")
+        batches = original.filter((item): item is BatchImportBatch => isBatchImportBatch(item, projectKey))
+      }
+      const nextBatches = batches
+        .map((batch) => ({
+          ...batch,
+          taskIds: batch.taskIds.filter((id) => id !== taskId),
+          updatedAt: batch.taskIds.includes(taskId) ? Date.now() : batch.updatedAt,
+        }))
+        .filter((batch) => batch.taskIds.length > 0)
+
+      if (originalRaw !== null) {
+        await writeFileAtomic(batchesPath, JSON.stringify(nextBatches, null, 2))
+      }
+      try {
+        await deleteFile(importTaskDir(projectKey, taskId))
+      } catch (error) {
+        if (originalRaw !== null) {
+          try {
+            await writeFileAtomic(batchesPath, originalRaw)
+          } catch (rollbackError) {
+            console.error("删除批量导入任务:回滚批次记录失败", rollbackError)
+          }
+        }
+        throw error
+      }
+    })
+  })
+}
+
 export async function cacheTaskSource(
 export async function cacheTaskSource(
   task: BatchImportTask,
   task: BatchImportTask,
 ): Promise<BatchImportTask> {
 ): Promise<BatchImportTask> {

+ 57 - 2
src/lib/novel/book-analysis/character-extraction-engine.spec.ts

@@ -14,6 +14,18 @@ vi.mock("@/commands/fs", () => ({
   writeFile: vi.fn(async () => undefined),
   writeFile: vi.fn(async () => undefined),
 }))
 }))
 
 
+const { analyzeSixDimensionsMock } = vi.hoisted(() => ({
+  analyzeSixDimensionsMock: vi.fn(async ({ character }: any) => ({ character })),
+}))
+vi.mock("./six-dimension-engine", () => ({
+  analyzeSixDimensions: (input: unknown) => analyzeSixDimensionsMock(input),
+  DEPTH_DESCRIPTIONS: {
+    fast: { label: "快速" },
+    standard: { label: "标准" },
+    deep: { label: "深入" },
+  },
+}))
+
 // mock streamChat:第一组调用 reject,第二组调用 resolve 一个有效 profile
 // mock streamChat:第一组调用 reject,第二组调用 resolve 一个有效 profile
 const streamChatMock = vi.fn()
 const streamChatMock = vi.fn()
 vi.mock("@/lib/llm-client", () => ({
 vi.mock("@/lib/llm-client", () => ({
@@ -47,8 +59,9 @@ vi.mock("./simple-extraction-engine", () => ({
   }),
   }),
 }))
 }))
 
 
-import { extractSingleCharacter } from "./character-extraction-engine"
-import type { ExtractedCharacter } from "./types"
+import { readFile } from "@/commands/fs"
+import { extractCharactersFromChapters, extractSingleCharacter } from "./character-extraction-engine"
+import type { ExtractedCharacter, RecognizedCharacter } from "./types"
 import type { LlmConfig } from "@/stores/wiki-store"
 import type { LlmConfig } from "@/stores/wiki-store"
 
 
 const fakeLlmConfig: LlmConfig = {
 const fakeLlmConfig: LlmConfig = {
@@ -79,6 +92,48 @@ const fakeCharacter: ExtractedCharacter = {
 
 
 beforeEach(() => {
 beforeEach(() => {
   streamChatMock.mockReset()
   streamChatMock.mockReset()
+  analyzeSixDimensionsMock.mockClear()
+})
+
+describe("extractCharactersFromChapters 目标角色约束", () => {
+  it("只深度分析并执行六维分析用户勾选的角色", async () => {
+    vi.mocked(readFile).mockImplementation(async (path: string) => {
+      const order = path.includes("chapter-2") ? 2 : 1
+      return `---\ntitle: 第${order}章\norder: ${order}\n---\n林烬与乌鸦同时出现。`
+    })
+    streamChatMock.mockImplementation(async (_cfg, messages: Array<{ content: string }>, handlers: any) => {
+      expect(messages[0].content).toContain('角色"林烬"')
+      handlers.onToken(JSON.stringify({
+        name: "林烬",
+        category: "protagonist",
+        personality: "冷静",
+      }))
+      handlers.onDone()
+    })
+    const selected: RecognizedCharacter = {
+      id: "char-linjing",
+      name: "林烬",
+      aliases: [],
+      appearances: 2,
+      chapterIndices: [0, 1],
+      importanceScore: 90,
+      category: "主角",
+      sourceBook: "book-1",
+    }
+
+    const result = await extractCharactersFromChapters({
+      bookPath: "E:/Novel/book-analysis/book-1",
+      selectedChapterIds: ["chapter-1", "chapter-2"],
+      llmConfig: fakeLlmConfig,
+      depth: "standard",
+      targetCharacters: [selected],
+    })
+
+    expect(streamChatMock).toHaveBeenCalledTimes(1)
+    expect(analyzeSixDimensionsMock).toHaveBeenCalledTimes(1)
+    expect(analyzeSixDimensionsMock.mock.calls[0][0].character.name).toBe("林烬")
+    expect(result.characters.map((character) => character.name)).toEqual(["林烬"])
+  })
 })
 })
 
 
 describe("extractSingleCharacter (fix/character-reextract-and-loading-state)", () => {
 describe("extractSingleCharacter (fix/character-reextract-and-loading-state)", () => {

+ 56 - 38
src/lib/novel/book-analysis/character-extraction-engine.ts

@@ -31,6 +31,8 @@ export interface CharacterExtractionInput {
   bookTitle?: string
   bookTitle?: string
   /** 作者 */
   /** 作者 */
   bookAuthor?: string
   bookAuthor?: string
+  /** 已在上一步识别并由用户勾选的角色;提供后只分析这些角色,不再重新识别全书角色。 */
+  targetCharacters?: RecognizedCharacter[]
   onProgress?: (progress: {
   onProgress?: (progress: {
     stage: string
     stage: string
     stageLabel: string
     stageLabel: string
@@ -257,59 +259,75 @@ export async function extractCharactersFromChapters(
     throw new Error("未能读取到任何章节内容")
     throw new Error("未能读取到任何章节内容")
   }
   }
 
 
-  // 第二阶段:识别所有角色(逐章分析)
+  // 第二阶段:未指定目标角色时,兼容旧入口并逐章识别所有角色。
   const characterMentions = new Map<string, {
   const characterMentions = new Map<string, {
     count: number
     count: number
     importance: number
     importance: number
     chapters: number[]
     chapters: number[]
   }>()
   }>()
 
 
-  for (let i = 0; i < chapters.length; i++) {
-    if (signal?.aborted) {
-      throw new Error("用户取消分析")
-    }
+  if (input.targetCharacters === undefined) {
+    for (let i = 0; i < chapters.length; i++) {
+      if (signal?.aborted) {
+        throw new Error("用户取消分析")
+      }
 
 
-    const chapter = chapters[i]
+      const chapter = chapters[i]
 
 
-    onProgress?.({
-      stage: "extracting_characters",
-      stageLabel: "识别角色中",
-      completed: i,
-      total: chapters.length * 2,
-      percentage: 30 + Math.floor((i / chapters.length) * 20),
-      currentItem: chapter.title,
-    })
+      onProgress?.({
+        stage: "extracting_characters",
+        stageLabel: "识别角色中",
+        completed: i,
+        total: chapters.length * 2,
+        percentage: 30 + Math.floor((i / chapters.length) * 20),
+        currentItem: chapter.title,
+      })
 
 
-    const identified = await identifyCharactersInChapter(
-      chapter.content,
-      chapter.title,
-      chapter.order,
-      llmConfig,
-      signal
-    )
+      const identified = await identifyCharactersInChapter(
+        chapter.content,
+        chapter.title,
+        chapter.order,
+        llmConfig,
+        signal
+      )
 
 
-    // 汇总角色出现次数
-    for (const char of identified) {
-      const existing = characterMentions.get(char.name)
-      if (existing) {
-        existing.count++
-        existing.importance = Math.max(existing.importance, char.importance)
-        existing.chapters.push(chapter.order)
-      } else {
-        characterMentions.set(char.name, {
-          count: 1,
-          importance: char.importance,
-          chapters: [chapter.order],
-        })
+      // 汇总角色出现次数
+      for (const char of identified) {
+        const existing = characterMentions.get(char.name)
+        if (existing) {
+          existing.count++
+          existing.importance = Math.max(existing.importance, char.importance)
+          existing.chapters.push(chapter.order)
+        } else {
+          characterMentions.set(char.name, {
+            count: 1,
+            importance: char.importance,
+            chapters: [chapter.order],
+          })
+        }
       }
       }
     }
     }
   }
   }
 
 
   // 第三阶段:深度分析重要角色(importance >= 5)
   // 第三阶段:深度分析重要角色(importance >= 5)
-  const importantCharacters = Array.from(characterMentions.entries())
-    .filter(([_, data]) => data.importance >= 5)
-    .sort((a, b) => b[1].importance - a[1].importance)
-    .slice(0, 20) // 最多分析前20个重要角色
+  const importantCharacters: Array<[string, { count: number; importance: number; chapters: number[] }]> =
+    input.targetCharacters !== undefined
+      ? input.targetCharacters.map((character) => {
+          const appearedChapterOrders = character.chapterIndices
+            .map((index) => chapters[index]?.order)
+            .filter((order): order is number => order !== undefined)
+          return [character.name, {
+            count: character.appearances,
+            importance: Math.max(1, Math.min(10, Math.ceil(character.importanceScore / 10))),
+            chapters: appearedChapterOrders.length > 0
+              ? appearedChapterOrders
+              : chapters.map((chapter) => chapter.order),
+          }]
+        })
+      : Array.from(characterMentions.entries())
+          .filter(([_, data]) => data.importance >= 5)
+          .sort((a, b) => b[1].importance - a[1].importance)
+          .slice(0, 20) // 最多分析前20个重要角色
 
 
   const characters: ExtractedCharacter[] = []
   const characters: ExtractedCharacter[] = []
 
 

+ 19 - 0
src/lib/novel/book-analysis/library-store.spec.ts

@@ -30,6 +30,7 @@ import {
   findBookLibraryEntryBySha256,
   findBookLibraryEntryBySha256,
   loadBookLibrary,
   loadBookLibrary,
   removeBookLibraryEntry,
   removeBookLibraryEntry,
+  renameBookLibraryEntry,
   upsertBookLibraryEntry,
   upsertBookLibraryEntry,
 } from "./library-store"
 } from "./library-store"
 
 
@@ -116,6 +117,24 @@ describe("library-store", () => {
     )
     )
   })
   })
 
 
+  it("renameBookLibraryEntry: 同步更新索引和 metadata", async () => {
+    seedLibrary([entry()])
+    const metadataPath = `${projectPath}/book-analysis/book-1/metadata.json`
+    memStore.set(metadataPath, JSON.stringify({ title: "A", updatedAt: 1 }))
+
+    await renameBookLibraryEntry(projectPath, "book-1", "  新名字  ")
+
+    expect((await loadBookLibrary(projectPath)).entries[0].title).toBe("新名字")
+    expect(JSON.parse(memStore.get(metadataPath)!).title).toBe("新名字")
+  })
+
+  it("renameBookLibraryEntry: 拒绝与其他作品重名", async () => {
+    seedLibrary([entry(), entry({ bookId: "book-2", title: "B" })])
+    memStore.set(`${projectPath}/book-analysis/book-1/metadata.json`, JSON.stringify({ title: "A" }))
+
+    await expect(renameBookLibraryEntry(projectPath, "book-1", "B")).rejects.toThrow("作品名称“B”已存在")
+  })
+
   it("removeBookLibraryEntry: 条目不存在时不重写索引", async () => {
   it("removeBookLibraryEntry: 条目不存在时不重写索引", async () => {
     seedLibrary([entry({ bookId: "book-2" })])
     seedLibrary([entry({ bookId: "book-2" })])
 
 

+ 46 - 0
src/lib/novel/book-analysis/library-store.ts

@@ -95,6 +95,52 @@ export async function removeBookLibraryEntry(
     await saveBookLibraryUnlocked(projectPath, { ...library, entries })
     await saveBookLibraryUnlocked(projectPath, { ...library, entries })
   })
   })
 }
 }
+
+export async function renameBookLibraryEntry(
+  projectPath: string,
+  bookId: string,
+  rawTitle: string,
+): Promise<BookLibraryEntry> {
+  const title = rawTitle.trim()
+  if (!title) throw new Error("作品名称不能为空")
+
+  return withProjectLock(normalizePath(projectPath), async () => {
+    const library = await loadBookLibraryStrictUnlocked(projectPath)
+    const index = library.entries.findIndex((entry) => entry.bookId === bookId)
+    if (index < 0) throw new Error("作品库中找不到该作品")
+    if (library.entries.some((entry) => entry.bookId !== bookId && entry.title === title)) {
+      throw new Error(`作品名称“${title}”已存在`)
+    }
+
+    const current = library.entries[index]
+    if (current.title === title) return current
+
+    const metadataPath = joinPath(projectPath, "book-analysis", bookId, "metadata.json")
+    const metadataRaw = await readFile(metadataPath)
+    const metadata = JSON.parse(metadataRaw) as unknown
+    if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) {
+      throw new Error("作品元数据无效")
+    }
+
+    const now = Date.now()
+    const renamedEntry = { ...current, title, updatedAt: now }
+    const renamedMetadata = { ...metadata, title, updatedAt: now }
+    await writeFileAtomic(metadataPath, JSON.stringify(renamedMetadata, null, 2))
+    try {
+      const entries = [...library.entries]
+      entries[index] = renamedEntry
+      await saveBookLibraryUnlocked(projectPath, { ...library, entries })
+    } catch (error) {
+      try {
+        await writeFileAtomic(metadataPath, metadataRaw)
+      } catch (rollbackError) {
+        console.error("作品重命名:回滚 metadata 失败", rollbackError)
+      }
+      throw error
+    }
+    return renamedEntry
+  })
+}
 export async function findBookLibraryEntry(
 export async function findBookLibraryEntry(
   projectPath: string,
   projectPath: string,
   sourcePath: string,
   sourcePath: string,

+ 82 - 0
src/lib/novel/deep-chapter-generation.spec.ts

@@ -155,6 +155,88 @@ function createLegacyPlanComplianceDeps(reviewResults: NovelReviewResult[] = [])
 }
 }
 
 
 describe("runDeepChapterGeneration", () => {
 describe("runDeepChapterGeneration", () => {
+  it("routes workflow, prose, and de-AI stages to their configured models", async () => {
+    const previousState = useWikiStore.getState()
+    useWikiStore.setState({
+      aiChatModel: "custom/writer-model",
+      defaultLlmModel: "custom/workflow-model",
+      providerConfigs: {
+        custom: {
+          enabled: true,
+          apiKey: "test-key",
+          savedModels: [
+            { id: "writer", name: "Writer", model: "writer-model", createdAt: 1 },
+            { id: "workflow", name: "Workflow", model: "workflow-model", createdAt: 2 },
+            { id: "review", name: "Review", model: "review-model", createdAt: 3 },
+            { id: "de-ai", name: "De-AI", model: "de-ai-model", createdAt: 4 },
+          ],
+        },
+      },
+      novelConfig: {
+        ...previousState.novelConfig,
+        defaultLlmModel: "custom/workflow-model",
+        reviewModel: "custom/review-model",
+        deAiModel: "custom/de-ai-model",
+        deepPreviousChaptersAnalysis: false,
+      },
+    })
+
+    try {
+      const deps = createDeps([{
+        severity: "error",
+        type: "plot",
+        message: "需要返修",
+        evidence: "正文",
+        relatedMemory: "大纲",
+        suggestion: "修正偏差",
+      }])
+      const calledModels: string[] = []
+      vi.mocked(deps.streamChat).mockImplementation(async (
+        config: LlmConfig,
+        messages: ChatMessage[],
+        callbacks: StreamCallbacks,
+      ) => {
+        calledModels.push(config.model)
+        const prompt = messagesPromptText(messages)
+        const content = prompt.includes("简单审查") || prompt.includes("去AI味")
+          ? chapterText("最终去AI味正文")
+          : prompt.includes("返修")
+            ? chapterText("返修正文内容")
+            : prompt.includes("正文")
+              ? chapterText("初稿正文内容")
+              : "写作任务书内容"
+        callbacks.onToken(content)
+        callbacks.onDone()
+      })
+
+      await runDeepChapterGeneration(
+        {
+          projectPath: "E:/Novel",
+          userRequest: "生成第三章",
+          chapterNumber: 3,
+          llmConfig: { ...llmConfig, model: "writer-model" },
+          aiWorkflowMode: "strict",
+        },
+        {},
+        deps,
+      )
+
+      expect(calledModels).toEqual([
+        "workflow-model",
+        "writer-model",
+        "writer-model",
+        "de-ai-model",
+      ])
+    } finally {
+      useWikiStore.setState({
+        aiChatModel: previousState.aiChatModel,
+        defaultLlmModel: previousState.defaultLlmModel,
+        providerConfigs: previousState.providerConfigs,
+        novelConfig: previousState.novelConfig,
+      })
+    }
+  })
+
   it("uses the confirmed plan when retrieving context and selecting the de-AI skill", async () => {
   it("uses the confirmed plan when retrieving context and selecting the de-AI skill", async () => {
     const deps = createDeps()
     const deps = createDeps()
     const planBlueprint = "确认计划:新角色顾舟在北塔登场,并带出铜钥匙。"
     const planBlueprint = "确认计划:新角色顾舟在北塔登场,并带出铜钥匙。"

+ 9 - 8
src/lib/novel/deep-chapter-generation.ts

@@ -20,7 +20,7 @@ import {
   contextPackToPrompt,
   contextPackToPrompt,
   type ContextPack,
   type ContextPack,
 } from "./context-engine";
 } from "./context-engine";
-import { resolveNovelModel } from "./model-resolver";
+import { resolveDefaultModel, resolveNovelModel } from "./model-resolver";
 import { reviewChapter, type NovelReviewResult } from "./review-adapter";
 import { reviewChapter, type NovelReviewResult } from "./review-adapter";
 import type { TaskRouteResult } from "./task-router";
 import type { TaskRouteResult } from "./task-router";
 import type { GoldenThreeChapterRequest } from "./golden-three-chapters";
 import type { GoldenThreeChapterRequest } from "./golden-three-chapters";
@@ -481,6 +481,7 @@ export async function runDeepChapterGeneration(
   const resumeCheckpoint = input.resumeCheckpoint;
   const resumeCheckpoint = input.resumeCheckpoint;
   const novelConfig = useWikiStore.getState().novelConfig;
   const novelConfig = useWikiStore.getState().novelConfig;
   const writingConfig = resolveWritingConfig(input.llmConfig);
   const writingConfig = resolveWritingConfig(input.llmConfig);
+  const workflowConfig = resolveDefaultModel(input.llmConfig);
   const deAiConfig = resolveNovelModel(input.llmConfig, novelConfig, "deAi");
   const deAiConfig = resolveNovelModel(input.llmConfig, novelConfig, "deAi");
   const workflowProfile = resolveChapterWorkflowProfile(input.aiWorkflowMode);
   const workflowProfile = resolveChapterWorkflowProfile(input.aiWorkflowMode);
   const lengthSpec = resolveCurrentChapterLengthSpec();
   const lengthSpec = resolveCurrentChapterLengthSpec();
@@ -505,7 +506,7 @@ export async function runDeepChapterGeneration(
   if (!executionContract && workflowProfile.runExecutionContractBuild && planBlueprint) {
   if (!executionContract && workflowProfile.runExecutionContractBuild && planBlueprint) {
     try {
     try {
       const buildContract = deps.runChapterExecutionContractBuild || runChapterExecutionContractBuild;
       const buildContract = deps.runChapterExecutionContractBuild || runChapterExecutionContractBuild;
-      executionContract = await buildContract(writingConfig, planBlueprint, signal);
+      executionContract = await buildContract(workflowConfig, planBlueprint, signal);
     } catch (error) {
     } catch (error) {
       rethrowIfUserAbort(error, signal);
       rethrowIfUserAbort(error, signal);
       console.warn("[Deep Chapter] 执行清单生成失败,使用本地兜底解析:", error);
       console.warn("[Deep Chapter] 执行清单生成失败,使用本地兜底解析:", error);
@@ -556,7 +557,7 @@ export async function runDeepChapterGeneration(
       previousChaptersAnalysis = await analyzePreviousChapters(
       previousChaptersAnalysis = await analyzePreviousChapters(
         input.projectPath,
         input.projectPath,
         input.chapterNumber,
         input.chapterNumber,
-        writingConfig,
+        workflowConfig,
         3,
         3,
         signal,
         signal,
       );
       );
@@ -760,7 +761,7 @@ export async function runDeepChapterGeneration(
       },
       },
       () =>
       () =>
         collectModelText(
         collectModelText(
-          writingConfig,
+          workflowConfig,
           [
           [
             {
             {
               role: "user",
               role: "user",
@@ -1393,7 +1394,7 @@ export async function runDeepChapterGeneration(
           detail: "按场景验收标准逐项检查最终正文。",
           detail: "按场景验收标准逐项检查最终正文。",
           params: workflowBaseParams,
           params: workflowBaseParams,
         },
         },
-        () => runReport(writingConfig, executionContract!, finalContent, signal),
+        () => runReport(workflowConfig, executionContract!, finalContent, signal),
         (value) => executionReportToToolSummary(value),
         (value) => executionReportToToolSummary(value),
         (value) => ({
         (value) => ({
           status: value.status,
           status: value.status,
@@ -1446,7 +1447,7 @@ export async function runDeepChapterGeneration(
                 detail: "返修后再次检查执行清单失败项是否消除。",
                 detail: "返修后再次检查执行清单失败项是否消除。",
                 params: workflowBaseParams,
                 params: workflowBaseParams,
               },
               },
-              () => runReport(writingConfig, executionContract!, repairedCandidate, signal),
+              () => runReport(workflowConfig, executionContract!, repairedCandidate, signal),
               (value) => executionReportToToolSummary(value),
               (value) => executionReportToToolSummary(value),
               (value) => ({
               (value) => ({
                 status: value.status,
                 status: value.status,
@@ -1514,7 +1515,7 @@ export async function runDeepChapterGeneration(
       planCompliance = await runChapterWorkflowStep(
       planCompliance = await runChapterWorkflowStep(
         callbacks,
         callbacks,
         complianceStep,
         complianceStep,
-        () => runCompliance(writingConfig, planExecutionSummary, finalContent, signal),
+        () => runCompliance(workflowConfig, planExecutionSummary, finalContent, signal),
         (value) => value ? "计划履约度检查完成。" : "计划履约度检查完成,未返回具体结果。",
         (value) => value ? "计划履约度检查完成。" : "计划履约度检查完成,未返回具体结果。",
         (value) => ({ hasComplianceResult: Boolean(value?.trim()) }),
         (value) => ({ hasComplianceResult: Boolean(value?.trim()) }),
       );
       );
@@ -1573,7 +1574,7 @@ export async function runDeepChapterGeneration(
                 callbacks,
                 callbacks,
                 recheckStep,
                 recheckStep,
                 () => runCompliance(
                 () => runCompliance(
-                  writingConfig,
+                  workflowConfig,
                   planExecutionSummary,
                   planExecutionSummary,
                   repairedCandidate,
                   repairedCandidate,
                   signal,
                   signal,

+ 65 - 0
src/stores/book-analysis-import-store.spec.ts

@@ -22,9 +22,11 @@ const mocks = vi.hoisted(() => {
     loadBatchImportBatches: vi.fn(),
     loadBatchImportBatches: vi.fn(),
     saveBatchImportTask: vi.fn(),
     saveBatchImportTask: vi.fn(),
     saveBatchImportBatch: vi.fn(),
     saveBatchImportBatch: vi.fn(),
+    deleteFailedBatchImportTask: vi.fn(),
     cacheTaskSource: vi.fn(),
     cacheTaskSource: vi.fn(),
     loadBookLibrary: vi.fn(),
     loadBookLibrary: vi.fn(),
     findBookLibraryEntryBySha256: vi.fn(),
     findBookLibraryEntryBySha256: vi.fn(),
+    renameBookLibraryEntry: vi.fn(),
     fileExists: vi.fn(),
     fileExists: vi.fn(),
     readFile: vi.fn(),
     readFile: vi.fn(),
   }
   }
@@ -39,6 +41,8 @@ vi.mock("@/lib/novel/book-analysis/batch-import-scheduler", () => ({
       regenerateTask: vi.fn().mockResolvedValue(undefined),
       regenerateTask: vi.fn().mockResolvedValue(undefined),
       cancelTask: vi.fn().mockResolvedValue(undefined),
       cancelTask: vi.fn().mockResolvedValue(undefined),
       cancelAllQueued: vi.fn().mockResolvedValue(undefined),
       cancelAllQueued: vi.fn().mockResolvedValue(undefined),
+      syncTerminalTask: vi.fn(),
+      forgetTerminalTask: vi.fn(),
       subscribe: vi.fn((nextListener: (tasks: BatchImportTask[]) => void) => {
       subscribe: vi.fn((nextListener: (tasks: BatchImportTask[]) => void) => {
         listener = nextListener
         listener = nextListener
         nextListener([])
         nextListener([])
@@ -58,12 +62,14 @@ vi.mock("@/lib/novel/book-analysis/batch-import-storage", () => ({
   loadBatchImportBatches: mocks.loadBatchImportBatches,
   loadBatchImportBatches: mocks.loadBatchImportBatches,
   saveBatchImportTask: mocks.saveBatchImportTask,
   saveBatchImportTask: mocks.saveBatchImportTask,
   saveBatchImportBatch: mocks.saveBatchImportBatch,
   saveBatchImportBatch: mocks.saveBatchImportBatch,
+  deleteFailedBatchImportTask: mocks.deleteFailedBatchImportTask,
   cacheTaskSource: mocks.cacheTaskSource,
   cacheTaskSource: mocks.cacheTaskSource,
 }))
 }))
 
 
 vi.mock("@/lib/novel/book-analysis/library-store", () => ({
 vi.mock("@/lib/novel/book-analysis/library-store", () => ({
   loadBookLibrary: mocks.loadBookLibrary,
   loadBookLibrary: mocks.loadBookLibrary,
   findBookLibraryEntryBySha256: mocks.findBookLibraryEntryBySha256,
   findBookLibraryEntryBySha256: mocks.findBookLibraryEntryBySha256,
+  renameBookLibraryEntry: mocks.renameBookLibraryEntry,
 }))
 }))
 
 
 vi.mock("@/commands/fs", () => ({
 vi.mock("@/commands/fs", () => ({
@@ -131,8 +137,10 @@ describe("book analysis import store", () => {
     mocks.loadBatchImportBatches.mockResolvedValue([])
     mocks.loadBatchImportBatches.mockResolvedValue([])
     mocks.saveBatchImportTask.mockResolvedValue(undefined)
     mocks.saveBatchImportTask.mockResolvedValue(undefined)
     mocks.saveBatchImportBatch.mockResolvedValue(undefined)
     mocks.saveBatchImportBatch.mockResolvedValue(undefined)
+    mocks.deleteFailedBatchImportTask.mockResolvedValue(undefined)
     mocks.loadBookLibrary.mockResolvedValue({ version: 1, entries: [] })
     mocks.loadBookLibrary.mockResolvedValue({ version: 1, entries: [] })
     mocks.findBookLibraryEntryBySha256.mockResolvedValue(undefined)
     mocks.findBookLibraryEntryBySha256.mockResolvedValue(undefined)
+    mocks.renameBookLibraryEntry.mockImplementation(async (_path: string, bookId: string, title: string) => ({ bookId, title }))
     mocks.fileExists.mockResolvedValue(false)
     mocks.fileExists.mockResolvedValue(false)
     mocks.readFile.mockResolvedValue("[]")
     mocks.readFile.mockResolvedValue("[]")
     mocks.cacheTaskSource.mockImplementation(async (task: BatchImportTask) => ({
     mocks.cacheTaskSource.mockImplementation(async (task: BatchImportTask) => ({
@@ -644,4 +652,61 @@ describe("book analysis import store", () => {
     expect(store.getState().tasks[0].error).toContain("缓存源文件失败")
     expect(store.getState().tasks[0].error).toContain("缓存源文件失败")
     expect(mocks.schedulers[0].enqueue).toHaveBeenCalledWith([store.getState().tasks[1]])
     expect(mocks.schedulers[0].enqueue).toHaveBeenCalledWith([store.getState().tasks[1]])
   })
   })
+
+  it("删除失败任务后同步清理内存批次并让调度器忘记任务", async () => {
+    const failed = makeTask("failed-task", { status: "failed", error: "导入失败", completedAt: 200 })
+    const batch = { ...persistedBatch(PROJECT_A), id: failed.batchId, taskIds: [failed.id] }
+    mocks.loadBatchImportTasks.mockResolvedValue([failed])
+    mocks.loadBatchImportBatches.mockResolvedValue([batch])
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+
+    await store.getState().deleteFailedTask(failed.id)
+
+    expect(mocks.deleteFailedBatchImportTask).toHaveBeenCalledWith(PROJECT_A, failed.id)
+    expect(mocks.schedulers[0].forgetTerminalTask).toHaveBeenCalledWith(failed.id)
+    expect(store.getState().tasks).toEqual([])
+    expect(store.getState().batches).toEqual([])
+  })
+
+  it("重命名已完成任务时同步作品名称、任务记录并增加 revision", async () => {
+    const completed = makeTask("completed-task", {
+      status: "completed",
+      finalTitle: "旧名字",
+      completedAt: 200,
+    })
+    mocks.loadBatchImportTasks.mockResolvedValue([completed])
+    const onRevision = vi.fn()
+    const store = createBookAnalysisImportStore({ onRevision })
+    await store.getState().initializeProject(PROJECT_A)
+
+    await store.getState().renameCompletedTask(completed.id, "  新名字  ")
+
+    expect(mocks.renameBookLibraryEntry).toHaveBeenCalledWith(PROJECT_A, completed.bookId, "新名字")
+    expect(mocks.saveBatchImportTask).toHaveBeenCalledWith(expect.objectContaining({
+      id: completed.id,
+      finalTitle: "新名字",
+    }))
+    expect(mocks.schedulers[0].syncTerminalTask).toHaveBeenCalledWith(expect.objectContaining({ finalTitle: "新名字" }))
+    expect(store.getState().tasks[0].finalTitle).toBe("新名字")
+    expect(store.getState().revision).toBe(1)
+    expect(onRevision).toHaveBeenCalledOnce()
+  })
+
+  it("任务记录重命名保存失败时回滚作品名称", async () => {
+    const completed = makeTask("completed-task", {
+      status: "completed",
+      finalTitle: "旧名字",
+      completedAt: 200,
+    })
+    mocks.loadBatchImportTasks.mockResolvedValue([completed])
+    const store = createBookAnalysisImportStore()
+    await store.getState().initializeProject(PROJECT_A)
+    mocks.saveBatchImportTask.mockRejectedValueOnce(new Error("任务保存失败"))
+
+    await expect(store.getState().renameCompletedTask(completed.id, "新名字")).rejects.toThrow("任务保存失败")
+
+    expect(mocks.renameBookLibraryEntry.mock.calls.map(([, , title]) => title)).toEqual(["新名字", "旧名字"])
+    expect(store.getState().tasks[0].finalTitle).toBe("旧名字")
+  })
 })
 })

+ 58 - 0
src/stores/book-analysis-import-store.ts

@@ -7,6 +7,7 @@ import {
 import { reserveUniqueTitle } from "@/lib/novel/book-analysis/batch-import-hash"
 import { reserveUniqueTitle } from "@/lib/novel/book-analysis/batch-import-hash"
 import {
 import {
   cacheTaskSource,
   cacheTaskSource,
+  deleteFailedBatchImportTask,
   loadBatchImportBatches,
   loadBatchImportBatches,
   loadBatchImportTasks,
   loadBatchImportTasks,
   saveBatchImportBatch,
   saveBatchImportBatch,
@@ -20,6 +21,7 @@ import type {
 import {
 import {
   findBookLibraryEntryBySha256,
   findBookLibraryEntryBySha256,
   loadBookLibrary,
   loadBookLibrary,
+  renameBookLibraryEntry,
 } from "@/lib/novel/book-analysis/library-store"
 } from "@/lib/novel/book-analysis/library-store"
 
 
 const OPEN_PANEL_STATUSES = new Set(["queued", "copying", "splitting", "interrupted", "failed"])
 const OPEN_PANEL_STATUSES = new Set(["queued", "copying", "splitting", "interrupted", "failed"])
@@ -81,6 +83,8 @@ export interface BookAnalysisImportState {
   regenerateTask(taskId: string): Promise<void>
   regenerateTask(taskId: string): Promise<void>
   cancelTask(taskId: string): Promise<void>
   cancelTask(taskId: string): Promise<void>
   cancelAllQueued(batchId: string): Promise<void>
   cancelAllQueued(batchId: string): Promise<void>
+  deleteFailedTask(taskId: string): Promise<void>
+  renameCompletedTask(taskId: string, title: string): Promise<void>
   setPanelCollapsed(collapsed: boolean): void
   setPanelCollapsed(collapsed: boolean): void
   dispose(): Promise<void>
   dispose(): Promise<void>
 }
 }
@@ -439,6 +443,60 @@ export function createBookAnalysisImportStore(options: { onRevision?: () => void
         }
         }
       },
       },
 
 
+      deleteFailedTask: async (taskId) => {
+        const current = scheduler
+        const projectPath = get().projectPath
+        const token = generation
+        if (!current || !projectPath) throw new Error("请先初始化拆书项目")
+        const task = get().tasks.find((item) => item.id === taskId)
+        if (!task) throw new Error("找不到导入任务")
+        if (task.status !== "failed") throw new Error("只能删除导入失败的任务")
+        await deleteFailedBatchImportTask(projectPath, taskId)
+        if (!isCurrent(token, projectPath, current)) return
+        schedulerTaskIds.delete(taskId)
+        current.forgetTerminalTask(taskId)
+        set((state) => ({
+          tasks: state.tasks.filter((item) => item.id !== taskId),
+          batches: state.batches
+            .map((batch) => ({ ...batch, taskIds: batch.taskIds.filter((id) => id !== taskId) }))
+            .filter((batch) => batch.taskIds.length > 0),
+        }))
+      },
+
+      renameCompletedTask: async (taskId, rawTitle) => {
+        const current = scheduler
+        const projectPath = get().projectPath
+        const token = generation
+        if (!current || !projectPath) throw new Error("请先初始化拆书项目")
+        const task = get().tasks.find((item) => item.id === taskId)
+        if (!task) throw new Error("找不到导入任务")
+        if (task.status !== "completed") throw new Error("只能重命名已完成的任务")
+        const title = rawTitle.trim()
+        if (!title) throw new Error("作品名称不能为空")
+        const oldTitle = task.finalTitle || task.requestedTitle
+        if (title === oldTitle) return
+
+        await renameBookLibraryEntry(projectPath, task.bookId, title)
+        const renamed = { ...task, finalTitle: title, updatedAt: Date.now() }
+        try {
+          await saveBatchImportTask(renamed)
+        } catch (error) {
+          try {
+            await renameBookLibraryEntry(projectPath, task.bookId, oldTitle)
+          } catch (rollbackError) {
+            console.error("批量导入任务重命名:回滚作品名称失败", rollbackError)
+          }
+          throw error
+        }
+        if (!isCurrent(token, projectPath, current)) return
+        current.syncTerminalTask(renamed)
+        set((state) => ({
+          tasks: state.tasks.map((item) => item.id === taskId ? renamed : item),
+          revision: state.revision + 1,
+        }))
+        options.onRevision?.()
+      },
+
       setPanelCollapsed: (panelCollapsed) => set({ panelCollapsed, panelTouched: true }),
       setPanelCollapsed: (panelCollapsed) => set({ panelCollapsed, panelTouched: true }),
       dispose: () => {
       dispose: () => {
         generation += 1
         generation += 1