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

Merge remote-tracking branch 'origin/main' (PR #26)

Mochocyang 2 hónapja
szülő
commit
680e7e739b

+ 13 - 0
src/components/chat/chat-message.spec.tsx

@@ -42,6 +42,19 @@ describe("chat thinking display", () => {
     expect(html).toContain("max-h-")
     expect(html).toContain("overflow-y-auto")
   })
+
+  it("labels fragmented model reasoning as 思考过程 instead of hundreds of workflow stages", () => {
+    const fragmentedReasoning = ["好的,", "用户", "问", "的是", "当前", "情节", "中", "的人物"]
+      .join("\n\n")
+    const html = renderToStaticMarkup(
+      <StreamingMessage content={`<think>\n${fragmentedReasoning}\n</think>\n\n回答正文`} />,
+    )
+
+    expect(html).toContain("思考过程")
+    expect(html).not.toContain("工作流阶段")
+    expect(html).not.toContain("个阶段")
+    expect(html).toContain("好的,用户问的是当前情节中的人物")
+  })
 })
 
 describe("AI workflow mode toggle style", () => {

+ 47 - 31
src/components/chat/chat-message.tsx

@@ -133,7 +133,7 @@ export function ChatMessage({
       </div>
       <div className={`${isUser ? "w-fit max-w-full lg:max-w-[50vw]" : "min-w-0 flex-1 max-w-full"} flex flex-col gap-1.5`}>
         <div
-          className={`rounded-lg px-3 py-2 text-sm ${
+          className={`w-full min-w-0 rounded-lg px-3 py-2 text-sm ${
             isUser
               ? "bg-primary text-primary-foreground"
               : message.discarded
@@ -800,7 +800,7 @@ function MarkdownContent({ content }: { content: string }) {
   const htmlLang = getHtmlLang(renderLanguage);
 
   return (
-    <div>
+    <div className="w-full min-w-0">
       {thinking && <WorkflowBlock content={thinking} />}
       <div
         className="chat-markdown prose prose-sm max-w-none dark:prose-invert prose-p:my-1 prose-headings:my-2 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-code:text-xs prose-code:before:content-none prose-code:after:content-none"
@@ -1065,27 +1065,49 @@ function separateThinking(text: string): {
   return { thinking: filteredThinking, answer: answer.trim() };
 }
 
+function countWorkflowStages(content: string): number {
+  return content.split("\n").filter((line) => isWorkflowStageHeader(line)).length
+}
+
+function getThinkingBlockMeta(content: string, streaming: boolean): { title: string; stageCount: number | null } {
+  const stageCount = countWorkflowStages(content)
+  if (stageCount > 0) {
+    return {
+      title: streaming ? "工作流进行中..." : "工作流阶段",
+      stageCount,
+    }
+  }
+  return {
+    title: streaming ? "思考中..." : "思考过程",
+    stageCount: null,
+  }
+}
+
+/** 模型 reasoning 流式输出常带大量换行;合并为连续文本,避免被误切成上百个“阶段”。 */
+function formatThinkingForDisplay(content: string): string {
+  const trimmed = content.trim()
+  if (!trimmed) return ""
+
+  if (countWorkflowStages(trimmed) > 0) {
+    return trimmed.replace(/\n{3,}/g, "\n\n")
+  }
+
+  return trimmed.replace(/\s*\n+\s*/g, "")
+}
+
 /** Streaming workflow: show stages as they come in so user can see progress. */
 function StreamingWorkflowBlock({ content }: { content: string }) {
-  const paragraphs = content
-    .split(/\n\s*\n/)
-    .map((p) => p.replace(/\n/g, " ").trim())
-    .filter((p) => p.length > 0);
+  const displayContent = useMemo(() => formatThinkingForDisplay(content), [content])
+  const { title } = useMemo(() => getThinkingBlockMeta(content, true), [content])
 
   return (
-    <div className="rounded-md border border-dashed border-blue-500/30 bg-blue-50/50 dark:bg-blue-950/20 px-2.5 py-2 min-h-[3rem]">
+    <div className="w-full min-w-0 rounded-md border border-dashed border-blue-500/30 bg-blue-50/50 dark:bg-blue-950/20 px-2.5 py-2 min-h-[3rem]">
       <div className="flex items-center gap-1.5 mb-1.5">
         <span className="text-sm animate-pulse">📋</span>
-        <span className="text-xs font-medium text-blue-700 dark:text-blue-400">
-          工作流进行中...
-        </span>
+        <span className="text-xs font-medium text-blue-700 dark:text-blue-400">{title}</span>
       </div>
-      <div className="max-h-72 overflow-y-auto pr-1 text-xs text-blue-800/70 dark:text-blue-300/60 leading-relaxed whitespace-pre-wrap break-words">
-        {paragraphs.map((p, i) => (
-          <div key={`p-${i}`} className={i > 0 ? "mt-1.5" : ""}>
-            {p}
-          </div>
-        ))}
+      <div className="w-full min-w-0 max-h-72 overflow-y-auto overflow-x-hidden pr-1 text-xs text-blue-800/70 dark:text-blue-300/60 leading-relaxed whitespace-pre-wrap [overflow-wrap:anywhere]">
+        {displayContent}
         <span className="animate-pulse text-blue-500">▊</span>
       </div>
     </div>
@@ -1094,26 +1116,20 @@ function StreamingWorkflowBlock({ content }: { content: string }) {
 
 /** Completed workflow stages: keep visible so user can review what happened. */
 function WorkflowBlock({ content }: { content: string }) {
-  const paragraphs = content
-    .split(/\n\s*\n/)
-    .map((p) => p.replace(/\n/g, " ").trim())
-    .filter((p) => p.length > 0);
+  const displayContent = useMemo(() => formatThinkingForDisplay(content), [content])
+  const { title, stageCount } = useMemo(() => getThinkingBlockMeta(content, false), [content])
 
   return (
-    <div className="mb-2 rounded-md border border-dashed border-blue-500/30 bg-blue-50/50 dark:bg-blue-950/20 min-h-[3rem]">
+    <div className="mb-2 w-full min-w-0 rounded-md border border-dashed border-blue-500/30 bg-blue-50/50 dark:bg-blue-950/20 min-h-[3rem]">
       <div className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-xs text-blue-700 dark:text-blue-400">
         <span className="text-sm">📋</span>
-        <span className="font-medium">工作流阶段</span>
-        <span className="text-[10px] text-blue-600/60 dark:text-blue-500/60">
-          {paragraphs.length} 个阶段
-        </span>
+        <span className="font-medium">{title}</span>
+        {stageCount !== null && (
+          <span className="text-[10px] text-blue-600/60 dark:text-blue-500/60">{stageCount} 个阶段</span>
+        )}
       </div>
-      <div className="max-h-72 overflow-y-auto border-t border-blue-500/20 px-2.5 py-2 pr-1 text-xs text-blue-800/80 dark:text-blue-300/70 whitespace-pre-wrap break-words leading-relaxed">
-        {paragraphs.map((p, i) => (
-          <div key={`p-${i}`} className={i > 0 ? "mt-1.5" : ""}>
-            {p}
-          </div>
-        ))}
+      <div className="w-full min-w-0 max-h-72 overflow-y-auto overflow-x-hidden border-t border-blue-500/20 px-2.5 py-2 pr-1 text-xs text-blue-800/80 dark:text-blue-300/70 whitespace-pre-wrap leading-relaxed [overflow-wrap:anywhere]">
+        {displayContent}
       </div>
     </div>
   );

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

@@ -1622,6 +1622,10 @@ export function ChatPanel() {
       ))
     abortControllersRef.current[convId]?.abort()
     delete abortControllersRef.current[convId]
+    const finalizeStopped = () => {
+      finalizeStream(`${currentStreamingContent ? `${currentStreamingContent}\n\n` : ""}已停止生成。`, [], convId)
+      delete activeStreamSessionsRef.current[convId]
+    }
     if (sessionId !== undefined) {
       streamSessionGuardRef.current.stop(convId, sessionId, () => {
         if (runningAssistant) {
@@ -1634,10 +1638,12 @@ export function ChatPanel() {
           }))
           clearStreaming(convId)
         } else {
-          finalizeStream(`${currentStreamingContent ? `${currentStreamingContent}\n\n` : ""}已停止生成。`, [], convId)
+          finalizeStopped()
         }
         delete activeStreamSessionsRef.current[convId]
       })
+    } else if (currentStreamingContent !== undefined) {
+      finalizeStopped()
     }
   }, [clearStreaming, finalizeStream])
 

+ 115 - 7
src/components/layout/knowledge-tree.tsx

@@ -1,5 +1,5 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from "react"
-import { BookOpen, ChevronDown, ChevronRight, FileText, Folder, FolderInput, FolderOpen, Globe, Loader2, MessageCircle, Pencil, Plus, Trash2, Check, X } from "lucide-react"
+import { BookOpen, ChevronDown, ChevronRight, FileText, Folder, FolderInput, FolderOpen, Globe, Loader2, MessageCircle, Pencil, Plus, Sparkles, Trash2, Check, X } from "lucide-react"
 import { useTranslation } from "react-i18next"
 import { ScrollArea } from "@/components/ui/scroll-area"
 import { Button } from "@/components/ui/button"
@@ -12,10 +12,36 @@ import { countChapterBodyWords } from "@/lib/chapter-word-count"
 import { normalizeChapterStatus, type ChapterStatus } from "@/lib/novel/chapter-meta"
 import { moveFileToTrash } from "@/lib/trash"
 import { makeChapterFileName, makeDefaultChapterTitle, makeSafeFileSlug } from "@/lib/wiki-filename"
-import { useImportProgressStore } from "@/stores/import-progress-store"
+import { useImportProgressStore, type ImportProgressTask } from "@/stores/import-progress-store"
+import { useOutlineGenerationStore } from "@/stores/outline-generation-store"
+import { startOutlineIngestTask } from "@/lib/novel/outline-generation"
+import { getOutlineFileName, outlineSnapshotExists } from "@/lib/novel/outline-ingest-utils"
 import { saveLastReadChapter } from "@/lib/project-store"
 import type { ReferenceToken } from "@/lib/reference/types"
 
+function formatImportProgressRunningLabel(task: ImportProgressTask, kindLabel: string): string {
+  if (task.cancelling) return `正在取消${kindLabel}提取...`
+
+  const activeTitles = task.activeTitles?.filter(Boolean) ?? []
+
+  if (activeTitles.length > 1) {
+    const preview = activeTitles.slice(0, 2).join("、")
+    return activeTitles.length > 2 ? `${preview} 等${activeTitles.length}个` : preview
+  }
+
+  if (activeTitles.length === 1) return activeTitles[0]!
+  return task.currentTitle || `${kindLabel}提取中`
+}
+
+function formatImportProgressDetail(task: ImportProgressTask): string {
+  const base = `${task.completed}/${task.total}`
+  const activeCount = task.activeTitles?.length ?? 0
+  if ((task.concurrency ?? 1) > 1 && activeCount > 1) {
+    return `${base} · ${activeCount} 路并行`
+  }
+  return base
+}
+
 interface WikiPageInfo {
   path: string
   title: string
@@ -283,13 +309,17 @@ export function KnowledgeTree({
 }: KnowledgeTreeProps) {
   const { t } = useTranslation()
   const project = useWikiStore((s) => s.project)
+  const novelMode = useWikiStore((s) => s.novelMode)
   const selectedFile = useWikiStore((s) => s.selectedFile)
   const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
   const fileTree = useWikiStore((s) => s.fileTree)
   const setFileTree = useWikiStore((s) => s.setFileTree)
   const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion)
   const dataVersion = useWikiStore((s) => s.dataVersion)
+  const outlineTasks = useOutlineGenerationStore((s) => s.tasks)
+  const outlineImportTasks = useImportProgressStore((s) => s.tasks)
   const [pages, setPages] = useState<WikiPageInfo[]>([])
+  const [extractedOutlinePaths, setExtractedOutlinePaths] = useState<Set<string>>(() => new Set())
   const [collapsedFolders, setCollapsedFolders] = useState<Record<string, boolean>>({})
   const [armedPath, setArmedPath] = useState<string | null>(null)
   const [deletingPath, setDeletingPath] = useState<string | null>(null)
@@ -391,6 +421,59 @@ export function KnowledgeTree({
 
   const effectivePages = useMemo(() => [...pageInfoByPath.values()], [pageInfoByPath])
 
+  const outlinePages = useMemo(
+    () => effectivePages.filter((page): page is WikiPageInfo & { type: "outline" } => page.type === "outline"),
+    [effectivePages],
+  )
+
+  useEffect(() => {
+    if (!project || filterType !== "outline" || !novelMode) {
+      setExtractedOutlinePaths(new Set())
+      return
+    }
+    let cancelled = false
+    void (async () => {
+      const extracted = new Set<string>()
+      await Promise.all(outlinePages.map(async (page) => {
+        if (await outlineSnapshotExists(project.path, page.path)) {
+          extracted.add(normalizePath(page.path))
+        }
+      }))
+      if (!cancelled) setExtractedOutlinePaths(extracted)
+    })()
+    return () => {
+      cancelled = true
+    }
+  }, [filterType, novelMode, outlinePages, project, dataVersion, outlineTasks, outlineImportTasks])
+
+  const isOutlinePathIngesting = useCallback((outlinePath: string) => {
+    if (!project) return false
+    const normalizedPath = normalizePath(outlinePath)
+    const fileName = getOutlineFileName(normalizedPath)
+    const pp = normalizePath(project.path)
+    const importRunning = outlineImportTasks.find((task) => (
+      task.projectPath === pp &&
+      task.kind === "outline" &&
+      task.status === "running"
+    ))
+    if (importRunning) {
+      if (importRunning.total === 1 && importRunning.currentTitle === fileName) return true
+      if (importRunning.activeTitles?.includes(fileName)) return true
+    }
+    return outlineTasks.some((task) => (
+      task.projectPath === pp &&
+      task.kind === "ingest" &&
+      task.outlinePath != null &&
+      normalizePath(task.outlinePath) === normalizedPath &&
+      task.status === "ingesting"
+    ))
+  }, [outlineImportTasks, outlineTasks, project])
+
+  const handleOutlineIngest = useCallback((outlinePath: string) => {
+    if (!project || !novelMode || isOutlinePathIngesting(outlinePath)) return
+    startOutlineIngestTask(project.path, outlinePath)
+  }, [isOutlinePathIngesting, novelMode, project])
+
   const sortedChapterPages = useMemo(() => {
     return effectivePages
       .filter((page): page is WikiPageInfo & { type: "chapter" } => page.type === "chapter")
@@ -1154,6 +1237,8 @@ export function KnowledgeTree({
       const isDragSource = dragSource === normalizedPath
       const chapterIndex = chapterIndexMap.get(normalizedPath)
       const isInsertTarget = isDragging && dragInsertIndex !== null && chapterIndex !== undefined && chapterIndex === dragInsertIndex && !isDragSource
+      const isOutlineExtracted = filterType === "outline" && extractedOutlinePaths.has(normalizedPath)
+      const isOutlineIngesting = filterType === "outline" && isOutlinePathIngesting(normalizedPath)
       return [
         <div
           key={normalizedPath}
@@ -1227,6 +1312,27 @@ export function KnowledgeTree({
               </>
             )}
           </button>
+          {filterType === "outline" && novelMode ? (
+            <Button
+              variant="ghost"
+              size="icon"
+              className={`mr-0 h-7 w-7 shrink-0 ${isOutlineExtracted ? "text-emerald-600 hover:text-emerald-700" : ""}`}
+              title={isOutlineExtracted ? t("novel.outlineGenerator.reingestTitle") : t("novel.outlineGenerator.ingest")}
+              disabled={isOutlineIngesting}
+              onClick={(event) => {
+                event.stopPropagation()
+                handleOutlineIngest(normalizedPath)
+              }}
+            >
+              {isOutlineIngesting ? (
+                <Loader2 className="h-4 w-4 animate-spin" />
+              ) : isOutlineExtracted ? (
+                <Check className="h-4 w-4" />
+              ) : (
+                <Sparkles className="h-4 w-4" />
+              )}
+            </Button>
+          ) : null}
           <DeleteButton
             armed={isArmed}
             deleting={isDeleting}
@@ -1262,6 +1368,10 @@ export function KnowledgeTree({
     dragInsertIndex,
     isDragging,
     sortedChapterPages,
+    novelMode,
+    extractedOutlinePaths,
+    isOutlinePathIngesting,
+    handleOutlineIngest,
     t,
   ])
 
@@ -1619,9 +1729,7 @@ export function RawSourcesSection({ onCancelExtraction }: { onCancelExtraction?:
                       ) : null}
                       <span className={isRunning ? "text-foreground font-medium" : ""}>
                         {isRunning
-                          ? task.cancelling
-                            ? `正在取消${kindLabel}提取...`
-                            : task.currentTitle || `${kindLabel}提取中`
+                          ? formatImportProgressRunningLabel(task, kindLabel)
                           : task.status === "done"
                             ? `${kindLabel}提取完成`
                             : task.status === "error"
@@ -1660,9 +1768,9 @@ export function RawSourcesSection({ onCancelExtraction }: { onCancelExtraction?:
                       />
                     </div>
                   )}
-                  {isRunning && task.completed > 0 && (
+                  {isRunning && (
                     <span className="text-muted-foreground">
-                      {task.completed}/{task.total} · {kindLabel}
+                      {formatImportProgressDetail(task)}
                     </span>
                   )}
                 </div>

+ 67 - 22
src/components/layout/preview-panel.tsx

@@ -2,7 +2,7 @@
 import { useTranslation } from "react-i18next"
 import { Check, MoreHorizontal, X } from "lucide-react"
 import { useWikiStore } from "@/stores/wiki-store"
-import { resolveDefaultModel, resolveNovelModel } from "@/lib/novel/model-resolver"
+import { resolveDefaultModel, resolveNovelModel, formatResolvedModelLabel } from "@/lib/novel/model-resolver"
 import type { FinalChapterSavePhase } from "@/stores/wiki-store"
 import { useReviewStore } from "@/stores/review-store"
 import { deleteFile, fileExists, readFile, writeFile, writeFileAtomic, listDirectory } from "@/commands/fs"
@@ -31,6 +31,7 @@ import {
   setLastChapterDeAiSkill,
 } from "@/lib/novel/de-ai-skill-library"
 import { startOutlineIngestTask } from "@/lib/novel/outline-generation"
+import { getOutlineIngestIdentity, getOutlineFileName, outlineSnapshotExists } from "@/lib/novel/outline-ingest-utils"
 import { streamChat } from "@/lib/llm-client"
 import {
   extractChapterNumberFromMarkdown,
@@ -174,6 +175,7 @@ export function PreviewPanel() {
   const pendingEditorHighlight = useWikiStore((s) => s.pendingEditorHighlight)
   const setPendingEditorHighlight = useWikiStore((s) => s.setPendingEditorHighlight)
   const bumpDataVersion = useWikiStore((s) => s.bumpDataVersion)
+  const dataVersion = useWikiStore((s) => s.dataVersion)
   const finalChapterSave = useWikiStore((s) => s.finalChapterSave)
   const setFinalChapterSave = useWikiStore((s) => s.setFinalChapterSave)
   const outlineTasks = useOutlineGenerationStore((s) => s.tasks)
@@ -192,6 +194,7 @@ export function PreviewPanel() {
   const [deAiSourceContent, setDeAiSourceContent] = useState("")
   const [deAiCandidateContent, setDeAiCandidateContent] = useState("")
   const [deAiSkillName, setDeAiSkillName] = useState("")
+  const [deAiModelName, setDeAiModelName] = useState("")
   const [deAiSkillMemoryWarning, setDeAiSkillMemoryWarning] = useState("")
   const [selectionTransformOpen, setSelectionTransformOpen] = useState(false)
   const [selectionTransformAction, setSelectionTransformAction] = useState<ChapterSelectionAction | null>(null)
@@ -199,6 +202,7 @@ export function PreviewPanel() {
   const [selectionTransformSourceContent, setSelectionTransformSourceContent] = useState("")
   const [selectionTransformCandidateContent, setSelectionTransformCandidateContent] = useState("")
   const [selectionTransformSkillName, setSelectionTransformSkillName] = useState("")
+  const [selectionTransformModelName, setSelectionTransformModelName] = useState("")
   const [deAiSkillPickerOpen, setDeAiSkillPickerOpen] = useState(false)
   const [deAiSkillPickerPosition, setDeAiSkillPickerPosition] = useState<CSSProperties>(() => getDeAiSkillPickerPosition())
   const [chapterDeAiSkillId, setChapterDeAiSkillId] = useState<string | null | undefined>(undefined)
@@ -390,7 +394,9 @@ export function PreviewPanel() {
     setSelectionTransformOpen(false)
     setDeAiPreviewOpen(false)
     setDeAiSkillName("")
+    setDeAiModelName("")
     setSelectionTransformSkillName("")
+    setSelectionTransformModelName("")
     setLoadedFilePath(null)
 
     if (!selectedFile) {
@@ -551,6 +557,26 @@ export function PreviewPanel() {
       .sort((a: OutlineGenerationTask, b: OutlineGenerationTask) => b.updatedAt - a.updatedAt)[0] ?? null
   }, [canIngestOutline, outlineTasks, project, selectedFile])
 
+  const outlineIngestProgressRunning = useImportProgressStore((s) => {
+    if (!project || !canIngestOutline || !selectedFile) return null
+    const pp = normalizePath(project.path)
+    return s.tasks.find((task) => (
+      task.projectPath === pp &&
+      task.kind === "outline" &&
+      task.status === "running"
+    )) ?? null
+  })
+  const isOutlineIngesting = useMemo(() => {
+    if (!project || !selectedFile || !canIngestOutline) return false
+    const fileName = getOutlineFileName(selectedFile)
+    if (outlineIngestProgressRunning) {
+      if (outlineIngestProgressRunning.total === 1) return true
+      if (outlineIngestProgressRunning.currentTitle === fileName) return true
+      if (outlineIngestProgressRunning.activeTitles?.includes(fileName)) return true
+    }
+    return currentOutlineTask?.status === "ingesting"
+  }, [canIngestOutline, currentOutlineTask, outlineIngestProgressRunning, project, selectedFile])
+
   // 检测大纲是否已经提取过初始记忆(持久化状态)
   useEffect(() => {
     if (!canIngestOutline || !project || !selectedFile) {
@@ -558,24 +584,29 @@ export function PreviewPanel() {
       setOutlineSnapshotNumber(null)
       return
     }
-    const normalizedOutlinePath = normalizePath(selectedFile)
-    const fileName = normalizedOutlinePath.split("/").pop() ?? "outline"
-    const outlineName = fileName.replace(/\.\w+$/, "")
-    let hash = 0
-    for (let i = 0; i < outlineName.length; i++) {
-      hash = ((hash << 5) - hash + outlineName.charCodeAt(i)) | 0
+    const { chapterNumber } = getOutlineIngestIdentity(project.path, selectedFile)
+    setOutlineSnapshotNumber(chapterNumber)
+    let cancelled = false
+    void outlineSnapshotExists(project.path, selectedFile)
+      .then((exists) => {
+        if (!cancelled) setOutlineIngested(exists)
+      })
+      .catch(() => {
+        if (!cancelled) setOutlineIngested(false)
+      })
+    return () => {
+      cancelled = true
     }
-    const outlineNum = -(Math.abs(hash % 999) + 1)
-    setOutlineSnapshotNumber(outlineNum)
-    const prefix = `outline-${String(Math.abs(outlineNum)).padStart(3, "0")}`
-    const jsonPath = `${normalizePath(project.path)}/.novel/snapshots/${prefix}.snapshot.json`
-    fileExists(jsonPath).then((exists) => setOutlineIngested(exists)).catch(() => setOutlineIngested(false))
-  }, [canIngestOutline, project, selectedFile])
+  }, [canIngestOutline, project, selectedFile, dataVersion, currentOutlineTask?.status, currentOutlineTask?.updatedAt])
   useEffect(() => {
     if (!canIngestOutline) return
     if (!currentOutlineTask?.message) return
+    if (currentOutlineTask.status === "ingesting" && !outlineIngestProgressRunning) {
+      setSaveStatus("")
+      return
+    }
     setSaveStatus(currentOutlineTask.message)
-  }, [canIngestOutline, currentOutlineTask])
+  }, [canIngestOutline, currentOutlineTask, outlineIngestProgressRunning])
   const chapterNumber = useMemo(() => {
     if (!chapterFrontmatter) return null
     const meta = parseChapterMeta(chapterFrontmatter)
@@ -584,7 +615,6 @@ export function PreviewPanel() {
   const canViewSnapshot = Boolean(novelMode && project && chapterNumber !== null)
   const currentFinalChapterSave = finalChapterSave != null && finalChapterSave.projectPath === project?.path && finalChapterSave.filePath === selectedFile ? finalChapterSave : null
   const isFinalChapterSaving = currentFinalChapterSave?.saving ?? isSavingFinal
-  const isOutlineIngesting = currentOutlineTask?.status === "ingesting"
 
   const phaseLabelMap: Record<FinalChapterSavePhase, string> = {
     saving: t("novel.chapter.savingAsFinal"),
@@ -949,12 +979,14 @@ export function PreviewPanel() {
     setDeAiSkillName(skillName)
     const state = useWikiStore.getState()
     const llmConfig = resolveNovelModel(state.llmConfig, state.novelConfig, "deAi")
+    const modelLabel = formatResolvedModelLabel(llmConfig, state.providerConfigs)
+    setDeAiModelName(modelLabel)
     if (!hasUsableLlm(llmConfig, state.providerConfigs)) {
       setDeAiProcessing(false)
       setSaveStatus("未配置可用的 AI 模型,无法去AI味")
       return
     }
-    setSaveStatus(formatDeAiStatus(`去AI味处理中,使用 Skill:${skillName}...`))
+    setSaveStatus(formatDeAiStatus(`去AI味处理中,使用 Skill:${skillName},模型:${modelLabel}...`))
     let result = ""
     try {
       await streamChat(
@@ -969,7 +1001,7 @@ export function PreviewPanel() {
             setDeAiCandidateContent(result)
             setDeAiPreviewOpen(true)
             setDeAiProcessing(false)
-            setSaveStatus(formatDeAiStatus(`本次使用 Skill:${skillName}`))
+            setSaveStatus(formatDeAiStatus(`本次使用 Skill:${skillName},模型:${modelLabel}`))
           },
           onError: (error) => {
             console.error("去AI味处理失败:", error)
@@ -986,6 +1018,7 @@ export function PreviewPanel() {
   const handleDeAiApply = useCallback(() => {
     setDeAiPreviewOpen(false)
     setDeAiSkillName("")
+    setDeAiModelName("")
     handleSave(replaceWholeChapterBody(fileContent, deAiCandidateContent))
   }, [deAiCandidateContent, fileContent, handleSave])
 
@@ -1010,6 +1043,7 @@ export function PreviewPanel() {
   const handleDeAiClose = useCallback(() => {
     setDeAiPreviewOpen(false)
     setDeAiSkillName("")
+    setDeAiModelName("")
   }, [])
 
   const runSelectionTransform = useCallback(async (
@@ -1033,9 +1067,11 @@ export function PreviewPanel() {
 
     const actionFile = selectedFileRef.current
     const actionLabel = action === "polish" ? "AI润色" : "去AI味"
+    const modelLabel = formatResolvedModelLabel(llmConfig, state.providerConfigs)
     setSelectionTransformSkillName(action === "de-ai" ? skillName ?? "" : "")
+    setSelectionTransformModelName(action === "de-ai" ? modelLabel : "")
     const transformStatus = action === "de-ai" && skillName
-      ? `${actionLabel}处理中,使用 Skill:${skillName}...`
+      ? `${actionLabel}处理中,使用 Skill:${skillName},模型:${modelLabel}...`
       : `${actionLabel}处理中...`
     setSaveStatus(action === "de-ai" ? formatDeAiStatus(transformStatus) : transformStatus)
 
@@ -1352,7 +1388,11 @@ export function PreviewPanel() {
                       disabled={isOutlineIngesting}
                       className="block w-full rounded px-2 py-1.5 text-left hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
                     >
-                      {isOutlineIngesting ? t("novel.outlineGenerator.ingesting") : outlineIngested ? "已提取记忆" : t("novel.outlineGenerator.ingest")}
+                      {isOutlineIngesting
+                        ? t("novel.outlineGenerator.ingesting")
+                        : outlineIngested
+                          ? t("novel.outlineGenerator.reingestButton")
+                          : t("novel.outlineGenerator.ingest")}
                     </button>
                   ) : null}
                   {canIngestOutline && outlineIngested && outlineSnapshotNumber !== null ? (
@@ -1456,9 +1496,13 @@ export function PreviewPanel() {
                   ? "border-emerald-500/50 text-emerald-700 hover:bg-emerald-50 dark:text-emerald-300 dark:hover:bg-emerald-950/30"
                   : "border-border text-foreground hover:bg-accent"
               }`}
-              title={outlineIngested ? "重新提取初始记忆(将覆盖上次提取的内容)" : t("novel.outlineGenerator.ingest")}
+              title={outlineIngested ? t("novel.outlineGenerator.reingestTitle") : t("novel.outlineGenerator.ingest")}
             >
-              {isOutlineIngesting ? t("novel.outlineGenerator.ingesting") : outlineIngested ? "✓ 已提取记忆" : t("novel.outlineGenerator.ingest")}
+              {isOutlineIngesting
+                ? t("novel.outlineGenerator.ingesting")
+                : outlineIngested
+                  ? t("novel.outlineGenerator.reingestButton")
+                  : t("novel.outlineGenerator.ingest")}
             </button>
           ) : null}
           {!chapterToolbarCompact && canIngestOutline && outlineIngested && outlineSnapshotNumber !== null ? (
@@ -1622,6 +1666,7 @@ export function PreviewPanel() {
         sourceContent={deAiSourceContent}
         candidateContent={deAiCandidateContent}
         skillName={deAiSkillName}
+        modelName={deAiModelName}
         onApply={handleDeAiApply}
         onSaveDraft={() => void handleDeAiSaveDraft()}
         onClose={handleDeAiClose}
@@ -1630,7 +1675,7 @@ export function PreviewPanel() {
         open={selectionTransformOpen}
         title={selectionTransformAction === "polish" ? "AI润色预览" : "去AI味预览"}
         description={selectionTransformAction === "de-ai" && selectionTransformSkillName
-          ? `本次使用 Skill:${selectionTransformSkillName}。确认后会替换当前选中的正文片段。`
+          ? `本次使用 Skill:${selectionTransformSkillName}${selectionTransformModelName ? `,模型:${selectionTransformModelName}` : ""}。确认后会替换当前选中的正文片段。`
           : "确认后会替换当前选中的正文片段。"}
         sourceLabel="原文片段"
         candidateLabel={selectionTransformAction === "polish" ? "润色结果" : "去AI味结果"}

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 431 - 619
src/components/layout/sidebar-panel.tsx


+ 6 - 1
src/components/novel/de-ai-preview-dialog.tsx

@@ -12,6 +12,7 @@ export interface DeAiPreviewDialogProps {
   sourceContent: string
   candidateContent: string
   skillName?: string
+  modelName?: string
   onApply: () => void
   onSaveDraft: () => void
   onClose: () => void
@@ -22,6 +23,7 @@ export function DeAiPreviewDialog({
   sourceContent,
   candidateContent,
   skillName,
+  modelName,
   onApply,
   onSaveDraft,
   onClose,
@@ -32,7 +34,10 @@ export function DeAiPreviewDialog({
         <DialogHeader>
           <DialogTitle>去AI味预览</DialogTitle>
           {skillName ? (
-            <div className="text-xs text-muted-foreground">本次使用 Skill:{skillName}</div>
+            <div className="text-xs text-muted-foreground">
+              本次使用 Skill:{skillName}
+              {modelName ? `,模型:${modelName}` : ""}
+            </div>
           ) : null}
         </DialogHeader>
         <div className="grid grid-cols-2 gap-4">

+ 21 - 17
src/components/sources/outline-action-toolbar.tsx

@@ -1,10 +1,12 @@
-import { useCallback, useMemo, useState } from "react"
+import { useCallback, useState } from "react"
 import { Loader2, MessageSquare, Sparkles } from "lucide-react"
 import { useTranslation } from "react-i18next"
 import { Button } from "@/components/ui/button"
 import { OutlineGeneratorDialog, type OutlineGeneratorMode } from "@/components/sources/outline-generator-dialog"
-import { runBulkOutlineIngest } from "@/lib/novel/outline-generation"
+import { runBulkOutlineIngest, formatBulkOutlineIngestResult, OutlineIngestNotReadyError } from "@/lib/novel/outline-generation"
 import { cn } from "@/lib/utils"
+import { toast } from "@/lib/toast"
+import { useImportProgressStore } from "@/stores/import-progress-store"
 import { useOutlineGenerationStore } from "@/stores/outline-generation-store"
 import { useWikiStore } from "@/stores/wiki-store"
 
@@ -22,19 +24,20 @@ export function OutlineActionToolbar({
   const { t } = useTranslation()
   const project = useWikiStore((s) => s.project)
   const setActiveView = useWikiStore((s) => s.setActiveView)
-  const outlineTasks = useOutlineGenerationStore((s) => s.tasks)
   const setOutlineChatOpen = useOutlineGenerationStore((s) => s.setPanelOpen)
   const [outlineDialogOpen, setOutlineDialogOpen] = useState(false)
   const [outlineDialogMode, setOutlineDialogMode] = useState<OutlineGeneratorMode>("outline")
   const [bulkIngestRunning, setBulkIngestRunning] = useState(false)
 
-  const bulkIngesting = useMemo(() => (
-    project != null && outlineTasks.some((task) => (
+  const bulkOutlineProgressRunning = useImportProgressStore((s) => (
+    project != null && s.tasks.some((task) => (
       task.projectPath === project.path &&
-      task.kind === "ingest" &&
-      task.status === "ingesting"
+      task.kind === "outline" &&
+      task.status === "running"
     ))
-  ), [outlineTasks, project])
+  ))
+
+  const bulkIngestActive = bulkIngestRunning || bulkOutlineProgressRunning
 
   function openOutlineDialog(mode: OutlineGeneratorMode) {
     setOutlineDialogMode(mode)
@@ -51,23 +54,24 @@ export function OutlineActionToolbar({
   }, [onToggleOutlineChat, setActiveView, setOutlineChatOpen])
 
   const handleBulkIngest = useCallback(async () => {
-    if (!project || bulkIngestRunning || bulkIngesting) return
+    if (!project || bulkIngestActive) return
     setBulkIngestRunning(true)
     onBulkIngestResult?.(null)
     try {
       const result = await runBulkOutlineIngest(project.path)
-      if (result.total === 0) {
-        onBulkIngestResult?.(t("novel.outlineGenerator.bulkIngestEmpty"))
-      } else {
-        onBulkIngestResult?.(t("novel.outlineGenerator.bulkIngestResult", result))
-      }
+      onBulkIngestResult?.(formatBulkOutlineIngestResult(result))
     } catch (err) {
+      if (err instanceof OutlineIngestNotReadyError) {
+        toast.error(err.message)
+        onBulkIngestResult?.(err.message)
+        return
+      }
       const message = err instanceof Error ? err.message : String(err)
       onBulkIngestResult?.(t("novel.outlineGenerator.bulkIngestError", { message }))
     } finally {
       setBulkIngestRunning(false)
     }
-  }, [bulkIngestRunning, bulkIngesting, onBulkIngestResult, project, t])
+  }, [bulkIngestActive, onBulkIngestResult, project, t])
 
   return (
     <>
@@ -83,8 +87,8 @@ export function OutlineActionToolbar({
         <Button size="sm" variant="outline" onClick={() => openOutlineDialog("refine")}>
           {t("novel.outlineGenerator.refineTitle")}
         </Button>
-        <Button size="sm" variant="outline" onClick={() => void handleBulkIngest()} disabled={bulkIngestRunning || bulkIngesting}>
-          {bulkIngestRunning || bulkIngesting ? (
+        <Button size="sm" variant="outline" onClick={() => void handleBulkIngest()} disabled={bulkIngestActive}>
+          {bulkIngestActive ? (
             <>
               <Loader2 className="mr-1 h-4 w-4 animate-spin" />
               {t("novel.outlineGenerator.bulkIngesting")}

+ 1 - 1
src/components/sources/sources-view.tsx

@@ -88,7 +88,7 @@ export function SourcesView() {
         </div>
       </div>
       {bulkIngestResult ? (
-        <div className="border-b px-4 py-2 text-xs text-muted-foreground">
+        <div className="border-b px-4 py-2 text-xs text-muted-foreground whitespace-pre-line">
           {bulkIngestResult}
         </div>
       ) : null}

+ 14 - 0
src/i18n/en.json

@@ -1396,10 +1396,22 @@
       "error": "Generation failed",
       "ingest": "Extract Initial Memory",
       "ingesting": "Extracting initial memory...",
+      "reingestButton": "Re-extract Memory",
+      "reingestTitle": "Re-extract initial memory (overwrites the previous extraction)",
       "bulkIngest": "Extract All",
       "bulkIngesting": "Extracting all...",
       "bulkIngestEmpty": "There are no outline documents to extract in the current outline library.",
       "bulkIngestResult": "Bulk extraction complete: {{succeeded}} succeeded, {{failed}} failed, {{total}} total.",
+      "bulkIngestResultWithFailures": "Bulk extraction complete: {{succeeded}} succeeded, {{failed}} failed, {{total}} total. Failures:",
+      "bulkIngestMoreFailures": "{{count}} more outline(s) failed to extract.",
+      "bulkIngestCancelled": "Bulk extraction cancelled: {{succeeded}} succeeded, {{failed}} failed, {{total}} total.",
+      "bulkIngestCancelledProgress": "Bulk extraction cancelled. {{succeeded}}/{{total}} succeeded.",
+      "bulkIngestProgressDone": "Bulk extraction complete: {{succeeded}}/{{total}} succeeded.",
+      "bulkIngestProgressWithFailures": "Bulk extraction complete: {{succeeded}} succeeded, {{failed}} failed. {{preview}}",
+      "bulkIngestSyncing": "Writing memory for {{name}}",
+      "bulkIngestParallel": "{{active}}/{{concurrency}} parallel extractions running",
+      "ingestNoLlm": "No usable extract model configured. Please configure an LLM in Settings first.",
+      "ingestCancelledNotification": "Outline ingestion cancelled",
       "bulkIngestError": "Bulk extraction failed: {{message}}",
       "generationMayTakeLong": "Outline generation may take a while. You can hide this dialog and work on something else; we will remind you later.",
       "hideAndContinue": "Hide and keep generating",
@@ -1413,6 +1425,8 @@
       "openedNotification": "Outline opened",
       "ingestingNotification": "Extracting initial project memory...",
       "ingestSuccessNotification": "Initial project memory extracted successfully",
+      "ingestSuccessTruncatedNotification": "Initial project memory extracted (outline truncated to fit context window: {{used}}/{{total}} chars, budget {{budget}} chars)",
+      "ingestProgressTruncated": "{{name}} extracted (truncated {{used}}/{{total}} chars)",
       "ingestFailedNotification": "Initial project memory extraction failed",
       "ingestError": "Initial project memory extraction failed: {{message}}",
       "addedToOutlineList": "Added to the outline list",

+ 14 - 0
src/i18n/zh.json

@@ -1338,10 +1338,22 @@
       "error": "生成失败",
       "ingest": "提取初始记忆",
       "ingesting": "正在提取初始记忆...",
+      "reingestButton": "重新提取记忆",
+      "reingestTitle": "重新提取初始记忆(将覆盖上次提取的内容)",
       "bulkIngest": "一键提取",
       "bulkIngesting": "一键提取中...",
       "bulkIngestEmpty": "当前大纲库里没有可提取的大纲文档。",
       "bulkIngestResult": "一键提取完成:共 {{total}} 个大纲,成功 {{succeeded}} 个,失败 {{failed}} 个。",
+      "bulkIngestResultWithFailures": "一键提取完成:共 {{total}} 个大纲,成功 {{succeeded}} 个,失败 {{failed}} 个。失败明细:",
+      "bulkIngestMoreFailures": "另有 {{count}} 个大纲提取失败。",
+      "bulkIngestCancelled": "一键提取已取消:共 {{total}} 个大纲,成功 {{succeeded}} 个,失败 {{failed}} 个。",
+      "bulkIngestCancelledProgress": "已取消一键提取,成功 {{succeeded}}/{{total}} 个。",
+      "bulkIngestProgressDone": "一键提取完成:成功 {{succeeded}}/{{total}} 个。",
+      "bulkIngestProgressWithFailures": "一键提取完成:成功 {{succeeded}} 个,失败 {{failed}} 个。{{preview}}",
+      "bulkIngestSyncing": "正在写入 {{name}} 的记忆",
+      "bulkIngestParallel": "{{active}}/{{concurrency}} 路并行提取中",
+      "ingestNoLlm": "未配置可用的提取模型,请先在设置中配置 LLM。",
+      "ingestCancelledNotification": "大纲摄取已取消",
       "bulkIngestError": "一键提取失败:{{message}}",
       "generationMayTakeLong": "生成大纲可能需要较长时间,建议隐藏之后先去做其他的,稍后会提醒您。",
       "hideAndContinue": "隐藏并后台生成",
@@ -1355,6 +1367,8 @@
       "openedNotification": "已打开大纲",
       "ingestingNotification": "正在提取项目初始记忆...",
       "ingestSuccessNotification": "项目初始记忆提取成功",
+      "ingestSuccessTruncatedNotification": "项目初始记忆提取成功(大纲过长,已按上下文窗口截断:{{used}}/{{total}} 字,预算 {{budget}} 字)",
+      "ingestProgressTruncated": "{{name}} 提取完成(已截断 {{used}}/{{total}} 字)",
       "ingestFailedNotification": "项目初始记忆提取失败",
       "ingestError": "项目初始记忆提取失败:{{message}}",
       "addedToOutlineList": "已加入到大纲列表",

+ 34 - 0
src/lib/async-pool.spec.ts

@@ -0,0 +1,34 @@
+import { describe, expect, it, vi } from "vitest"
+import { mapWithConcurrency } from "./async-pool"
+
+describe("mapWithConcurrency", () => {
+  it("preserves result order", async () => {
+    const results = await mapWithConcurrency([1, 2, 3, 4], 2, async (value) => value * 2)
+    expect(results).toEqual([2, 4, 6, 8])
+  })
+
+  it("limits active workers", async () => {
+    let active = 0
+    let maxActive = 0
+
+    await mapWithConcurrency([1, 2, 3, 4, 5], 2, async (value) => {
+      active += 1
+      maxActive = Math.max(maxActive, active)
+      await new Promise((resolve) => setTimeout(resolve, 10))
+      active -= 1
+      return value
+    })
+
+    expect(maxActive).toBeLessThanOrEqual(2)
+  })
+
+  it("stops scheduling when signal is aborted", async () => {
+    const controller = new AbortController()
+    controller.abort()
+    const fn = vi.fn(async (value: number) => value)
+
+    await mapWithConcurrency([1, 2, 3], 2, fn, { signal: controller.signal })
+
+    expect(fn).not.toHaveBeenCalled()
+  })
+})

+ 34 - 0
src/lib/async-pool.ts

@@ -0,0 +1,34 @@
+export interface MapWithConcurrencyOptions {
+  signal?: AbortSignal
+  onItemComplete?: (index: number) => void
+}
+
+/**
+ * Map items with a fixed concurrency limit. Result order matches input order.
+ */
+export async function mapWithConcurrency<T, R>(
+  items: readonly T[],
+  concurrency: number,
+  fn: (item: T, index: number) => Promise<R>,
+  options?: MapWithConcurrencyOptions,
+): Promise<R[]> {
+  if (items.length === 0) return []
+
+  const results: R[] = new Array(items.length)
+  let nextIndex = 0
+
+  async function worker(): Promise<void> {
+    while (true) {
+      if (options?.signal?.aborted) return
+      const index = nextIndex
+      nextIndex += 1
+      if (index >= items.length) return
+      results[index] = await fn(items[index], index)
+      options?.onItemComplete?.(index)
+    }
+  }
+
+  const workerCount = Math.max(1, Math.min(concurrency, items.length))
+  await Promise.all(Array.from({ length: workerCount }, () => worker()))
+  return results
+}

+ 27 - 0
src/lib/context-budget.spec.ts

@@ -0,0 +1,27 @@
+import { describe, expect, it } from "vitest"
+import {
+  computeOutlineIngestBodyBudget,
+  OUTLINE_INGEST_MIN_BODY_BUDGET,
+} from "./context-budget"
+
+describe("computeOutlineIngestBodyBudget", () => {
+  const promptOverhead = 2_500
+
+  it("uses at least the legacy floor on large windows", () => {
+    const budget = computeOutlineIngestBodyBudget(204_800, promptOverhead, 1)
+    expect(budget).toBeGreaterThan(OUTLINE_INGEST_MIN_BODY_BUDGET)
+  })
+
+  it("scales down for smaller context windows", () => {
+    const large = computeOutlineIngestBodyBudget(204_800, promptOverhead, 1)
+    const small = computeOutlineIngestBodyBudget(32_768, promptOverhead, 1)
+    expect(small).toBeLessThan(large)
+    expect(small).toBeGreaterThan(0)
+  })
+
+  it("applies CJK language scale", () => {
+    const english = computeOutlineIngestBodyBudget(128_000, promptOverhead, 1)
+    const cjk = computeOutlineIngestBodyBudget(128_000, promptOverhead, 0.425)
+    expect(cjk).toBeLessThan(english)
+  })
+})

+ 36 - 0
src/lib/context-budget.ts

@@ -174,3 +174,39 @@ export function computeNovelContextTokenBudget(
   }
   return cap
 }
+
+/** Legacy single-pass outline ingest floor; kept so small windows still behave predictably. */
+export const OUTLINE_INGEST_MIN_BODY_BUDGET = 8_000
+/** Upper cap aligned with wiki long-source ingest. */
+export const OUTLINE_INGEST_MAX_BODY_BUDGET = 300_000
+
+function clampBudget(value: number, min: number, max: number): number {
+  return Math.max(min, Math.min(max, value))
+}
+
+/**
+ * Character budget for the outline body in `ingestOutline`.
+ *
+ * Reserves space for fixed prompts and JSON output, then allocates the
+ * remainder to the outline markdown. Scales with `maxContextSize` and
+ * CJK language scale like other budget helpers.
+ */
+export function computeOutlineIngestBodyBudget(
+  maxContextSize: number | undefined,
+  promptOverheadChars: number,
+  langScale?: number,
+): number {
+  const { maxCtx, responseReserve } = computeContextBudget(maxContextSize, langScale)
+  const outputReserve = Math.max(responseReserve, Math.floor(maxCtx * 0.15))
+  const instructionReserve = Math.max(promptOverheadChars, Math.floor(maxCtx * 0.08))
+  const available = maxCtx - outputReserve - instructionReserve
+  const upper = Math.min(
+    OUTLINE_INGEST_MAX_BODY_BUDGET,
+    Math.max(OUTLINE_INGEST_MIN_BODY_BUDGET, Math.floor(maxCtx * 0.6)),
+  )
+  const min = Math.min(
+    OUTLINE_INGEST_MIN_BODY_BUDGET,
+    Math.max(1_000, Math.floor(available)),
+  )
+  return clampBudget(Math.floor(available), min, upper)
+}

+ 108 - 37
src/lib/novel/chapter-ingest.ts

@@ -20,6 +20,7 @@ import { mergeSnapshotTimeline } from "./timeline"
 import { buildStructuredMemoryDocuments, isValidMemorySnapshot } from "./memory-rebuild"
 import { clearGraphCache } from "@/lib/graph-relevance"
 import { RetrievalStore } from "./retrieval"
+import { computeOutlineIngestBodyBudget } from "@/lib/context-budget"
 
 export interface ValidationWarning {
   type: "entity_new" | "canon_conflict"
@@ -664,6 +665,49 @@ function normalizeOutlineIngestError(err: unknown): Error {
   return new Error(message)
 }
 
+const OUTLINE_INGEST_JSON_TEMPLATE = `输出 JSON:
+{
+  "chapterId": "outline-init",
+  "chapterNumber": 0,
+  "summary": "大纲摘要",
+  "characters": ["初始人物"],
+  "locations": ["初始地点"],
+  "organizations": ["初始组织/势力"],
+  "items": ["关键物品"],
+  "events": ["背景事件"],
+  "characterStateChanges": ["人物初始状态"],
+  "relationshipChanges": ["人物初始关系"],
+  "knowledgeChanges": [],
+  "foreshadowingChanges": ["初始伏笔"],
+  "newCanonFacts": ["世界观正史设定"],
+  "timelineEvents": ["时间线背景"],
+  "conflicts": ["核心冲突"],
+  "endingHook": "",
+  "graphNodes": ["图谱节点列表"],
+  "graphEdges": ["图谱关系边,格式:A->关系->B。关系必须是以下之一:出场于|发生于|属于|持有|敌对|合作|怀疑|隐瞒|知道|不知道|推进伏笔|回收伏笔|新增伏笔|导致|揭示|影响|位于"]
+}`
+
+export interface OutlineIngestResult {
+  snapshot: ChapterSnapshot | null
+  truncated: boolean
+  originalLength: number
+  bodyLength: number
+  bodyBudget: number
+  failureReason?: "no_llm" | null
+}
+
+export interface IngestOutlineOptions {
+  skipSync?: boolean
+}
+
+function buildOutlineIngestUserPrompt(body: string): string {
+  return `请从以下大纲中提取初始设定:
+
+${body}
+
+${OUTLINE_INGEST_JSON_TEMPLATE}`
+}
+
 async function extractSnapshotWithLLM(
   chapterNumber: number,
   chapterBody: string,
@@ -1072,9 +1116,15 @@ export interface SyncSnapshotToMemoryResult {
   memorySyncedAt: string
 }
 
+export interface SyncSnapshotToMemoryOptions {
+  deferStructuredMemoryExport?: boolean
+  deferDerivedRebuild?: boolean
+}
+
 export async function syncSnapshotToMemory(
   projectPath: string,
   snapshot: ChapterSnapshot,
+  options?: SyncSnapshotToMemoryOptions,
 ): Promise<SyncSnapshotToMemoryResult> {
   const pp = normalizePath(projectPath)
   const currentSnapshot = await readCurrentSnapshot(pp, snapshot.chapterNumber)
@@ -1140,9 +1190,13 @@ export async function syncSnapshotToMemory(
 
   await backupSnapshotBeforeOverwrite(pp, syncedSnapshot.chapterNumber)
   await saveSnapshot(pp, syncedSnapshot)
-  const memoryPagePaths = await exportStructuredMemoryToWiki(pp, syncedSnapshot)
-  clearGraphCache()
-  useWikiStore.getState().bumpDataVersion()
+  const memoryPagePaths = options?.deferStructuredMemoryExport
+    ? []
+    : await exportStructuredMemoryToWiki(pp, syncedSnapshot)
+  if (!options?.deferDerivedRebuild) {
+    clearGraphCache()
+    useWikiStore.getState().bumpDataVersion()
+  }
 
   return { writtenEntityPaths, memoryPagePaths, memorySyncedAt }
 }
@@ -1348,6 +1402,13 @@ async function rebuildDerivedMemoryFromSnapshots(projectPath: string, latestSnap
   await writeStructuredMemoryDocuments(projectPath, snapshots)
 }
 
+export async function finalizeProjectMemoryRebuild(projectPath: string): Promise<void> {
+  const pp = normalizePath(projectPath)
+  await rebuildDerivedMemoryFromSnapshots(pp)
+  clearGraphCache()
+  useWikiStore.getState().bumpDataVersion()
+}
+
 async function saveSnapshot(projectPath: string, snapshot: ChapterSnapshot): Promise<void> {
   const canonicalSnapshot = ensureSnapshotIdentity(canonicalizeSnapshotCharacters(snapshot))
   const normalizedSnapshot = normalizeChapterSnapshot(canonicalSnapshot, {
@@ -1521,17 +1582,38 @@ export async function ingestOutline(
   projectPath: string,
   outlinePath: string,
   signal?: AbortSignal,
-): Promise<ChapterSnapshot | null> {
+  options?: IngestOutlineOptions,
+): Promise<OutlineIngestResult> {
+  const emptyResult = (
+    snapshot: ChapterSnapshot | null = null,
+    failureReason: OutlineIngestResult["failureReason"] = null,
+  ): OutlineIngestResult => ({
+    snapshot,
+    truncated: false,
+    originalLength: 0,
+    bodyLength: 0,
+    bodyBudget: 0,
+    failureReason,
+  })
+
   const pp = normalizePath(projectPath)
   const state = useWikiStore.getState()
   const llmConfig = state.llmConfig
   const novelConfig = state.novelConfig
   // 使用 resolveNovelModel 正确解析提取模型(含供应商配置切换),与 ingestChapter 保持一致
   const runtimeLlmConfig = resolveNovelModel(llmConfig, novelConfig, "extract")
-  if (!hasUsableLlm(runtimeLlmConfig, state.providerConfigs)) return null
+  if (!hasUsableLlm(runtimeLlmConfig, state.providerConfigs)) return emptyResult(null, "no_llm")
 
   const content = await readFile(outlinePath)
-  const body = content.length > 8000 ? content.slice(0, 8000) : content
+  const originalLength = content.length
+
+  const outputLang = getOutputLanguage()
+  const langReminder = buildLanguageReminder(outputLang)
+  const systemPrompt = `你是一个专业的小说编辑助手。请从大纲中提取初始设定信息,输出 JSON。${langReminder}`
+  const promptOverhead = systemPrompt.length + buildOutlineIngestUserPrompt("").length
+  const bodyBudget = computeOutlineIngestBodyBudget(runtimeLlmConfig.maxContextSize, promptOverhead)
+  const truncated = content.length > bodyBudget
+  const body = truncated ? content.slice(0, bodyBudget) : content
 
   // 从文件路径提取大纲名称作为标题
   const normalizedOutlinePath = normalizePath(outlinePath)
@@ -1547,36 +1629,7 @@ export async function ingestOutline(
   const outlineNumber = -(Math.abs(hash % 999) + 1) // -1 到 -999
   const chapterId = `outline-${outlineName}`
 
-  const outputLang = getOutputLanguage()
-  const langReminder = buildLanguageReminder(outputLang)
-
-  const systemPrompt = `你是一个专业的小说编辑助手。请从大纲中提取初始设定信息,输出 JSON。${langReminder}`
-
-  const userPrompt = `请从以下大纲中提取初始设定:
-
-${body}
-
-输出 JSON:
-{
-  "chapterId": "outline-init",
-  "chapterNumber": 0,
-  "summary": "大纲摘要",
-  "characters": ["初始人物"],
-  "locations": ["初始地点"],
-  "organizations": ["初始组织/势力"],
-  "items": ["关键物品"],
-  "events": ["背景事件"],
-  "characterStateChanges": ["人物初始状态"],
-  "relationshipChanges": ["人物初始关系"],
-  "knowledgeChanges": [],
-  "foreshadowingChanges": ["初始伏笔"],
-  "newCanonFacts": ["世界观正史设定"],
-  "timelineEvents": ["时间线背景"],
-  "conflicts": ["核心冲突"],
-  "endingHook": "",
-  "graphNodes": ["图谱节点列表"],
-  "graphEdges": ["图谱关系边,格式:A->关系->B。关系必须是以下之一:出场于|发生于|属于|持有|敌对|合作|怀疑|隐瞒|知道|不知道|推进伏笔|回收伏笔|新增伏笔|导致|揭示|影响|位于"]
-}`
+  const userPrompt = buildOutlineIngestUserPrompt(body)
 
   try {
     const messages: ChatMessage[] = [
@@ -1613,8 +1666,26 @@ ${body}
       throw new Error("Outline snapshot payload is invalid.")
     }
 
+    if (options?.skipSync) {
+      return {
+        snapshot,
+        truncated,
+        originalLength,
+        bodyLength: body.length,
+        bodyBudget,
+        failureReason: null,
+      }
+    }
+
     const syncResult = await syncSnapshotToMemory(pp, snapshot)
-    return { ...snapshot, memorySyncedAt: syncResult.memorySyncedAt }
+    return {
+      snapshot: { ...snapshot, memorySyncedAt: syncResult.memorySyncedAt },
+      truncated,
+      originalLength,
+      bodyLength: body.length,
+      bodyBudget,
+      failureReason: null,
+    }
   } catch (err) {
     console.error("[Outline Ingest] Failed:", err)
     throw normalizeOutlineIngestError(err)

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

@@ -1579,4 +1579,24 @@ describe("runDeepChapterGeneration", () => {
       controller.signal,
     )
   })
+
+  it("stops after review when the user cancels during review", async () => {
+    const controller = new AbortController()
+    const deps: DeepChapterGenerationDeps = {
+      ...createDeps(),
+      reviewChapter: vi.fn(async () => {
+        controller.abort()
+        throw new Error("已停止生成")
+      }),
+    }
+
+    await expect(runDeepChapterGeneration(
+      { projectPath: "E:/Novel", userRequest: "生成第3章", chapterNumber: 3, llmConfig },
+      {},
+      deps,
+      controller.signal,
+    )).rejects.toThrow("已停止生成")
+
+    expect(deps.streamChat).toHaveBeenCalledTimes(2)
+  })
 })

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


+ 2 - 0
src/lib/novel/deep-outline-generation.ts

@@ -2,6 +2,7 @@ import type { LlmConfig } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { streamChat, type ChatMessage, type RequestOverrides, type StreamCallbacks } from "@/lib/llm-client"
+import { USER_ABORT_MESSAGE } from "@/lib/user-abort"
 
 export interface DeepOutlineGenerationInput {
   llmConfig: LlmConfig
@@ -130,6 +131,7 @@ async function collectModelText(
     { reasoning: config.reasoning },
   )
 
+  if (signal?.aborted) throw new Error(USER_ABORT_MESSAGE)
   if (streamError) throw streamError
   return content.trim()
 }

+ 1 - 1
src/lib/novel/mod.ts

@@ -3,7 +3,7 @@ export { parseChapterMeta, isChapterPage, isOutlinePage, type ChapterMeta, type
 export { parseVolumeMeta, isVolumePage, getChapterVolumes, type VolumeMeta } from "./volume"
 export { createChapterPipeline, type ChapterPipeline, type ChapterPipelineDeps } from "./chapter-pipeline"
 export { buildContextPack, contextPackToPrompt, type ContextPack } from "./context-engine"
-export { ingestChapter, ingestChapterPipeline, ingestOutline, loadSnapshot, listSnapshots, deleteChapterSnapshots, type ChapterSnapshot, type CharacterDetail, type LocationDetail, type OrganizationDetail, type ItemDetail, type EventDetail, type IngestResult, type IngestFailReason } from "./chapter-ingest"
+export { ingestChapter, ingestChapterPipeline, ingestOutline, loadSnapshot, listSnapshots, deleteChapterSnapshots, finalizeProjectMemoryRebuild, syncSnapshotToMemory, type ChapterSnapshot, type CharacterDetail, type LocationDetail, type OrganizationDetail, type ItemDetail, type EventDetail, type IngestResult, type IngestFailReason, type OutlineIngestResult, type IngestOutlineOptions, type SyncSnapshotToMemoryOptions } from "./chapter-ingest"
 export { reviewChapter, type NovelReviewResult } from "./review-adapter"
 export { runNovelLint, buildNovelLintPrompt, type NovelLintResult } from "./lint"
 export { resolveNovelModel, type NovelTaskType } from "./model-resolver"

+ 15 - 0
src/lib/novel/model-resolver.ts

@@ -166,3 +166,18 @@ export function resolveNovelModel(
 
   return toUnusableConfig(llmConfig)
 }
+
+export function formatResolvedModelLabel(
+  config: LlmConfig,
+  providerConfigs: Record<string, ProviderOverride>,
+): string {
+  const model = config.model.trim()
+  if (!model) return "未知模型"
+
+  for (const override of Object.values(providerConfigs)) {
+    const found = override.savedModels?.find((saved) => saved.model === model)
+    if (found?.name?.trim()) return found.name.trim()
+  }
+
+  return model
+}

+ 194 - 12
src/lib/novel/outline-generation.spec.ts

@@ -1,11 +1,42 @@
 import { beforeEach, describe, expect, it, vi } from "vitest"
-import { readFileSync } from "node:fs"
-import { resolve } from "node:path"
-
-const source = readFileSync(resolve(__dirname, "outline-generation.ts"), "utf8")
 
 const mocks = vi.hoisted(() => ({
   buildContextPackMock: vi.fn(),
+  ingestOutlineMock: vi.fn(),
+  syncSnapshotToMemoryMock: vi.fn(),
+  finalizeProjectMemoryRebuildMock: vi.fn(),
+  refreshProjectStateMock: vi.fn(),
+  hasUsableLlmMock: vi.fn(() => true),
+  outlineStore: {
+    tasks: [] as Array<{ id: string; projectPath: string; outlinePath: string | null; status: string; message: string; error: string | null; updatedAt: number }>,
+    createTask: vi.fn((input: { projectPath: string; outlinePath?: string | null }) => {
+      const id = `outline-task-${mocks.outlineStore.tasks.length + 1}`
+      mocks.outlineStore.tasks.unshift({
+        id,
+        projectPath: input.projectPath,
+        outlinePath: input.outlinePath ?? null,
+        status: "ingesting",
+        message: "",
+        error: null,
+        updatedAt: Date.now(),
+      })
+      return id
+    }),
+    updateTask: vi.fn((taskId: string, patch: Record<string, unknown>) => {
+      const task = mocks.outlineStore.tasks.find((item) => item.id === taskId)
+      if (task) Object.assign(task, patch)
+    }),
+  },
+  importProgressStore: {
+    tasks: [] as Array<{ id: string; abortController?: AbortController }>,
+    startTask: vi.fn((input: { abortController?: AbortController }) => {
+      const id = `import-progress-${mocks.importProgressStore.tasks.length + 1}`
+      mocks.importProgressStore.tasks.unshift({ id, abortController: input.abortController })
+      return id
+    }),
+    updateTask: vi.fn(),
+    finishTask: vi.fn(),
+  },
 }))
 
 vi.mock("./context-engine", async () => {
@@ -16,11 +47,72 @@ vi.mock("./context-engine", async () => {
   }
 })
 
-import { buildOutlineGenerationPrompt, buildOutlineRefinementContext } from "./outline-generation"
+vi.mock("./chapter-ingest", () => ({
+  ingestOutline: mocks.ingestOutlineMock,
+  syncSnapshotToMemory: mocks.syncSnapshotToMemoryMock,
+  finalizeProjectMemoryRebuild: mocks.finalizeProjectMemoryRebuildMock,
+}))
+
+vi.mock("@/lib/project-refresh", () => ({
+  refreshProjectState: mocks.refreshProjectStateMock,
+}))
+
+vi.mock("@/lib/has-usable-llm", () => ({
+  hasUsableLlm: mocks.hasUsableLlmMock,
+}))
+
+vi.mock("@/stores/wiki-store", () => ({
+  useWikiStore: {
+    getState: () => ({
+      llmConfig: { provider: "custom", model: "test" },
+      novelConfig: { extractModel: "test-model" },
+      providerConfigs: {},
+    }),
+  },
+}))
+
+vi.mock("@/stores/outline-generation-store", () => ({
+  useOutlineGenerationStore: {
+    getState: () => mocks.outlineStore,
+  },
+}))
+
+vi.mock("@/stores/import-progress-store", () => ({
+  useImportProgressStore: {
+    getState: () => mocks.importProgressStore,
+  },
+}))
+
+import {
+  assertOutlineIngestLlmReady,
+  buildOutlineGenerationPrompt,
+  buildOutlineRefinementContext,
+  formatBulkOutlineIngestResult,
+  OutlineIngestNotReadyError,
+  runOutlineIngestPaths,
+} from "./outline-generation"
 
 describe("outline-generation context fallback", () => {
   beforeEach(() => {
     mocks.buildContextPackMock.mockReset()
+    mocks.ingestOutlineMock.mockReset()
+    mocks.syncSnapshotToMemoryMock.mockReset()
+    mocks.finalizeProjectMemoryRebuildMock.mockReset()
+    mocks.refreshProjectStateMock.mockReset()
+    mocks.hasUsableLlmMock.mockReset()
+    mocks.hasUsableLlmMock.mockReturnValue(true)
+    mocks.outlineStore.tasks = []
+    mocks.importProgressStore.tasks = []
+    mocks.importProgressStore.startTask.mockClear()
+    mocks.importProgressStore.updateTask.mockClear()
+    mocks.importProgressStore.finishTask.mockClear()
+    mocks.syncSnapshotToMemoryMock.mockResolvedValue({
+      writtenEntityPaths: [],
+      memoryPagePaths: [],
+      memorySyncedAt: "2026-01-01T00:00:00.000Z",
+    })
+    mocks.finalizeProjectMemoryRebuildMock.mockResolvedValue(undefined)
+    mocks.refreshProjectStateMock.mockResolvedValue(undefined)
   })
 
   it("still builds a generation prompt when context loading fails", async () => {
@@ -44,12 +136,102 @@ describe("outline-generation context fallback", () => {
   })
 })
 
-describe("outline-generation output workflow", () => {
-  it("keeps all outline refinement and file generation prompts constrained to usable outline正文", () => {
-    expect(source).toContain("buildOutlineRefinementWorkflowPrompt")
-    expect(source).toContain("## AI大纲生成工作流")
-    expect(source).toContain("提取对小说创作有用的关键内容")
-    expect(source).toContain("最终回复只输出大纲标题和大纲正文")
-    expect(source).toContain("不要输出工具调用报告、分析过程、完成报告、下一步行动")
+describe("bulk outline ingest", () => {
+  beforeEach(() => {
+    mocks.buildContextPackMock.mockReset()
+    mocks.ingestOutlineMock.mockReset()
+    mocks.syncSnapshotToMemoryMock.mockReset()
+    mocks.finalizeProjectMemoryRebuildMock.mockReset()
+    mocks.refreshProjectStateMock.mockReset()
+    mocks.hasUsableLlmMock.mockReset()
+    mocks.hasUsableLlmMock.mockReturnValue(true)
+    mocks.outlineStore.tasks = []
+    mocks.importProgressStore.tasks = []
+    mocks.importProgressStore.startTask.mockClear()
+    mocks.importProgressStore.updateTask.mockClear()
+    mocks.importProgressStore.finishTask.mockClear()
+    mocks.syncSnapshotToMemoryMock.mockResolvedValue({
+      writtenEntityPaths: [],
+      memoryPagePaths: [],
+      memorySyncedAt: "2026-01-01T00:00:00.000Z",
+    })
+    mocks.finalizeProjectMemoryRebuildMock.mockResolvedValue(undefined)
+    mocks.refreshProjectStateMock.mockResolvedValue(undefined)
+  })
+
+  it("throws when extract model is unavailable", () => {
+    mocks.hasUsableLlmMock.mockReturnValue(false)
+    expect(() => assertOutlineIngestLlmReady()).toThrow(OutlineIngestNotReadyError)
+  })
+
+  it("defers memory rebuild until all outlines sync", async () => {
+    mocks.ingestOutlineMock
+      .mockResolvedValueOnce({
+        snapshot: { chapterId: "outline-a", chapterNumber: -1 },
+        truncated: false,
+        originalLength: 100,
+        bodyLength: 100,
+        bodyBudget: 1000,
+        failureReason: null,
+      })
+      .mockResolvedValueOnce({
+        snapshot: { chapterId: "outline-b", chapterNumber: -2 },
+        truncated: false,
+        originalLength: 100,
+        bodyLength: 100,
+        bodyBudget: 1000,
+        failureReason: null,
+      })
+
+    const result = await runOutlineIngestPaths("E:/Novel", [
+      "E:/Novel/wiki/outlines/a.md",
+      "E:/Novel/wiki/outlines/b.md",
+    ])
+
+    expect(result).toMatchObject({ total: 2, succeeded: 2, failed: 0 })
+    expect(mocks.syncSnapshotToMemoryMock).toHaveBeenCalledTimes(2)
+    expect(mocks.syncSnapshotToMemoryMock.mock.calls[0]?.[2]).toEqual({
+      deferStructuredMemoryExport: true,
+      deferDerivedRebuild: true,
+    })
+    expect(mocks.finalizeProjectMemoryRebuildMock).toHaveBeenCalledTimes(1)
+    expect(mocks.refreshProjectStateMock).toHaveBeenCalledTimes(1)
+  })
+
+  it("collects per-file failures", async () => {
+    mocks.ingestOutlineMock
+      .mockResolvedValueOnce({
+        snapshot: { chapterId: "outline-a", chapterNumber: -1 },
+        truncated: false,
+        originalLength: 100,
+        bodyLength: 100,
+        bodyBudget: 1000,
+        failureReason: null,
+      })
+      .mockRejectedValueOnce(new Error("JSON 解析失败"))
+
+    const result = await runOutlineIngestPaths("E:/Novel", [
+      "E:/Novel/wiki/outlines/a.md",
+      "E:/Novel/wiki/outlines/b.md",
+    ])
+
+    expect(result.succeeded).toBe(1)
+    expect(result.failed).toBe(1)
+    expect(result.failures).toHaveLength(1)
+    expect(result.failures[0]?.name).toBe("b")
+    expect(result.failures[0]?.reason).toContain("JSON 解析失败")
+  })
+
+  it("formats failure details for the banner", () => {
+    const message = formatBulkOutlineIngestResult({
+      total: 2,
+      succeeded: 1,
+      failed: 1,
+      failures: [{ name: "b", path: "E:/Novel/wiki/outlines/b.md", reason: "JSON 解析失败" }],
+    })
+
+    expect(message).toContain("失败")
+    expect(message).toContain("b")
+    expect(message).toContain("JSON 解析失败")
   })
 })

+ 499 - 113
src/lib/novel/outline-generation.ts

@@ -11,10 +11,15 @@ import { useImportProgressStore } from "@/stores/import-progress-store"
 import type { LlmConfig } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
-import { ingestOutline } from "./chapter-ingest"
+import { mapWithConcurrency } from "@/lib/async-pool"
+import {
+  finalizeProjectMemoryRebuild,
+  ingestOutline,
+  syncSnapshotToMemory,
+  type OutlineIngestResult,
+} from "./chapter-ingest"
 import { buildContextPack, type ContextPack } from "./context-engine"
-import { resolveDefaultModel, resolveModelConfig } from "@/lib/novel/model-resolver"
-import { readSoulDoc } from "./soul-doc"
+import { resolveDefaultModel, resolveModelConfig, resolveNovelModel } from "@/lib/novel/model-resolver"
 
 export type OutlineSectionGenerationKey =
   | "chapterOutlines"
@@ -131,7 +136,6 @@ function appendContextSection(sections: string[], title: string, content: string
 
 function formatOutlineRefinementContext(pack: ContextPack): string {
   const sections: string[] = []
-  appendContextSection(sections, "作品灵魂与总则", pack.soulDoc)
   appendContextSection(sections, "已有大纲", pack.outline)
   appendContextSection(sections, "最近剧情摘要", pack.recentSummaries)
   appendContextSection(sections, "人物状态变化", pack.characterStates)
@@ -233,58 +237,6 @@ async function safeBuildOutlineContextPack(projectPath: string, task: string): P
   }
 }
 
-async function ensureSoulDocInPrompt(projectPath: string, prompt: string): Promise<string> {
-  let nextPrompt = prompt
-  try {
-    if (!nextPrompt.includes("作品灵魂") && !nextPrompt.includes("soulDoc") && !nextPrompt.includes("灵魂与总则")) {
-      const soulDoc = await readSoulDoc(projectPath)
-      if (soulDoc.trim()) {
-        nextPrompt = `## 作品灵魂与总则\n${soulDoc}\n\n${nextPrompt}`
-      }
-    }
-  } catch {}
-  if (!nextPrompt.includes("## AI大纲生成工作流")) {
-    nextPrompt = [
-      "## AI大纲生成工作流",
-      "提取请求关键词,识别用户意图,读取已有大纲、章节、记忆和推演信息,提取对小说创作有用的关键内容,再结合用户要用的 skill + soul.md 约束生成。",
-      "最终回复只输出大纲标题和大纲正文;不要输出工具调用报告、分析过程、完成报告、下一步行动。",
-      "",
-      nextPrompt,
-    ].join("\n")
-  }
-  return nextPrompt
-}
-
-function getOutlineRefinementOutputRules(config: OutlineSectionGenerationConfig): string {
-  switch (config.key) {
-    case "chapterOutlines":
-      return "章节细纲必须按章节写清:章节目标、核心事件、冲突、转折、结尾钩子、承接关系。"
-    case "characterBriefs":
-      return "人物小传必须写清:人物定位、目标与动机、欲望和恐惧、关系变化、冲突点、成长或崩坏路径。"
-    case "organizationsOutline":
-      return "组织势力设定必须写清:阵营目标、资源、内部矛盾、外部冲突、剧情作用、与主角线关系。"
-    case "powerSystem":
-      return "金手指与能力体系必须写清:规则、限制、代价、成长路径、反制方式、剧情用途。"
-    case "foreshadowingPlan":
-      return "伏笔计划必须写清:伏笔名称、埋设位置、表层误导、真实指向、推进节点、回收位置。"
-    case "locationsOutline":
-      return "地点设定必须写清:地点定位、所属势力、空间规则、资源与限制、可触发事件、剧情作用。"
-  }
-}
-
-function buildOutlineRefinementWorkflowPrompt(config: OutlineSectionGenerationConfig): string {
-  return [
-    "## AI大纲生成工作流",
-    "1. 提取请求关键词:确认要生成的大纲分项、范围和已有约束。",
-    "2. 识别用户意图:本次是生成/完善大纲正文,不是审稿报告、工具报告或分析说明。",
-    "3. 提取对小说创作有用的关键内容:章节目标、冲突、伏笔、人物动机、设定限制、时间线承接和结尾钩子。",
-    "4. 结合用户要用的 skill + soul.md 约束,生成可直接保存的大纲正文。",
-    "5. 结果强约束收敛:最终回复只输出大纲标题和大纲正文。",
-    "不要输出工具调用报告、分析过程、完成报告、下一步行动。",
-    getOutlineRefinementOutputRules(config),
-  ].join("\n")
-}
-
 function buildSectionRefinementPrompt(
   context: string,
   config: OutlineSectionGenerationConfig,
@@ -299,9 +251,6 @@ function buildSectionRefinementPrompt(
     "2. 本次用户要求只能用于补充、聚焦和完善,不得改写既定主线和核心设定。",
     "3. 如果信息不足,只能做最小必要补完,且必须与现有设定兼容。",
     "4. 只输出正文 Markdown,不要输出 JSON、代码块、解释、前言或额外说明。",
-    "5. 不要输出工具调用报告、分析过程、完成报告、下一步行动。",
-    "",
-    buildOutlineRefinementWorkflowPrompt(config),
     "",
     "已有大纲与项目记忆:",
     context || "当前暂无可读取的项目记忆,请仅基于已有大纲与本次要求进行细化。",
@@ -476,8 +425,7 @@ export async function generateOutlineFile(
   let content = ""
   let streamError: Error | null = null
 
-  const safePrompt = await ensureSoulDocInPrompt(projectPath, prompt)
-  const messages: ChatMessage[] = [{ role: "user", content: safePrompt }]
+  const messages: ChatMessage[] = [{ role: "user", content: prompt }]
 
   await streamChat(llmConfig, messages, {
     onToken: (token) => {
@@ -701,6 +649,398 @@ export function createOutlineIngestTask(projectPath: string, outlinePath: string
   })
 }
 
+export class OutlineIngestNotReadyError extends Error {
+  constructor() {
+    super(i18n.t("novel.outlineGenerator.ingestNoLlm"))
+    this.name = "OutlineIngestNotReadyError"
+  }
+}
+
+export function assertOutlineIngestLlmReady(): void {
+  const state = useWikiStore.getState()
+  const runtimeLlmConfig = resolveNovelModel(state.llmConfig, state.novelConfig, "extract")
+  if (!hasUsableLlm(runtimeLlmConfig, state.providerConfigs)) {
+    throw new OutlineIngestNotReadyError()
+  }
+}
+
+export const BULK_OUTLINE_INGEST_CONCURRENCY = 2
+
+export interface OutlineIngestFailure {
+  name: string
+  path: string
+  reason: string
+}
+
+export interface BulkOutlineIngestResult {
+  total: number
+  succeeded: number
+  failed: number
+  cancelled?: boolean
+  failures: OutlineIngestFailure[]
+}
+
+export interface RunOutlineIngestPathsOptions {
+  onProgressTaskStarted?: (taskId: string) => void
+}
+
+export interface OutlineIngestTaskResult {
+  success: boolean
+  outlinePath: string
+  outlineFileName: string
+  error?: string
+  truncated?: boolean
+  bodyLength?: number
+  originalLength?: number
+  bodyBudget?: number
+}
+
+export interface RunOutlineIngestTaskOptions {
+  signal?: AbortSignal
+  parentProgressId?: string
+  manageProgress?: boolean
+}
+
+import { getOutlineFileName } from "./outline-ingest-utils"
+
+function buildOutlineIngestFailureReason(err: unknown): string {
+  if (err instanceof Error) return err.message
+  return String(err)
+}
+
+function getOutlineIngestCancelledMessage(): string {
+  return i18n.t("novel.outlineGenerator.ingestCancelledNotification")
+}
+
+function finalizeOutstandingIngestTasks(taskIds: string[], cancelled: boolean): void {
+  const message = cancelled
+    ? getOutlineIngestCancelledMessage()
+    : i18n.t("novel.outlineGenerator.ingestFailedNotification")
+  for (const taskId of taskIds) {
+    const task = useOutlineGenerationStore.getState().tasks.find((item) => item.id === taskId)
+    if (task?.status !== "ingesting") continue
+    useOutlineGenerationStore.getState().updateTask(taskId, {
+      status: "error",
+      message,
+      error: message,
+    })
+  }
+}
+
+/** Clear orphaned ingest tasks when no outline import progress is running. */
+export function reconcileStaleOutlineIngestTasks(projectPath: string): void {
+  const pp = normalizePath(projectPath)
+  const hasRunningOutlineImport = useImportProgressStore.getState().tasks.some(
+    (task) => task.projectPath === pp && task.kind === "outline" && task.status === "running",
+  )
+  if (hasRunningOutlineImport) return
+
+  const message = getOutlineIngestCancelledMessage()
+  for (const task of useOutlineGenerationStore.getState().tasks) {
+    if (task.projectPath !== pp || task.kind !== "ingest" || task.status !== "ingesting") continue
+    useOutlineGenerationStore.getState().updateTask(task.id, {
+      status: "error",
+      message,
+      error: message,
+    })
+  }
+}
+
+function buildBulkIngestProgressMessage(result: BulkOutlineIngestResult): string {
+  if (result.cancelled) {
+    return i18n.t("novel.outlineGenerator.bulkIngestCancelledProgress", {
+      succeeded: result.succeeded,
+      total: result.total,
+    })
+  }
+  if (result.failed > 0) {
+    const preview = result.failures
+      .slice(0, 3)
+      .map((failure) => `${failure.name}: ${failure.reason}`)
+      .join(";")
+    return i18n.t("novel.outlineGenerator.bulkIngestProgressWithFailures", {
+      succeeded: result.succeeded,
+      failed: result.failed,
+      preview,
+    })
+  }
+  return i18n.t("novel.outlineGenerator.bulkIngestProgressDone", {
+    succeeded: result.succeeded,
+    total: result.total,
+  })
+}
+
+export function formatBulkOutlineIngestResult(result: BulkOutlineIngestResult): string {
+  if (result.total === 0) {
+    return i18n.t("novel.outlineGenerator.bulkIngestEmpty")
+  }
+  if (result.cancelled) {
+    return i18n.t("novel.outlineGenerator.bulkIngestCancelled", {
+      succeeded: result.succeeded,
+      failed: result.failed,
+      total: result.total,
+    })
+  }
+  if (result.failed === 0) {
+    return i18n.t("novel.outlineGenerator.bulkIngestResult", {
+      total: result.total,
+      succeeded: result.succeeded,
+      failed: result.failed,
+    })
+  }
+
+  const lines = result.failures
+    .slice(0, 5)
+    .map((failure) => `· ${failure.name}: ${failure.reason}`)
+  const extra = result.failures.length > 5
+    ? `\n${i18n.t("novel.outlineGenerator.bulkIngestMoreFailures", { count: result.failures.length - 5 })}`
+    : ""
+
+  return [
+    i18n.t("novel.outlineGenerator.bulkIngestResultWithFailures", {
+      total: result.total,
+      succeeded: result.succeeded,
+      failed: result.failed,
+    }),
+    ...lines,
+  ].join("\n") + extra
+}
+
+type OutlineExtractOutcome =
+  | { kind: "success"; path: string; taskId: string; ingestResult: OutlineIngestResult }
+  | { kind: "failure"; path: string; taskId: string; reason: string }
+  | { kind: "skipped"; path: string; taskId: string }
+
+export async function runOutlineIngestPaths(
+  projectPath: string,
+  outlinePaths: string[],
+  options?: RunOutlineIngestPathsOptions,
+): Promise<BulkOutlineIngestResult> {
+  assertOutlineIngestLlmReady()
+
+  const pp = normalizePath(projectPath)
+  const normalizedPaths = outlinePaths.map((path) => normalizePath(path))
+  if (normalizedPaths.length === 0) {
+    return { total: 0, succeeded: 0, failed: 0, failures: [] }
+  }
+
+  const abortController = new AbortController()
+  const signal = abortController.signal
+  const taskIds = normalizedPaths.map((outlinePath) => createOutlineIngestTask(pp, outlinePath))
+  const progressTaskId = useImportProgressStore.getState().startTask({
+    projectPath: pp,
+    kind: "outline",
+    total: normalizedPaths.length,
+    currentTitle: getOutlineFileName(normalizedPaths[0] ?? ""),
+    message: i18n.t("novel.outlineGenerator.bulkIngesting"),
+    abortController,
+    concurrency: BULK_OUTLINE_INGEST_CONCURRENCY,
+    activeTitles: [],
+  })
+  options?.onProgressTaskStarted?.(progressTaskId)
+
+  const failures: OutlineIngestFailure[] = []
+  const extractProgress = {
+    completed: 0,
+    inFlight: new Set<string>(),
+  }
+
+  function publishExtractProgress(): void {
+    const activeTitles = [...extractProgress.inFlight]
+    useImportProgressStore.getState().updateTask(progressTaskId, {
+      completed: extractProgress.completed,
+      currentTitle: activeTitles[0] ?? "",
+      activeTitles,
+      concurrency: BULK_OUTLINE_INGEST_CONCURRENCY,
+      message: activeTitles.length > 1
+        ? i18n.t("novel.outlineGenerator.bulkIngestParallel", {
+          active: activeTitles.length,
+          concurrency: BULK_OUTLINE_INGEST_CONCURRENCY,
+        })
+        : i18n.t("novel.outlineGenerator.bulkIngesting"),
+    })
+  }
+
+  const extractOutcomes = (await mapWithConcurrency(
+    normalizedPaths,
+    BULK_OUTLINE_INGEST_CONCURRENCY,
+    async (outlinePath, index) => {
+      const outlineFileName = getOutlineFileName(outlinePath)
+      const taskId = taskIds[index]!
+
+      if (signal.aborted) {
+        useOutlineGenerationStore.getState().updateTask(taskId, {
+          status: "error",
+          message: getOutlineIngestCancelledMessage(),
+          error: getOutlineIngestCancelledMessage(),
+        })
+        return { kind: "skipped", path: outlinePath, taskId } satisfies OutlineExtractOutcome
+      }
+
+      extractProgress.inFlight.add(outlineFileName)
+      publishExtractProgress()
+
+      useOutlineGenerationStore.getState().updateTask(taskId, {
+        status: "ingesting",
+        message: i18n.t("novel.outlineGenerator.ingestingNotification"),
+        error: null,
+      })
+
+      try {
+        const ingestResult = await ingestOutline(pp, outlinePath, signal, { skipSync: true })
+        if (ingestResult.failureReason === "no_llm") {
+          const reason = i18n.t("novel.outlineGenerator.ingestNoLlm")
+          useOutlineGenerationStore.getState().updateTask(taskId, {
+            status: "error",
+            message: reason,
+            error: reason,
+          })
+          return { kind: "failure", path: outlinePath, taskId, reason } satisfies OutlineExtractOutcome
+        }
+        if (!ingestResult.snapshot) {
+          const reason = i18n.t("novel.outlineGenerator.ingestFailedNotification")
+          useOutlineGenerationStore.getState().updateTask(taskId, {
+            status: "error",
+            message: reason,
+            error: reason,
+          })
+          return { kind: "failure", path: outlinePath, taskId, reason } satisfies OutlineExtractOutcome
+        }
+        return { kind: "success", path: outlinePath, taskId, ingestResult } satisfies OutlineExtractOutcome
+      } catch (err) {
+        const reason = buildOutlineIngestFailureReason(err)
+        useOutlineGenerationStore.getState().updateTask(taskId, {
+          status: "error",
+          message: reason,
+          error: reason,
+        })
+        return { kind: "failure", path: outlinePath, taskId, reason } satisfies OutlineExtractOutcome
+      } finally {
+        extractProgress.inFlight.delete(outlineFileName)
+        if (!signal.aborted) {
+          extractProgress.completed += 1
+        } else {
+          const task = useOutlineGenerationStore.getState().tasks.find((item) => item.id === taskId)
+          if (task?.status === "ingesting") {
+            useOutlineGenerationStore.getState().updateTask(taskId, {
+              status: "error",
+              message: getOutlineIngestCancelledMessage(),
+              error: getOutlineIngestCancelledMessage(),
+            })
+          }
+        }
+        publishExtractProgress()
+      }
+    },
+    { signal },
+  )).filter((outcome): outcome is OutlineExtractOutcome => outcome != null)
+
+  const cancelled = signal.aborted
+  const extractSuccesses = extractOutcomes.filter(
+    (outcome): outcome is Extract<OutlineExtractOutcome, { kind: "success" }> => outcome.kind === "success",
+  )
+
+  for (const outcome of extractOutcomes) {
+    if (outcome.kind === "failure") {
+      failures.push({
+        name: getOutlineFileName(outcome.path),
+        path: outcome.path,
+        reason: outcome.reason,
+      })
+    }
+  }
+
+  let succeeded = 0
+  let syncCompleted = 0
+
+  for (const item of extractSuccesses) {
+    if (signal.aborted) break
+
+    const outlineFileName = getOutlineFileName(item.path)
+    useImportProgressStore.getState().updateTask(progressTaskId, {
+      completed: syncCompleted,
+      currentTitle: i18n.t("novel.outlineGenerator.bulkIngestSyncing", { name: outlineFileName }),
+      activeTitles: [outlineFileName],
+      concurrency: 1,
+      message: i18n.t("novel.outlineGenerator.bulkIngestSyncing", { name: outlineFileName }),
+    })
+
+    try {
+      await syncSnapshotToMemory(pp, item.ingestResult.snapshot!, {
+        deferStructuredMemoryExport: true,
+        deferDerivedRebuild: true,
+      })
+      const successMessage = item.ingestResult.truncated
+        ? i18n.t("novel.outlineGenerator.ingestSuccessTruncatedNotification", {
+          used: item.ingestResult.bodyLength,
+          total: item.ingestResult.originalLength,
+          budget: item.ingestResult.bodyBudget,
+        })
+        : i18n.t("novel.outlineGenerator.ingestSuccessNotification")
+      useOutlineGenerationStore.getState().updateTask(item.taskId, {
+        status: "done",
+        message: successMessage,
+        error: null,
+      })
+      succeeded += 1
+    } catch (err) {
+      const reason = buildOutlineIngestFailureReason(err)
+      useOutlineGenerationStore.getState().updateTask(item.taskId, {
+        status: "error",
+        message: reason,
+        error: reason,
+      })
+      failures.push({
+        name: outlineFileName,
+        path: item.path,
+        reason,
+      })
+    } finally {
+      syncCompleted += 1
+      useImportProgressStore.getState().updateTask(progressTaskId, {
+        completed: syncCompleted,
+        activeTitles: [],
+      })
+    }
+  }
+
+  if (succeeded > 0) {
+    await finalizeProjectMemoryRebuild(pp)
+    await refreshProjectState(pp)
+  }
+
+  const result: BulkOutlineIngestResult = {
+    total: normalizedPaths.length,
+    succeeded,
+    failed: failures.length,
+    cancelled: cancelled || undefined,
+    failures,
+  }
+
+  useImportProgressStore.getState().finishTask(
+    progressTaskId,
+    cancelled ? "cancelled" : failures.length > 0 ? "error" : "done",
+    {
+      completed: syncCompleted,
+      total: normalizedPaths.length,
+      currentTitle: "",
+      activeTitles: [],
+      message: buildBulkIngestProgressMessage(result),
+    },
+  )
+
+  finalizeOutstandingIngestTasks(taskIds, cancelled)
+
+  return result
+}
+
+export async function runSingleOutlineIngest(
+  projectPath: string,
+  outlinePath: string,
+): Promise<BulkOutlineIngestResult> {
+  return runOutlineIngestPaths(projectPath, [normalizePath(outlinePath)])
+}
+
 export function startOutlineIngestTask(projectPath: string, outlinePath: string): string {
   const taskId = createOutlineIngestTask(projectPath, outlinePath)
   void runOutlineIngestTask(taskId)
@@ -723,11 +1063,7 @@ function collectOutlineMarkdownPaths(
   return paths
 }
 
-export async function runBulkOutlineIngest(projectPath: string): Promise<{
-  total: number
-  succeeded: number
-  failed: number
-}> {
+export async function runBulkOutlineIngest(projectPath: string): Promise<BulkOutlineIngestResult> {
   const pp = normalizePath(projectPath)
   let outlinePaths: string[] = []
 
@@ -736,41 +1072,36 @@ export async function runBulkOutlineIngest(projectPath: string): Promise<{
     outlinePaths = collectOutlineMarkdownPaths(tree as Array<{ path: string; name: string; is_dir: boolean; children?: Array<{ path: string; name: string; is_dir: boolean; children?: unknown[] }> }>)
       .sort((a, b) => a.localeCompare(b, "zh-CN"))
   } catch {
-    return { total: 0, succeeded: 0, failed: 0 }
-  }
-
-  let succeeded = 0
-  let failed = 0
-
-  for (const outlinePath of outlinePaths) {
-    const taskId = createOutlineIngestTask(pp, outlinePath)
-    await runOutlineIngestTask(taskId)
-    const task = useOutlineGenerationStore.getState().tasks.find((item) => item.id === taskId)
-    if (task?.status === "done") succeeded += 1
-    else failed += 1
+    return { total: 0, succeeded: 0, failed: 0, failures: [] }
   }
 
-  return {
-    total: outlinePaths.length,
-    succeeded,
-    failed,
-  }
+  return runOutlineIngestPaths(pp, outlinePaths)
 }
 
-export async function runOutlineIngestTask(taskId: string): Promise<void> {
+export async function runOutlineIngestTask(
+  taskId: string,
+  options: RunOutlineIngestTaskOptions = {},
+): Promise<OutlineIngestTaskResult | null> {
   const task = useOutlineGenerationStore.getState().tasks.find((item) => item.id === taskId)
-  if (!task?.outlinePath) return
+  if (!task?.outlinePath) return null
 
-  const outlineFileName = task.outlinePath.split("/").pop()?.replace(".md", "") || "大纲"
-  const abortController = new AbortController()
-  const progressTaskId = useImportProgressStore.getState().startTask({
-    projectPath: task.projectPath,
-    kind: "outline",
-    total: 1,
-    currentTitle: outlineFileName,
-    message: "正在提取大纲记忆",
-    abortController,
-  })
+  const outlinePath = task.outlinePath
+  const outlineFileName = getOutlineFileName(outlinePath)
+  const manageProgress = options.manageProgress !== false
+  const abortController = options.signal ? null : new AbortController()
+  const signal = options.signal ?? abortController!.signal
+  let progressTaskId = options.parentProgressId
+
+  if (manageProgress && !progressTaskId) {
+    progressTaskId = useImportProgressStore.getState().startTask({
+      projectPath: task.projectPath,
+      kind: "outline",
+      total: 1,
+      currentTitle: outlineFileName,
+      message: "正在提取大纲记忆",
+      abortController: abortController ?? undefined,
+    })
+  }
 
   try {
     useOutlineGenerationStore.getState().updateTask(taskId, {
@@ -778,35 +1109,90 @@ export async function runOutlineIngestTask(taskId: string): Promise<void> {
       message: i18n.t("novel.outlineGenerator.ingestingNotification"),
       error: null,
     })
-    const snapshot = await ingestOutline(task.projectPath, task.outlinePath, abortController.signal)
+    const ingestResult = await ingestOutline(task.projectPath, outlinePath, signal)
+    const snapshot = ingestResult.snapshot
+
+    if (ingestResult.failureReason === "no_llm") {
+      const reason = i18n.t("novel.outlineGenerator.ingestNoLlm")
+      useOutlineGenerationStore.getState().updateTask(taskId, {
+        status: "error",
+        message: reason,
+        error: reason,
+      })
+      if (manageProgress && progressTaskId) {
+        useImportProgressStore.getState().finishTask(progressTaskId, "error", {
+          completed: 0,
+          total: 1,
+          currentTitle: "",
+          message: `${outlineFileName} 提取失败:${reason}`,
+        })
+      }
+      return { success: false, outlinePath, outlineFileName, error: reason }
+    }
+
     if (snapshot) {
       await refreshProjectState(task.projectPath)
     }
+
+    const successMessage = snapshot
+      ? ingestResult.truncated
+        ? i18n.t("novel.outlineGenerator.ingestSuccessTruncatedNotification", {
+          used: ingestResult.bodyLength,
+          total: ingestResult.originalLength,
+          budget: ingestResult.bodyBudget,
+        })
+        : i18n.t("novel.outlineGenerator.ingestSuccessNotification")
+      : i18n.t("novel.outlineGenerator.ingestFailedNotification")
+    const errorMessage = snapshot ? undefined : i18n.t("novel.outlineGenerator.ingestFailedNotification")
+
     useOutlineGenerationStore.getState().updateTask(taskId, {
       status: snapshot ? "done" : "error",
-      message: snapshot
-        ? i18n.t("novel.outlineGenerator.ingestSuccessNotification")
-        : i18n.t("novel.outlineGenerator.ingestFailedNotification"),
-      error: snapshot ? null : i18n.t("novel.outlineGenerator.ingestFailedNotification"),
-    })
-    useImportProgressStore.getState().finishTask(progressTaskId, snapshot ? "done" : "error", {
-      completed: snapshot ? 1 : 0,
-      total: 1,
-      currentTitle: "",
-      message: snapshot ? `${outlineFileName} 提取完成` : `${outlineFileName} 提取失败`,
+      message: successMessage,
+      error: errorMessage ?? null,
     })
+
+    if (manageProgress && progressTaskId) {
+      useImportProgressStore.getState().finishTask(progressTaskId, snapshot ? "done" : "error", {
+        completed: snapshot ? 1 : 0,
+        total: 1,
+        currentTitle: "",
+        message: snapshot
+          ? ingestResult.truncated
+            ? i18n.t("novel.outlineGenerator.ingestProgressTruncated", {
+              name: outlineFileName,
+              used: ingestResult.bodyLength,
+              total: ingestResult.originalLength,
+            })
+            : `${outlineFileName} 提取完成`
+          : `${outlineFileName} 提取失败`,
+      })
+    }
+
+    return {
+      success: Boolean(snapshot),
+      outlinePath,
+      outlineFileName,
+      error: errorMessage,
+      truncated: ingestResult.truncated,
+      bodyLength: ingestResult.bodyLength,
+      originalLength: ingestResult.originalLength,
+      bodyBudget: ingestResult.bodyBudget,
+    }
   } catch (err) {
-    const message = err instanceof Error ? err.message : String(err)
+    const message = buildOutlineIngestFailureReason(err)
     useOutlineGenerationStore.getState().updateTask(taskId, {
       status: "error",
       message,
       error: message,
     })
-    useImportProgressStore.getState().finishTask(progressTaskId, "error", {
-      completed: 0,
-      total: 1,
-      currentTitle: "",
-      message: `${outlineFileName} 提取失败`,
-    })
+    if (manageProgress && progressTaskId) {
+      useImportProgressStore.getState().finishTask(progressTaskId, "error", {
+        completed: 0,
+        total: 1,
+        currentTitle: "",
+        message: `${outlineFileName} 提取失败:${message}`,
+      })
+    }
+    return { success: false, outlinePath, outlineFileName, error: message }
   }
 }

+ 38 - 0
src/lib/novel/outline-ingest-utils.spec.ts

@@ -0,0 +1,38 @@
+import { describe, expect, it, vi } from "vitest"
+import { getOutlineFileName, getOutlineIngestIdentity, outlineSnapshotExists } from "./outline-ingest-utils"
+
+vi.mock("@/commands/fs", () => ({
+  fileExists: vi.fn(),
+}))
+
+import { fileExists } from "@/commands/fs"
+
+describe("getOutlineIngestIdentity", () => {
+  it("derives a stable negative chapter number and snapshot path from outline filename", () => {
+    const identity = getOutlineIngestIdentity("/proj", "/proj/wiki/outlines/vol-1-outline.md")
+    expect(identity.outlineName).toBe("vol-1-outline")
+    expect(identity.chapterNumber).toBeLessThan(0)
+    expect(identity.snapshotJsonPath).toMatch(/\.novel\/snapshots\/outline-\d{3}\.snapshot\.json$/)
+  })
+
+  it("returns the same identity for the same outline path", () => {
+    const left = getOutlineIngestIdentity("/proj", "/proj/wiki/outlines/plot.md")
+    const right = getOutlineIngestIdentity("/proj", "/proj/wiki/outlines/plot.md")
+    expect(left).toEqual(right)
+  })
+})
+
+describe("getOutlineFileName", () => {
+  it("strips extension and falls back when path is empty", () => {
+    expect(getOutlineFileName("/a/b/story-outline.md")).toBe("story-outline")
+    expect(getOutlineFileName("")).toBe("大纲")
+  })
+})
+
+describe("outlineSnapshotExists", () => {
+  it("checks the derived snapshot json path", async () => {
+    vi.mocked(fileExists).mockResolvedValueOnce(true)
+    await expect(outlineSnapshotExists("/proj", "/proj/wiki/outlines/plot.md")).resolves.toBe(true)
+    expect(fileExists).toHaveBeenCalledWith(expect.stringContaining("/proj/.novel/snapshots/outline-"))
+  })
+})

+ 37 - 0
src/lib/novel/outline-ingest-utils.ts

@@ -0,0 +1,37 @@
+import { fileExists } from "@/commands/fs"
+import { normalizePath } from "@/lib/path-utils"
+
+export interface OutlineIngestIdentity {
+  outlineName: string
+  chapterNumber: number
+  snapshotJsonPath: string
+}
+
+export function getOutlineIngestIdentity(projectPath: string, outlinePath: string): OutlineIngestIdentity {
+  const normalizedOutlinePath = normalizePath(outlinePath)
+  const fileName = normalizedOutlinePath.split("/").pop() ?? "outline"
+  const outlineName = fileName.replace(/\.\w+$/, "")
+
+  let hash = 0
+  for (let i = 0; i < outlineName.length; i++) {
+    hash = ((hash << 5) - hash + outlineName.charCodeAt(i)) | 0
+  }
+  const chapterNumber = -(Math.abs(hash % 999) + 1)
+  const prefix = `outline-${String(Math.abs(chapterNumber)).padStart(3, "0")}`
+  const snapshotJsonPath = `${normalizePath(projectPath)}/.novel/snapshots/${prefix}.snapshot.json`
+
+  return { outlineName, chapterNumber, snapshotJsonPath }
+}
+
+export function getOutlineFileName(outlinePath: string): string {
+  return outlinePath.split("/").pop()?.replace(/\.\w+$/, "") || "大纲"
+}
+
+export async function outlineSnapshotExists(projectPath: string, outlinePath: string): Promise<boolean> {
+  const { snapshotJsonPath } = getOutlineIngestIdentity(projectPath, outlinePath)
+  try {
+    return await fileExists(snapshotJsonPath)
+  } catch {
+    return false
+  }
+}

+ 5 - 1
src/lib/novel/previous-chapters-analysis.ts

@@ -19,6 +19,7 @@ export async function analyzePreviousChapters(
   currentChapterNumber: number,
   llmConfig: LlmConfig,
   analysisCount: number = 3,
+  signal?: AbortSignal,
 ): Promise<string> {
   if (currentChapterNumber <= 1) return ""
 
@@ -26,6 +27,7 @@ export async function analyzePreviousChapters(
 
   // 读取前N章的完整内容
   for (let i = Math.max(1, currentChapterNumber - analysisCount); i < currentChapterNumber; i++) {
+    if (signal?.aborted) throw new Error("已停止生成")
     try {
       const results = await searchWiki(projectPath, `chapter_number:${i}`)
       if (results.length > 0) {
@@ -55,9 +57,11 @@ export async function analyzePreviousChapters(
       onToken: (token) => { analysis += token },
       onDone: () => {},
       onError: () => {},
-    }
+    },
+    signal,
   )
 
+  if (signal?.aborted) throw new Error("已停止生成")
   return analysis.trim()
 }
 

+ 2 - 0
src/lib/novel/review-adapter.ts

@@ -7,6 +7,7 @@ import { contextPackToPrompt, buildContextPack, type ContextPack } from "./conte
 import { buildCharacterAuraContext } from "./character-aura"
 import { resolveNovelModel } from "./model-resolver"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
+import { rethrowIfUserAbort } from "@/lib/user-abort"
 
 export interface NovelReviewResult {
   severity: "error" | "warning" | "info"
@@ -309,6 +310,7 @@ ${langReminder}`
 
     return chunkResults.flat()
   } catch (err) {
+    rethrowIfUserAbort(err, signal)
     console.error("[Novel Review] Failed:", err)
     return []
   }

+ 24 - 0
src/lib/user-abort.spec.ts

@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest"
+import { isUserAbortError, rethrowIfUserAbort, USER_ABORT_MESSAGE } from "./user-abort"
+
+describe("user-abort", () => {
+  it("detects explicit user abort message", () => {
+    expect(isUserAbortError(new Error(USER_ABORT_MESSAGE))).toBe(true)
+  })
+
+  it("detects aborted signal", () => {
+    const controller = new AbortController()
+    controller.abort()
+    expect(isUserAbortError(new Error("network"), controller.signal)).toBe(true)
+  })
+
+  it("rethrows user abort errors", () => {
+    const controller = new AbortController()
+    controller.abort()
+    expect(() => rethrowIfUserAbort(new Error("timeout"), controller.signal)).toThrow(USER_ABORT_MESSAGE)
+  })
+
+  it("ignores unrelated errors", () => {
+    expect(() => rethrowIfUserAbort(new Error("timeout"))).not.toThrow()
+  })
+})

+ 18 - 0
src/lib/user-abort.ts

@@ -0,0 +1,18 @@
+export const USER_ABORT_MESSAGE = "已停止生成"
+
+export function throwIfAborted(signal?: AbortSignal): void {
+  if (signal?.aborted) throw new Error(USER_ABORT_MESSAGE)
+}
+
+export function isUserAbortError(error: unknown, signal?: AbortSignal): boolean {
+  if (signal?.aborted) return true
+  if (!(error instanceof Error)) return false
+  if (error.message === USER_ABORT_MESSAGE) return true
+  if (error.name === "AbortError") return true
+  return /request cancelled|request canceled|aborted/i.test(error.message)
+}
+
+export function rethrowIfUserAbort(error: unknown, signal?: AbortSignal): never | void {
+  if (!isUserAbortError(error, signal)) return
+  throw new Error(USER_ABORT_MESSAGE)
+}

+ 18 - 1
src/stores/import-progress-store.ts

@@ -12,6 +12,8 @@ export interface ImportProgressTask {
   completed: number
   total: number
   currentTitle: string
+  activeTitles?: string[]
+  concurrency?: number
   message?: string
   error?: string
   cancelling: boolean
@@ -27,6 +29,8 @@ interface StartImportProgressTaskInput {
   currentTitle?: string
   message?: string
   abortController?: AbortController
+  activeTitles?: string[]
+  concurrency?: number
 }
 
 export interface ImportProgressState {
@@ -62,6 +66,8 @@ export const useImportProgressStore = create<ImportProgressState>((set, get) =>
           total: input.total,
           currentTitle: input.currentTitle ?? "",
           message: input.message,
+          activeTitles: input.activeTitles ?? [],
+          concurrency: input.concurrency,
           cancelling: false,
           createdAt: now,
           updatedAt: now,
@@ -82,7 +88,13 @@ export const useImportProgressStore = create<ImportProgressState>((set, get) =>
     }))
   },
   finishTask: (taskId, status, patch = {}) => {
+    const task = get().tasks.find((item) => item.id === taskId)
     get().updateTask(taskId, { ...patch, status, cancelling: false })
+    if (task?.kind === "outline" && status !== "running") {
+      void import("@/lib/novel/outline-generation").then(({ reconcileStaleOutlineIngestTasks }) => {
+        reconcileStaleOutlineIngestTasks(task.projectPath)
+      })
+    }
   },
   markCancelling: (taskId) => {
     get().updateTask(taskId, { cancelling: true })
@@ -91,7 +103,12 @@ export const useImportProgressStore = create<ImportProgressState>((set, get) =>
     const task = get().tasks.find((t) => t.id === taskId)
     if (!task) return
     task.abortController?.abort()
-    get().updateTask(taskId, { status: "cancelled", cancelling: false })
+    get().updateTask(taskId, { status: "cancelled", cancelling: false, activeTitles: [] })
+    if (task.kind === "outline") {
+      void import("@/lib/novel/outline-generation").then(({ reconcileStaleOutlineIngestTasks }) => {
+        reconcileStaleOutlineIngestTasks(task.projectPath)
+      })
+    }
     setTimeout(() => {
       get().clearTask(taskId)
     }, 3000)

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