Sfoglia il codice sorgente

fix(llm): 统一后台任务 LLM 解析并修复章节保存

保存正式章节、lint、大纲生成等路径改用 resolveNovelModel/resolveDefaultModel,
用 hasUsableLlm 替代原始 llmConfig 或 apiKey 判断。修复章节空内容 flush 覆盖
磁盘内容及聊天保存后 fileContent 不同步问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 3 mesi fa
parent
commit
cefe20e38a

+ 1 - 1
src/components/chat/chat-message.tsx

@@ -25,6 +25,7 @@ import { getHtmlLang, getTextDirection } from "@/lib/language-metadata"
 import { MermaidDiagram, unwrapMermaidPre } from "@/components/mermaid-diagram"
 import { MermaidDiagram, unwrapMermaidPre } from "@/components/mermaid-diagram"
 import { canContinueUnfinishedDeepChapter } from "./chat-resume"
 import { canContinueUnfinishedDeepChapter } from "./chat-resume"
 import { getCopyableAssistantContent } from "@/lib/chat-copy-content"
 import { getCopyableAssistantContent } from "@/lib/chat-copy-content"
+import { parseAgentResponse } from "@/lib/novel/agent-parser"
 
 
 interface ChatMessageProps {
 interface ChatMessageProps {
   message: DisplayMessage
   message: DisplayMessage
@@ -556,7 +557,6 @@ function AgentAwareContent({ content, projectPath }: { content: string; projectP
   const [dismissed, setDismissed] = useState(false)
   const [dismissed, setDismissed] = useState(false)
 
 
   const parsed = useMemo(() => {
   const parsed = useMemo(() => {
-    const { parseAgentResponse } = require("@/lib/novel/agent-parser") as typeof import("@/lib/novel/agent-parser")
     return parseAgentResponse(content)
     return parseAgentResponse(content)
   }, [content])
   }, [content])
 
 

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

@@ -291,9 +291,11 @@ export function ChatPanel() {
       const chapterDir = `${pp}/wiki/chapters`
       const chapterDir = `${pp}/wiki/chapters`
       await createDirectory(chapterDir)
       await createDirectory(chapterDir)
       const chapterPath = `${chapterDir}/chapter-${String(targetChapterNumber).padStart(3, "0")}.md`
       const chapterPath = `${chapterDir}/chapter-${String(targetChapterNumber).padStart(3, "0")}.md`
-      await writeFile(chapterPath, buildDraftContent(targetChapterNumber, chapterTitle, cleanedContent))
+      const chapterMarkdown = buildDraftContent(targetChapterNumber, chapterTitle, cleanedContent)
+      await writeFile(chapterPath, chapterMarkdown)
       setChapterSaveStatus(`已保存为${chapterTitle}`)
       setChapterSaveStatus(`已保存为${chapterTitle}`)
       useWikiStore.getState().setSelectedFile(chapterPath)
       useWikiStore.getState().setSelectedFile(chapterPath)
+      useWikiStore.getState().setFileContent(chapterMarkdown)
 
 
       await refreshProjectState(pp)
       await refreshProjectState(pp)
       useWikiStore.getState().setActiveView("wiki")
       useWikiStore.getState().setActiveView("wiki")

+ 16 - 0
src/components/layout/preview-panel.spec.ts

@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vitest"
+import { shouldSkipEmptyChapterFlush } from "./preview-panel"
+
+describe("shouldSkipEmptyChapterFlush", () => {
+  it("skips flushing empty markdown when disk snapshot still has content", () => {
+    expect(shouldSkipEmptyChapterFlush("", "---\ntype: chapter\n---\n\n# 第1章\n\n正文")).toBe(true)
+  })
+
+  it("allows flushing intentional empty edits when nothing was loaded", () => {
+    expect(shouldSkipEmptyChapterFlush("", "")).toBe(false)
+  })
+
+  it("allows flushing non-empty markdown", () => {
+    expect(shouldSkipEmptyChapterFlush("# 第1章\n\n正文", "")).toBe(false)
+  })
+})

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

@@ -95,6 +95,11 @@ async function getCanonicalChapterPath(currentPath: string, markdown: string, ch
   return getUniqueSiblingPath(getDirName(currentPath), makeChapterFileName(title, chapterNumber), currentPath)
   return getUniqueSiblingPath(getDirName(currentPath), makeChapterFileName(title, chapterNumber), currentPath)
 }
 }
 
 
+/** Avoid flushing an empty in-memory snapshot over a chapter that still has disk content. */
+export function shouldSkipEmptyChapterFlush(markdown: string, lastLoaded: string): boolean {
+  return !markdown.trim() && Boolean(lastLoaded.trim())
+}
+
 function extractChapterNumberFromMarkdown(markdown: string): number | null {
 function extractChapterNumberFromMarkdown(markdown: string): number | null {
   const { frontmatter } = parseFrontmatter(markdown)
   const { frontmatter } = parseFrontmatter(markdown)
   if (!frontmatter || typeof frontmatter !== "object") return null
   if (!frontmatter || typeof frontmatter !== "object") return null
@@ -279,6 +284,10 @@ export function PreviewPanel() {
 
 
     await writeFileAtomic(targetPath, normalized)
     await writeFileAtomic(targetPath, normalized)
     if (targetPath !== path) {
     if (targetPath !== path) {
+      if (useWikiStore.getState().selectedFile === path) {
+        selectedFileRef.current = targetPath
+        useWikiStore.getState().setSelectedFile(targetPath)
+      }
       await deleteFile(path)
       await deleteFile(path)
       if (project) {
       if (project) {
         try {
         try {
@@ -288,10 +297,6 @@ export function PreviewPanel() {
           // non-critical tree refresh
           // non-critical tree refresh
         }
         }
       }
       }
-      if (useWikiStore.getState().selectedFile === path) {
-        selectedFileRef.current = targetPath
-        useWikiStore.getState().setSelectedFile(targetPath)
-      }
     }
     }
 
 
     if (useWikiStore.getState().selectedFile === targetPath) {
     if (useWikiStore.getState().selectedFile === targetPath) {
@@ -311,9 +316,14 @@ export function PreviewPanel() {
       clearTimeout(saveTimerRef.current)
       clearTimeout(saveTimerRef.current)
       saveTimerRef.current = null
       saveTimerRef.current = null
     }
     }
-    if (markdown === lastLoadedRef.current) return
+    const storeMarkdown = useWikiStore.getState().fileContent
+    const resolvedMarkdown = markdown.trim()
+      ? markdown
+      : (storeMarkdown.trim() || lastLoadedRef.current)
+    if (shouldSkipEmptyChapterFlush(resolvedMarkdown, lastLoadedRef.current)) return
+    if (resolvedMarkdown === lastLoadedRef.current) return
     try {
     try {
-      await syncChapterToCanonicalPath(path, markdown)
+      await syncChapterToCanonicalPath(path, resolvedMarkdown)
     } catch (err) {
     } catch (err) {
       console.error("切换章节前同步文件失败:", err)
       console.error("切换章节前同步文件失败:", err)
     }
     }
@@ -686,16 +696,25 @@ export function PreviewPanel() {
         saveTimerRef.current = null
         saveTimerRef.current = null
       }
       }
 
 
-      const updatedMarkdown = updateChapterStatus(fileContent, "final")
+      const markdownToSave = fileContent.trim() ? fileContent : lastLoadedRef.current
+      if (!markdownToSave.trim()) {
+        updatePhase(false, null)
+        setSaveStatus("章节内容为空,无法保存为正式章节")
+        setIsSavingFinal(false)
+        return
+      }
+
+      const updatedMarkdown = updateChapterStatus(markdownToSave, "final")
       const syncResult = await syncChapterToCanonicalPath(selectedFile, updatedMarkdown)
       const syncResult = await syncChapterToCanonicalPath(selectedFile, updatedMarkdown)
       const targetPath = syncResult.targetPath
       const targetPath = syncResult.targetPath
       savePath = targetPath
       savePath = targetPath
       lastLoadedRef.current = syncResult.markdown
       lastLoadedRef.current = syncResult.markdown
+      fileContentRef.current = syncResult.markdown
       setFileContent(syncResult.markdown)
       setFileContent(syncResult.markdown)
 
 
       if (novelConfig.autoIngestOnSave) {
       if (novelConfig.autoIngestOnSave) {
         const state = useWikiStore.getState()
         const state = useWikiStore.getState()
-        const llmConfig = state.llmConfig
+        const llmConfig = resolveNovelModel(state.llmConfig, state.novelConfig, "extract")
         if (!hasUsableLlm(llmConfig, state.providerConfigs)) {
         if (!hasUsableLlm(llmConfig, state.providerConfigs)) {
           updatePhase(false, "ingest_no_llm")
           updatePhase(false, "ingest_no_llm")
         } else {
         } else {

+ 9 - 5
src/components/lint/lint-view.tsx

@@ -17,6 +17,7 @@ import { useWikiStore } from "@/stores/wiki-store"
 import { useReviewStore } from "@/stores/review-store"
 import { useReviewStore } from "@/stores/review-store"
 import { runStructuralLint, runSemanticLint, type LintResult } from "@/lib/lint"
 import { runStructuralLint, runSemanticLint, type LintResult } from "@/lib/lint"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
+import { resolveDefaultModel, resolveNovelModel } from "@/lib/novel/model-resolver"
 import { readFile, writeFile, listDirectory } from "@/commands/fs"
 import { readFile, writeFile, listDirectory } from "@/commands/fs"
 import { normalizePath } from "@/lib/path-utils"
 import { normalizePath } from "@/lib/path-utils"
 import { useTranslation } from "react-i18next"
 import { useTranslation } from "react-i18next"
@@ -34,8 +35,6 @@ export function LintView() {
   const { t } = useTranslation()
   const { t } = useTranslation()
   const novelMode = useWikiStore((s) => s.novelMode)
   const novelMode = useWikiStore((s) => s.novelMode)
   const project = useWikiStore((s) => s.project)
   const project = useWikiStore((s) => s.project)
-  const llmConfig = useWikiStore((s) => s.llmConfig)
-  const providerConfigs = useWikiStore((s) => s.providerConfigs)
   const selectedFile = useWikiStore((s) => s.selectedFile)
   const selectedFile = useWikiStore((s) => s.selectedFile)
   const fileContent = useWikiStore((s) => s.fileContent)
   const fileContent = useWikiStore((s) => s.fileContent)
   const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
   const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
@@ -111,8 +110,13 @@ export function LintView() {
       const structural = await runStructuralLint(pp)
       const structural = await runStructuralLint(pp)
       let all = structural
       let all = structural
 
 
-      if (runSemantic && hasUsableLlm(llmConfig, providerConfigs)) {
-        const semantic = await runSemanticLint(pp, llmConfig, {
+      const state = useWikiStore.getState()
+      const effectiveLlmConfig = novelMode
+        ? resolveNovelModel(state.llmConfig, state.novelConfig, "lint")
+        : resolveDefaultModel(state.llmConfig)
+
+      if (runSemantic && hasUsableLlm(effectiveLlmConfig, state.providerConfigs)) {
+        const semantic = await runSemanticLint(pp, effectiveLlmConfig, {
           chapterContent: novelMode && selectedFile ? fileContent : undefined,
           chapterContent: novelMode && selectedFile ? fileContent : undefined,
           chapterNumber: meta?.chapterNumber,
           chapterNumber: meta?.chapterNumber,
         })
         })
@@ -151,7 +155,7 @@ export function LintView() {
         })
         })
       }
       }
     }
     }
-  }, [project, llmConfig, providerConfigs, running, runSemantic, fileContent, novelMode, selectedFile, t, loadHistory, setLintRun])
+  }, [project, running, runSemantic, fileContent, novelMode, selectedFile, t, loadHistory, setLintRun])
 
 
   async function handleOpenPage(page: string) {
   async function handleOpenPage(page: string) {
     if (!project) return
     if (!project) return

+ 9 - 7
src/components/novel/book-analysis-result-viewer.tsx

@@ -18,6 +18,7 @@ import { joinPath } from "@/lib/path-utils"
 import { toast } from "@/lib/toast"
 import { toast } from "@/lib/toast"
 import { refreshProjectState } from "@/lib/project-refresh"
 import { refreshProjectState } from "@/lib/project-refresh"
 import { resolveDefaultModel } from "@/lib/novel/model-resolver"
 import { resolveDefaultModel } from "@/lib/novel/model-resolver"
+import { hasUsableLlm } from "@/lib/has-usable-llm"
 import type { BookAnalysisResult, BookAnalysisMetadata, ExtractedCharacter, PersonalityProfile } from "@/lib/novel/book-analysis/types"
 import type { BookAnalysisResult, BookAnalysisMetadata, ExtractedCharacter, PersonalityProfile } from "@/lib/novel/book-analysis/types"
 
 
 interface BookAnalysisResultViewerProps {
 interface BookAnalysisResultViewerProps {
@@ -125,6 +126,10 @@ export function BookAnalysisResultViewer({ projectPath, result, onClose }: BookA
     try {
     try {
       const reextractStoreState = useWikiStore.getState()
       const reextractStoreState = useWikiStore.getState()
       const reextractLlmConfig = resolveDefaultModel(reextractStoreState.llmConfig)
       const reextractLlmConfig = resolveDefaultModel(reextractStoreState.llmConfig)
+      if (!hasUsableLlm(reextractLlmConfig, reextractStoreState.providerConfigs)) {
+        toast.error("未配置 LLM,请先在设置中配置")
+        return
+      }
       // 修复:bookPath 实际写入路径是 book-analysis/{bookId},不是 book-analysis/{title}(feature/book-analysis-reuse)
       // 修复:bookPath 实际写入路径是 book-analysis/{bookId},不是 book-analysis/{title}(feature/book-analysis-reuse)
       const bookId = task?.bookId
       const bookId = task?.bookId
       const bookPath = joinPath(currentProject.path, "book-analysis", bookId ?? "unknown")
       const bookPath = joinPath(currentProject.path, "book-analysis", bookId ?? "unknown")
@@ -177,12 +182,11 @@ export function BookAnalysisResultViewer({ projectPath, result, onClose }: BookA
       return
       return
     }
     }
     const storeState = useWikiStore.getState()
     const storeState = useWikiStore.getState()
-    const baseLlmConfig = storeState.llmConfig
-    if (!baseLlmConfig) {
+    const llmConfig = resolveDefaultModel(storeState.llmConfig)
+    if (!hasUsableLlm(llmConfig, storeState.providerConfigs)) {
       toast.error("未配置 LLM,请先在设置中配置")
       toast.error("未配置 LLM,请先在设置中配置")
       return
       return
     }
     }
-    const llmConfig = resolveDefaultModel(baseLlmConfig)
     const bookPath = joinPath(currentProject.path, "book-analysis", bookId)
     const bookPath = joinPath(currentProject.path, "book-analysis", bookId)
     setStyleExtracting(true)
     setStyleExtracting(true)
     try {
     try {
@@ -241,15 +245,13 @@ export function BookAnalysisResultViewer({ projectPath, result, onClose }: BookA
     }
     }
 
 
     const storeState = useWikiStore.getState()
     const storeState = useWikiStore.getState()
-    const baseLlmConfig = storeState.llmConfig
-    if (!baseLlmConfig) {
+    const llmConfig = resolveDefaultModel(storeState.llmConfig)
+    if (!hasUsableLlm(llmConfig, storeState.providerConfigs)) {
       console.log('[单角色提取] 未配置LLM')
       console.log('[单角色提取] 未配置LLM')
       toast.error("未配置 LLM,请先在设置中配置")
       toast.error("未配置 LLM,请先在设置中配置")
       return
       return
     }
     }
 
 
-    const llmConfig = resolveDefaultModel(baseLlmConfig)
-
     const bookId = task?.bookId
     const bookId = task?.bookId
     if (!bookId) {
     if (!bookId) {
       console.log('[单角色提取] 未找到bookId')
       console.log('[单角色提取] 未找到bookId')

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

@@ -115,6 +115,7 @@ export function BookAnalysisView() {
     setSelectedCharacterId,
     setSelectedCharacterId,
     setChapterSelectionData,
     setChapterSelectionData,
     llmConfig,
     llmConfig,
+    providerConfigs,
     startTask,
     startTask,
   })
   })
 
 
@@ -135,6 +136,7 @@ export function BookAnalysisView() {
     clearRecognition,
     clearRecognition,
     setRecognitionError: useBookAnalysisStore((s) => s.setRecognitionError),
     setRecognitionError: useBookAnalysisStore((s) => s.setRecognitionError),
     llmConfig,
     llmConfig,
+    providerConfigs,
   })
   })
 
 
   // 角色特征提取钩子
   // 角色特征提取钩子

+ 3 - 2
src/components/novel/hooks/use-character-extraction.ts

@@ -2,6 +2,7 @@ import { useState, useCallback } from "react"
 import { useBookAnalysisStore } from "@/stores/book-analysis-store"
 import { useBookAnalysisStore } from "@/stores/book-analysis-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { resolveDefaultModel } from "@/lib/novel/model-resolver"
 import { resolveDefaultModel } from "@/lib/novel/model-resolver"
+import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { readFile } from "@/commands/fs"
 import { readFile } from "@/commands/fs"
 import { joinPath } from "@/lib/path-utils"
 import { joinPath } from "@/lib/path-utils"
 import { toast } from "@/lib/toast"
 import { toast } from "@/lib/toast"
@@ -212,7 +213,7 @@ export function useCharacterExtraction({
         simpleExtractionTotal: userPicked.length,
         simpleExtractionTotal: userPicked.length,
       })
       })
 
 
-      if (!llmConfig) {
+      if (!hasUsableLlm(llmConfig, useWikiStore.getState().providerConfigs)) {
         throw new Error("未配置 LLM,请先在设置中配置 LLM 后再提取")
         throw new Error("未配置 LLM,请先在设置中配置 LLM 后再提取")
       }
       }
       const { streamChat } = await import("@/lib/llm-client")
       const { streamChat } = await import("@/lib/llm-client")
@@ -415,7 +416,7 @@ export function useCharacterExtraction({
 
 
     const resumeStoreState = useWikiStore.getState()
     const resumeStoreState = useWikiStore.getState()
     const llmConfig = resolveDefaultModel(resumeStoreState.llmConfig)
     const llmConfig = resolveDefaultModel(resumeStoreState.llmConfig)
-    if (!llmConfig) {
+    if (!hasUsableLlm(llmConfig, resumeStoreState.providerConfigs)) {
       alert("未配置 LLM,请先在设置中配置")
       alert("未配置 LLM,请先在设置中配置")
       return
       return
     }
     }

+ 4 - 1
src/components/novel/hooks/use-character-recognition.ts

@@ -1,6 +1,7 @@
 import { useCallback } from "react"
 import { useCallback } from "react"
 import { useBookAnalysisStore } from "@/stores/book-analysis-store"
 import { useBookAnalysisStore } from "@/stores/book-analysis-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
+import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { readFile } from "@/commands/fs"
 import { readFile } from "@/commands/fs"
 import { joinPath } from "@/lib/path-utils"
 import { joinPath } from "@/lib/path-utils"
 import { toast } from "@/lib/toast"
 import { toast } from "@/lib/toast"
@@ -19,6 +20,7 @@ export interface UseCharacterRecognitionParams {
   clearRecognition: () => void
   clearRecognition: () => void
   setRecognitionError: (error?: string) => void
   setRecognitionError: (error?: string) => void
   llmConfig: ReturnType<typeof useWikiStore.getState>["llmConfig"]
   llmConfig: ReturnType<typeof useWikiStore.getState>["llmConfig"]
+  providerConfigs: ReturnType<typeof useWikiStore.getState>["providerConfigs"]
 }
 }
 
 
 /**
 /**
@@ -39,6 +41,7 @@ export function useCharacterRecognition({
   clearRecognition,
   clearRecognition,
   setRecognitionError,
   setRecognitionError,
   llmConfig,
   llmConfig,
+  providerConfigs,
 }: UseCharacterRecognitionParams) {
 }: UseCharacterRecognitionParams) {
   /**
   /**
    * 用户在章节选择面板中确认章节后,启动角色识别流程
    * 用户在章节选择面板中确认章节后,启动角色识别流程
@@ -79,7 +82,7 @@ export function useCharacterRecognition({
         }
         }
       }
       }
 
 
-      if (!llmConfig) {
+      if (!hasUsableLlm(llmConfig, providerConfigs)) {
         throw new Error("未配置可用的模型,请先在设置中配置 LLM,再识别角色")
         throw new Error("未配置可用的模型,请先在设置中配置 LLM,再识别角色")
       }
       }
       setRecognitionStatus("llm_recognizing")
       setRecognitionStatus("llm_recognizing")

+ 5 - 2
src/components/novel/hooks/use-library-operations.ts

@@ -15,6 +15,7 @@ import { refreshProjectState } from "@/lib/project-refresh"
 import { readFile, listDirectory, deleteFile } from "@/commands/fs"
 import { readFile, listDirectory, deleteFile } from "@/commands/fs"
 import { joinPath } from "@/lib/path-utils"
 import { joinPath } from "@/lib/path-utils"
 import { toast } from "@/lib/toast"
 import { toast } from "@/lib/toast"
+import { hasUsableLlm } from "@/lib/has-usable-llm"
 import type { AnalysisDepth } from "@/lib/novel/book-analysis/types"
 import type { AnalysisDepth } from "@/lib/novel/book-analysis/types"
 import type { ChapterSelectionData } from "./use-character-extraction"
 import type { ChapterSelectionData } from "./use-character-extraction"
 
 
@@ -27,6 +28,7 @@ export interface UseLibraryOperationsParams {
   setSelectedCharacterId: React.Dispatch<React.SetStateAction<string | null>>
   setSelectedCharacterId: React.Dispatch<React.SetStateAction<string | null>>
   setChapterSelectionData: React.Dispatch<React.SetStateAction<ChapterSelectionData | null>>
   setChapterSelectionData: React.Dispatch<React.SetStateAction<ChapterSelectionData | null>>
   llmConfig: ReturnType<typeof useWikiStore.getState>["llmConfig"]
   llmConfig: ReturnType<typeof useWikiStore.getState>["llmConfig"]
+  providerConfigs: ReturnType<typeof useWikiStore.getState>["providerConfigs"]
   startTask: (projectPath: string, config: any, abortController?: AbortController) => string
   startTask: (projectPath: string, config: any, abortController?: AbortController) => string
 }
 }
 
 
@@ -46,6 +48,7 @@ export function useLibraryOperations({
   setSelectedCharacterId,
   setSelectedCharacterId,
   setChapterSelectionData,
   setChapterSelectionData,
   llmConfig,
   llmConfig,
+  providerConfigs,
   startTask,
   startTask,
 }: UseLibraryOperationsParams) {
 }: UseLibraryOperationsParams) {
   const [styleExtracting, setStyleExtracting] = useState(false)
   const [styleExtracting, setStyleExtracting] = useState(false)
@@ -68,7 +71,7 @@ export function useLibraryOperations({
 
 
   const handleLibraryExtractStyle = useCallback(async () => {
   const handleLibraryExtractStyle = useCallback(async () => {
     if (!currentProjectPath || !selectedLibraryBook || styleExtracting) return
     if (!currentProjectPath || !selectedLibraryBook || styleExtracting) return
-    if (!llmConfig?.apiKey) {
+    if (!hasUsableLlm(llmConfig, providerConfigs)) {
       toast.error("未配置可用模型,请先在设置中配置 LLM。")
       toast.error("未配置可用模型,请先在设置中配置 LLM。")
       return
       return
     }
     }
@@ -255,7 +258,7 @@ export function useLibraryOperations({
 
 
   const handleLibraryReextractCharacters = useCallback(async () => {
   const handleLibraryReextractCharacters = useCallback(async () => {
     if (!currentProjectPath || !selectedLibraryBook) return
     if (!currentProjectPath || !selectedLibraryBook) return
-    if (!llmConfig) {
+    if (!hasUsableLlm(llmConfig, providerConfigs)) {
       toast.error("未配置可用模型,请先在设置中配置 LLM。")
       toast.error("未配置可用模型,请先在设置中配置 LLM。")
       return
       return
     }
     }

+ 10 - 1
src/components/novel/outline-editor.tsx

@@ -13,6 +13,8 @@ import { Button } from "@/components/ui/button"
 import { Label } from "@/components/ui/label"
 import { Label } from "@/components/ui/label"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { streamChat } from "@/lib/llm-client"
 import { streamChat } from "@/lib/llm-client"
+import { resolveDefaultModel } from "@/lib/novel/model-resolver"
+import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { writeFile, listDirectory, createDirectory } from "@/commands/fs"
 import { writeFile, listDirectory, createDirectory } from "@/commands/fs"
 import { PROMPTS } from "@/lib/novel/prompt-templates"
 import { PROMPTS } from "@/lib/novel/prompt-templates"
 import { normalizePath } from "@/lib/path-utils"
 import { normalizePath } from "@/lib/path-utils"
@@ -35,7 +37,8 @@ export function OutlineCreatorDialog({
 }: OutlineCreatorDialogProps) {
 }: OutlineCreatorDialogProps) {
   const { t } = useTranslation()
   const { t } = useTranslation()
   const project = useWikiStore((s) => s.project)
   const project = useWikiStore((s) => s.project)
-  const llmConfig = useWikiStore((s) => s.llmConfig)
+  const baseLlmConfig = useWikiStore((s) => s.llmConfig)
+  const providerConfigs = useWikiStore((s) => s.providerConfigs)
   const setFileTree = useWikiStore((s) => s.setFileTree)
   const setFileTree = useWikiStore((s) => s.setFileTree)
 
 
   const [outlineType, setOutlineType] = useState<OutlineType>("story-outline")
   const [outlineType, setOutlineType] = useState<OutlineType>("story-outline")
@@ -79,6 +82,12 @@ export function OutlineCreatorDialog({
       let content = ""
       let content = ""
 
 
       if (useAi) {
       if (useAi) {
+        const llmConfig = resolveDefaultModel(baseLlmConfig)
+        if (!hasUsableLlm(llmConfig, providerConfigs)) {
+          setError("请先在设置中配置可用的 AI 模型")
+          setGenerating(false)
+          return
+        }
         const outlineTypeLabel = t(`novel.outline.type.${outlineType}`)
         const outlineTypeLabel = t(`novel.outline.type.${outlineType}`)
         const prompt = PROMPTS.outlineGeneration(outlineTypeLabel, "", premise)
         const prompt = PROMPTS.outlineGeneration(outlineTypeLabel, "", premise)
 
 

+ 1 - 1
src/components/sources/outline-chat-panel.tsx

@@ -24,6 +24,7 @@ import {
   collectWebResearch,
   collectWebResearch,
   shouldUseWebResearch,
   shouldUseWebResearch,
 } from "@/lib/web-research"
 } from "@/lib/web-research"
+import { parseAgentResponse } from "@/lib/novel/agent-parser"
 
 
 async function loadOutlineContext(projectPath: string): Promise<{ context: string; sources: string[] }> {
 async function loadOutlineContext(projectPath: string): Promise<{ context: string; sources: string[] }> {
   const pp = normalizePath(projectPath)
   const pp = normalizePath(projectPath)
@@ -129,7 +130,6 @@ function OutlineAssistantMessage({ msg, index, isStreaming, streamingContent, ac
   // Parse for file edits
   // Parse for file edits
   const parsed = useMemo(() => {
   const parsed = useMemo(() => {
     if (!answer) return { textContent: "", edits: [], hasEdits: false }
     if (!answer) return { textContent: "", edits: [], hasEdits: false }
-    const { parseAgentResponse } = require("@/lib/novel/agent-parser") as typeof import("@/lib/novel/agent-parser")
     return parseAgentResponse(answer)
     return parseAgentResponse(answer)
   }, [answer])
   }, [answer])
 
 

+ 16 - 7
src/lib/changelog.ts

@@ -83,15 +83,24 @@ const TWO_POINT_TWO_THIRTY_TWO_CHANGELOG: ChangelogEntry = {
   date: "2026-07-04",
   date: "2026-07-04",
   highlights: {
   highlights: {
     en: [
     en: [
-      "Reworked how novel-writing tasks pick a model into one consistent rule: per-step model > Default Model > chat model (fallback). The model selected in the chat box is now used only for chatting and writing prose; review, summary, extract, and de-AI follow the Default Model by default.",
-      "Added a dedicated De-AI Model in Novel settings for chapter preview de-AI, selection de-AI, and deep generation stage 6.",
-      "Moved the Default Model to the top of Novel settings and clarified that it drives every feature other than writing prose (review, summary, extract, Dismantling, deduplication, source import, etc.).",
-      "Rewrote all model-setting descriptions in plain language, and split the single checkbox into Follow default model and Follow chat model so the same control no longer means opposite things in different places.",
+      "Chat model and background-task model are now separate: the model in the chat box is only for conversation and writing prose; review, summary, de-AI, and similar tasks follow the Default Model unless you pick something else for that step.",
+      "De-AI can use its own model, so you no longer have to share the chat model for every de-AI pass.",
+      "Default Model moved to the top of Novel settings with clearer wording about what it actually controls.",
+      "Model-setting labels are easier to read, and Follow default model and Follow chat model are now two separate checkboxes so they cannot mean opposite things in different places.",
+      "Duplicate-entity detection is smoother: pick a model before scanning, last scan results are remembered, merged entries disappear right away, and the merge button no longer gets stuck doing nothing.",
+      "Duplicate-entity scans and merges run faster, with progress that tells you what is happening; the most likely duplicates show up first.",
+      "macOS and Linux no longer check for automatic updates — the settings page points you to manual download instead, and the install guide covers common macOS setup issues.",
+      "Some settings pages no longer need a Save button; changes take effect as you go.",
     ],
     ],
     zh: [
     zh: [
-      "重构写小说的模型选择逻辑,统一为「该环节单独设置 > 默认模型 > 聊天模型兜底」:聊天框选中的模型只用于聊天和写正文,审稿、摘要、提取、去 AI 味等环节默认跟随「默认模型」。",
-      "小说设置新增「去 AI 味模型」,可单独指定章节预览去 AI 味、选区去 AI 味与深度生成阶段6使用的模型。",
-      "设置页各模型说明全部改写为通俗易懂的表述;「跟随」复选框拆分为「跟随默认模型」与「跟随聊天模型」,避免同一控件在不同位置含义相反。",
+      "聊天用的模型和后台任务用的模型分开了:聊天框里选的只管对话和写正文,审稿、摘要、去 AI 味等默认走「默认模型」;需要的话,也可以给单个环节单独指定模型。",
+      "去 AI 味可以单独选一个模型,不必再和聊天模型绑在一起。",
+      "「默认模型」提到小说设置最上面,说明也写清楚了——它负责除写正文以外的各种后台任务。",
+      "各处的模型设置说明改写了,「跟随默认模型」和「跟随聊天模型」分开勾选,不再搞混。",
+      "重复角色/实体检测更好用了:扫描前可以选模型,上次结果会保留,合并后条目马上消失;也修复了点击合并没反应的问题。",
+      "重复实体扫描和合并更快了,进度会告诉你当前在干什么;最可能重复的会排在前面。",
+      "macOS 和 Linux 版暂不支持自动更新,设置里会提示去官网手动下载;安装说明也补充了 macOS 常见问题。",
+      "部分设置页不再需要点保存,改动即时生效。",
     ],
     ],
   },
   },
 }
 }

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

@@ -13,7 +13,7 @@ import { useWikiStore } from "@/stores/wiki-store"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { ingestOutline } from "./chapter-ingest"
 import { ingestOutline } from "./chapter-ingest"
 import { buildContextPack, type ContextPack } from "./context-engine"
 import { buildContextPack, type ContextPack } from "./context-engine"
-import { resolveModelConfig } from "@/lib/novel/model-resolver"
+import { resolveDefaultModel, resolveModelConfig } from "@/lib/novel/model-resolver"
 
 
 export type OutlineSectionGenerationKey =
 export type OutlineSectionGenerationKey =
   | "chapterOutlines"
   | "chapterOutlines"
@@ -452,7 +452,7 @@ export async function runOutlineGenerationTask(taskId: string, llmConfig: LlmCon
   const { providerConfigs } = useWikiStore.getState()
   const { providerConfigs } = useWikiStore.getState()
   const effectiveLlmConfig = task.modelId
   const effectiveLlmConfig = task.modelId
     ? resolveModelConfig(task.modelId, llmConfig, providerConfigs)
     ? resolveModelConfig(task.modelId, llmConfig, providerConfigs)
-    : llmConfig
+    : resolveDefaultModel(llmConfig)
 
 
   if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
   if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
     throw new Error("请先在设置中配置并选择一个可用的 AI 模型,或在大纲弹窗中选择模型后再试。")
     throw new Error("请先在设置中配置并选择一个可用的 AI 模型,或在大纲弹窗中选择模型后再试。")
@@ -506,7 +506,7 @@ export async function runOutlineRefinementTask(taskId: string, llmConfig: LlmCon
   const { providerConfigs } = useWikiStore.getState()
   const { providerConfigs } = useWikiStore.getState()
   const effectiveLlmConfig = task.modelId
   const effectiveLlmConfig = task.modelId
     ? resolveModelConfig(task.modelId, llmConfig, providerConfigs)
     ? resolveModelConfig(task.modelId, llmConfig, providerConfigs)
-    : llmConfig
+    : resolveDefaultModel(llmConfig)
 
 
   if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
   if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
     throw new Error("请先在设置中配置并选择一个可用的 AI 模型,或在大纲弹窗中选择模型后再试。")
     throw new Error("请先在设置中配置并选择一个可用的 AI 模型,或在大纲弹窗中选择模型后再试。")

+ 3 - 1
src/lib/source-lifecycle.ts

@@ -13,6 +13,7 @@ import type { LlmConfig } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { useWikiStore } from "@/stores/wiki-store"
 import { enqueueBatch } from "@/lib/ingest-queue"
 import { enqueueBatch } from "@/lib/ingest-queue"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
+import { resolveDefaultModel } from "@/lib/novel/model-resolver"
 import { getFileName, getFileStem, normalizePath } from "@/lib/path-utils"
 import { getFileName, getFileStem, normalizePath } from "@/lib/path-utils"
 import {
 import {
   parseFrontmatterArray,
   parseFrontmatterArray,
@@ -105,7 +106,8 @@ export async function enqueueSourceIngest(
   llmConfig: LlmConfig,
   llmConfig: LlmConfig,
   options: { sourceRoot?: string; rootContext?: string } = {},
   options: { sourceRoot?: string; rootContext?: string } = {},
 ): Promise<string[]> {
 ): Promise<string[]> {
-  if (!hasUsableLlm(llmConfig, useWikiStore.getState().providerConfigs)) return []
+  const resolvedLlmConfig = resolveDefaultModel(llmConfig)
+  if (!hasUsableLlm(resolvedLlmConfig, useWikiStore.getState().providerConfigs)) return []
   const files = sourcePaths
   const files = sourcePaths
     .filter(isIngestableSourcePath)
     .filter(isIngestableSourcePath)
     .map((sourcePath) => ({
     .map((sourcePath) => ({