Bläddra i källkod

feat(novel): 重构 LLM 模型选择与默认模型配置

统一写小说及后台任务模型优先级为「分环节设置 > 默认模型 > 聊天模型兜底」,
将默认模型迁入小说设置页,补充 legacy 迁移、i18n 与 2.2.32 changelog。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 2 månader sedan
förälder
incheckning
53e76bf6a3

+ 1 - 1
package.json

@@ -1,7 +1,7 @@
 {
   "name": "llm-wiki",
   "private": true,
-  "version": "2.2.31",
+  "version": "2.2.32",
   "type": "module",
   "scripts": {
     "dev": "vite",

+ 1 - 1
src-tauri/Cargo.lock

@@ -4442,7 +4442,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
 
 [[package]]
 name = "llm-wiki"
-version = "2.2.30"
+version = "2.2.32"
 dependencies = [
  "arrow-array",
  "arrow-schema",

+ 1 - 1
src-tauri/Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = "llm-wiki"
-version = "2.2.30"
+version = "2.2.32"
 description = "LLM Wiki - A personal knowledge base for LLM concepts"
 authors = []
 edition = "2021"

+ 1 - 1
src-tauri/tauri.conf.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://schema.tauri.app/config/2",
   "productName": "QMaiWrite",
-  "version": "2.2.31",
+  "version": "2.2.32",
   "identifier": "com.qingmuai.writer",
   "build": {
     "beforeDevCommand": "npm run dev",

+ 4 - 13
src/components/novel/book-analysis-result-viewer.tsx

@@ -17,7 +17,7 @@ import { upsertWritingStylePreset, setEnabledWritingStyle, getEnabledWritingStyl
 import { joinPath } from "@/lib/path-utils"
 import { toast } from "@/lib/toast"
 import { refreshProjectState } from "@/lib/project-refresh"
-import { resolveModelConfig } from "@/lib/novel/model-resolver"
+import { resolveDefaultModel } from "@/lib/novel/model-resolver"
 import type { BookAnalysisResult, BookAnalysisMetadata, ExtractedCharacter, PersonalityProfile } from "@/lib/novel/book-analysis/types"
 
 interface BookAnalysisResultViewerProps {
@@ -124,9 +124,7 @@ export function BookAnalysisResultViewer({ projectPath, result, onClose }: BookA
     setReextractRunning(true)
     try {
       const reextractStoreState = useWikiStore.getState()
-      const reextractLlmConfig = reextractStoreState.aiChatModel
-        ? resolveModelConfig(reextractStoreState.aiChatModel, reextractStoreState.llmConfig, reextractStoreState.providerConfigs)
-        : reextractStoreState.llmConfig
+      const reextractLlmConfig = resolveDefaultModel(reextractStoreState.llmConfig)
       // 修复:bookPath 实际写入路径是 book-analysis/{bookId},不是 book-analysis/{title}(feature/book-analysis-reuse)
       const bookId = task?.bookId
       const bookPath = joinPath(currentProject.path, "book-analysis", bookId ?? "unknown")
@@ -184,10 +182,7 @@ export function BookAnalysisResultViewer({ projectPath, result, onClose }: BookA
       toast.error("未配置 LLM,请先在设置中配置")
       return
     }
-    const aiChatModel = storeState.aiChatModel
-    const llmConfig = aiChatModel
-      ? resolveModelConfig(aiChatModel, baseLlmConfig, storeState.providerConfigs)
-      : baseLlmConfig
+    const llmConfig = resolveDefaultModel(baseLlmConfig)
     const bookPath = joinPath(currentProject.path, "book-analysis", bookId)
     setStyleExtracting(true)
     try {
@@ -253,11 +248,7 @@ export function BookAnalysisResultViewer({ projectPath, result, onClose }: BookA
       return
     }
 
-    // 解析当前 AI 会话模型配置,确保 apiKey/endpoint 与模型匹配
-    const aiChatModel = storeState.aiChatModel
-    const llmConfig = aiChatModel
-      ? resolveModelConfig(aiChatModel, baseLlmConfig, storeState.providerConfigs)
-      : baseLlmConfig
+    const llmConfig = resolveDefaultModel(baseLlmConfig)
 
     const bookId = task?.bookId
     if (!bookId) {

+ 7 - 7
src/components/novel/book-analysis-view.tsx

@@ -6,7 +6,7 @@ import { BookAnalysisResultViewer } from "./book-analysis-result-viewer"
 import { ChapterSelectionPanel } from "./chapter-selection-panel"
 import { useBookAnalysisStore } from "@/stores/book-analysis-store"
 import { useWikiStore } from "@/stores/wiki-store"
-import { resolveModelConfig } from "@/lib/novel/model-resolver"
+import { resolveDefaultModel } from "@/lib/novel/model-resolver"
 import {
   toBookAnalysisResult,
   type BookAnalysisLibraryState,
@@ -73,11 +73,13 @@ export function BookAnalysisView() {
 
   // 角色识别 LLM 配置(feature/llm-character-recognizer)
   const baseLlmConfig = useWikiStore((s) => s.llmConfig)
+  const novelConfig = useWikiStore((s) => s.novelConfig)
   const aiChatModel = useWikiStore((s) => s.aiChatModel)
   const providerConfigs = useWikiStore((s) => s.providerConfigs)
-  const llmConfig = aiChatModel
-    ? resolveModelConfig(aiChatModel, baseLlmConfig, providerConfigs)
-    : baseLlmConfig
+  const llmConfig = useMemo(
+    () => resolveDefaultModel(baseLlmConfig),
+    [baseLlmConfig, novelConfig.defaultLlmModel, aiChatModel, providerConfigs],
+  )
 
   // 角色识别 store 状态与 actions(feature/character-recognition-and-simple-mode)
   const recognitionStatus = useBookAnalysisStore((s) => s.recognitionStatus)
@@ -218,9 +220,7 @@ export function BookAnalysisView() {
       const { splitNovelIntoChapters } = await import("@/lib/novel/book-analysis/analysis-engine")
       const { useWikiStore } = await import("@/stores/wiki-store")
       const storeState = useWikiStore.getState()
-      const analysisLlmConfig = storeState.aiChatModel
-        ? resolveModelConfig(storeState.aiChatModel, storeState.llmConfig, storeState.providerConfigs)
-        : storeState.llmConfig
+      const analysisLlmConfig = resolveDefaultModel(storeState.llmConfig)
       const updateTaskProgress = useBookAnalysisStore.getState().updateTaskProgress
       const updateTaskMetadata = useBookAnalysisStore.getState().updateTaskMetadata
 

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

@@ -1,7 +1,7 @@
 import { useState, useCallback } from "react"
 import { useBookAnalysisStore } from "@/stores/book-analysis-store"
 import { useWikiStore } from "@/stores/wiki-store"
-import { resolveModelConfig } from "@/lib/novel/model-resolver"
+import { resolveDefaultModel } from "@/lib/novel/model-resolver"
 import { readFile } from "@/commands/fs"
 import { joinPath } from "@/lib/path-utils"
 import { toast } from "@/lib/toast"
@@ -54,9 +54,7 @@ export function useCharacterExtraction({
 
   const resolveLlmConfig = useCallback(() => {
     const storeState = useWikiStore.getState()
-    return storeState.aiChatModel
-      ? resolveModelConfig(storeState.aiChatModel, storeState.llmConfig, storeState.providerConfigs)
-      : storeState.llmConfig
+    return resolveDefaultModel(storeState.llmConfig)
   }, [])
 
   /**
@@ -416,9 +414,7 @@ export function useCharacterExtraction({
     const updateTaskCharacters = useBookAnalysisStore.getState().updateTaskCharacters
 
     const resumeStoreState = useWikiStore.getState()
-    const llmConfig = resumeStoreState.aiChatModel
-      ? resolveModelConfig(resumeStoreState.aiChatModel, resumeStoreState.llmConfig, resumeStoreState.providerConfigs)
-      : resumeStoreState.llmConfig
+    const llmConfig = resolveDefaultModel(resumeStoreState.llmConfig)
     if (!llmConfig) {
       alert("未配置 LLM,请先在设置中配置")
       return

+ 1 - 1
src/components/settings/sections/changelog-section.tsx

@@ -235,7 +235,7 @@ export function ChangelogSection() {
                 </span>
                 <span className="text-xs text-muted-foreground">{entry.date}</span>
                 {entry.version === __APP_VERSION__ ? (
-                  <span className="text-xs text-emerald-600 dark:text-emerald-400">\u2190 当前版本</span>
+                  <span className="text-xs text-emerald-600 dark:text-emerald-400"> 当前版本</span>
                 ) : null}
               </div>
               <ul className="mt-3 space-y-2 text-sm leading-relaxed text-foreground/90">

+ 4 - 20
src/components/settings/sections/maintenance-section.tsx

@@ -19,7 +19,8 @@ import {
 import { Button } from "@/components/ui/button"
 import { Label } from "@/components/ui/label"
 import { ChatModelSelector } from "@/components/chat/chat-model-selector"
-import { useWikiStore, type ProviderConfigs } from "@/stores/wiki-store"
+import { useWikiStore } from "@/stores/wiki-store"
+import { getFirstAvailableModelKey } from "@/lib/llm-model-keys"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { normalizePath } from "@/lib/path-utils"
 import { resolveDefaultModel, resolveModelConfig } from "@/lib/novel/model-resolver"
@@ -105,24 +106,6 @@ function scanStateBelongsToProject(
   return normalizeProjectPath(state.projectPath) === normalizeProjectPath(project.path)
 }
 
-function getFirstAvailableModelKey(providerConfigs: ProviderConfigs): string {
-  for (const key of Object.keys(providerConfigs)) {
-    if (key.startsWith("custom-")) continue
-    const config = providerConfigs[key]
-    if (config.enabled !== true) continue
-    const first = config.savedModels?.[0]
-    if (first) return `${key}/${first.model}`
-  }
-  for (const key of Object.keys(providerConfigs)) {
-    if (!key.startsWith("custom-")) continue
-    const config = providerConfigs[key]
-    if (config.enabled === false) continue
-    const first = config.savedModels?.[0]
-    if (first) return `${key}/${first.model}`
-  }
-  return ""
-}
-
 function setSharedScanState(patch: Partial<MaintenanceScanState>): void {
   const normalizedPatch = patch.projectPath
     ? { ...patch, projectPath: normalizeProjectPath(patch.projectPath) }
@@ -176,7 +159,8 @@ export function MaintenanceSection() {
   const { t } = useTranslation()
   const llmConfig = useWikiStore((s) => s.llmConfig)
   const providerConfigs = useWikiStore((s) => s.providerConfigs)
-  const defaultLlmModel = useWikiStore((s) => s.defaultLlmModel)
+  const novelConfig = useWikiStore((s) => s.novelConfig)
+  const defaultLlmModel = novelConfig.defaultLlmModel
   const aiChatModel = useWikiStore((s) => s.aiChatModel)
   const project = useWikiStore((s) => s.project)
 

+ 160 - 68
src/components/settings/sections/novel-section.tsx

@@ -1,4 +1,4 @@
-import { useMemo, useState } from "react"
+import { useMemo, useState, type ReactNode } from "react"
 import { useTranslation } from "react-i18next"
 import { Info } from "lucide-react"
 import { Label } from "@/components/ui/label"
@@ -6,7 +6,8 @@ import { Input } from "@/components/ui/input"
 import { Button } from "@/components/ui/button"
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
 import { useWikiStore } from "@/stores/wiki-store"
-import { saveNovelConfig } from "@/lib/project-store"
+import { saveNovelConfig, saveDefaultLlmModel } from "@/lib/project-store"
+import { getFirstAvailableModelKey, hasAvailableModels } from "@/lib/llm-model-keys"
 
 import { testNovelModel, type TestableNovelModelTask } from "@/lib/novel/novel-model-test"
 import { ChatModelSelector } from "@/components/chat/chat-model-selector"
@@ -18,18 +19,107 @@ interface Props {
   setDraft: DraftSetter
 }
 
+const MODEL_PICKER_BLOCK_CLASS = "space-y-3 rounded-lg border border-border/60 p-4"
+
+interface NovelModelPickerBlockProps {
+  title: string
+  hintKey?: string
+  footnote?: string
+  followChecked: boolean
+  onFollowChange: (checked: boolean) => void
+  modelValue: string
+  onModelChange: (model: string) => void
+  renderHint?: (hintKey: string) => ReactNode
+  testState?: { loading: boolean; message: string; success: boolean }
+  onTest?: () => void
+  testLoadingLabel: string
+  testLabel: string
+  followLabel: string
+}
+
+function NovelModelPickerBlock({
+  title,
+  hintKey,
+  footnote,
+  followChecked,
+  onFollowChange,
+  modelValue,
+  onModelChange,
+  renderHint,
+  testState,
+  onTest,
+  testLoadingLabel,
+  testLabel,
+  followLabel,
+}: NovelModelPickerBlockProps) {
+  return (
+    <div className={MODEL_PICKER_BLOCK_CLASS}>
+      <div className="flex items-center gap-1.5">
+        <Label>{title}</Label>
+        {hintKey && renderHint?.(hintKey)}
+      </div>
+      <div className="flex flex-wrap items-center gap-2">
+        <label className="flex shrink-0 items-center gap-2">
+          <input
+            type="checkbox"
+            checked={followChecked}
+            onChange={(e) => onFollowChange(e.target.checked)}
+            className="h-4 w-4"
+          />
+          <span className="text-sm">{followLabel}</span>
+        </label>
+        <ChatModelSelector
+          value={modelValue}
+          onChange={onModelChange}
+          disabled={followChecked}
+        />
+        {onTest ? (
+          <Button
+            type="button"
+            size="sm"
+            variant="outline"
+            disabled={testState?.loading}
+            onClick={onTest}
+          >
+            {testState?.loading ? testLoadingLabel : testLabel}
+          </Button>
+        ) : null}
+      </div>
+      {footnote ? (
+        <p className="text-xs leading-5 text-muted-foreground/80">{footnote}</p>
+      ) : null}
+      {testState?.message ? (
+        <p className={`text-xs ${testState.success ? "text-emerald-600" : "text-destructive"}`}>
+          {testState.message}
+        </p>
+      ) : null}
+    </div>
+  )
+}
+
 export function NovelSection({ draft, setDraft }: Props) {
   const { t } = useTranslation()
   const setNovelConfigStore = useWikiStore((s) => s.setNovelConfig)
   const llmConfig = useWikiStore((s) => s.llmConfig)
   const aiChatModel = useWikiStore((s) => s.aiChatModel)
+  const providerConfigs = useWikiStore((s) => s.providerConfigs)
   const project = useWikiStore((s) => s.project)
+  const defaultLlmModel = draft.novelConfig.defaultLlmModel
+  const isWorkflowModelFollowingChat = !defaultLlmModel.trim()
+  const displayWorkflowDefaultModel = isWorkflowModelFollowingChat ? "" : defaultLlmModel
+
+  const modelsAvailable = useMemo(
+    () => hasAvailableModels(providerConfigs),
+    [providerConfigs],
+  )
+
   const [testStates, setTestStates] = useState<Record<TestableNovelModelTask, {
     loading: boolean
     message: string
     success: boolean
   } | undefined>>({
     writing: undefined,
+    workflow: undefined,
     review: undefined,
     summary: undefined,
     extract: undefined,
@@ -42,14 +132,15 @@ export function NovelSection({ draft, setDraft }: Props) {
     await saveNovelConfig(newConfig, project?.id, project?.path)
   }
 
+  const updateWorkflowDefaultModel = async (model: string) => {
+    await updateNovelConfig({ defaultLlmModel: model })
+    await saveDefaultLlmModel(model)
+  }
+
   const modelItems = useMemo(() => ([
-    { task: "review", field: "reviewModel", wrapperClassName: "space-y-2" },
-    { task: "summary", field: "summaryModel", wrapperClassName: "space-y-2" },
-    {
-      task: "extract",
-      field: "extractModel",
-      wrapperClassName: "space-y-2 rounded-md border border-primary/30 bg-primary/5 p-3",
-    },
+    { task: "review", field: "reviewModel" },
+    { task: "summary", field: "summaryModel" },
+    { task: "extract", field: "extractModel" },
   ] as const), [])
 
   const settingTooltip = (key: string) => (
@@ -126,6 +217,32 @@ export function NovelSection({ draft, setDraft }: Props) {
       <div className="space-y-2">
         <Label>{t("novel.settings.title")}</Label>
         <div className="grid gap-4 rounded-lg border p-4">
+          {modelsAvailable && (
+            <NovelModelPickerBlock
+              title={t("novel.settings.defaultLlmModel")}
+              hintKey="defaultLlmModelHint"
+              footnote={t("novel.settings.defaultLlmModelScopeNote")}
+              followChecked={isWorkflowModelFollowingChat}
+              onFollowChange={(checked) => {
+                if (checked) {
+                  void updateWorkflowDefaultModel("")
+                } else {
+                  void updateWorkflowDefaultModel(
+                    aiChatModel.trim() || getFirstAvailableModelKey(providerConfigs),
+                  )
+                }
+              }}
+              modelValue={displayWorkflowDefaultModel}
+              onModelChange={(model) => void updateWorkflowDefaultModel(model)}
+              renderHint={settingTooltip}
+              testState={testStates.workflow}
+              onTest={() => runModelTest("workflow")}
+              followLabel={t("novel.settings.followChatModel")}
+              testLoadingLabel={t("novel.settings.testingModel")}
+              testLabel={t("novel.settings.testModel")}
+            />
+          )}
+
           <div className="space-y-2">
             <div className="flex items-center gap-1.5">
               <Label>{t("novel.settings.recentSummaryWindow")}</Label>
@@ -400,6 +517,13 @@ export function NovelSection({ draft, setDraft }: Props) {
             </>
           )}
 
+          <div className="space-y-1 border-t border-border/60 pt-4">
+            <Label>{t("novel.settings.taskModelsTitle")}</Label>
+            <p className="text-xs leading-5 text-muted-foreground">
+              {t("novel.settings.taskModelsHint")}
+            </p>
+          </div>
+
           {modelItems.map((item) => {
             const state = testStates[item.task]
             const modelValue = draft.novelConfig[item.field] || ""
@@ -407,65 +531,33 @@ export function NovelSection({ draft, setDraft }: Props) {
             const displayValue = isFollowingChat ? "" : modelValue
 
             return (
-              <div key={item.task} className={item.wrapperClassName}>
-                <div className="flex items-center gap-1.5">
-                  <Label>{t(`novel.settings.${item.field}`)}</Label>
-                  {settingTooltip(`${item.field}Hint`)}
-                </div>
-                <div className="flex flex-wrap items-center gap-2">
-                  <div className="flex items-center gap-2">
-                    <label className="flex items-center gap-2">
-                      <input
-                        type="checkbox"
-                        checked={isFollowingChat}
-                        onChange={(e) => {
-                          if (e.target.checked) {
-                            // 勾选:清空模型值,跟随 AI 会话模型
-                            updateNovelConfig({
-                              [item.field]: "",
-                            } as Partial<NovelConfig>)
-                          } else {
-                        // 取消勾选:使用 AI 会话当前模型作为默认值,若为空则填入占位值让用户手动选择
-                        updateNovelConfig({
-                          [item.field]: aiChatModel || " ",
-                        } as Partial<NovelConfig>)
-                      }
-                        }}
-                        className="h-4 w-4"
-                      />
-                      <span className="text-sm">
-                        {t("novel.settings.followChatModel")}
-                      </span>
-                    </label>
-                    <ChatModelSelector
-                      value={displayValue}
-                      onChange={(model) => updateNovelConfig({
-                        [item.field]: model,
-                      } as Partial<NovelConfig>)}
-                      disabled={isFollowingChat}
-                    />
-                  </div>
-                  <Button
-                    type="button"
-                    size="sm"
-                    variant="outline"
-                    disabled={state?.loading}
-                    onClick={() => runModelTest(item.task)}
-                  >
-                    {state?.loading ? t("novel.settings.testingModel") : t("novel.settings.testModel")}
-                  </Button>
-                </div>
-                {item.task === "extract" ? (
-                  <p className="text-xs leading-5 text-muted-foreground">
-                    {t("novel.settings.extractModelHint")}
-                  </p>
-                ) : null}
-                {state?.message ? (
-                  <p className={`text-xs ${state.success ? "text-emerald-600" : "text-destructive"}`}>
-                    {state.message}
-                  </p>
-                ) : null}
-              </div>
+              <NovelModelPickerBlock
+                key={item.task}
+                title={t(`novel.settings.${item.field}`)}
+                hintKey={`${item.field}Hint`}
+                followChecked={isFollowingChat}
+                onFollowChange={(checked) => {
+                  if (checked) {
+                    updateNovelConfig({
+                      [item.field]: "",
+                    } as Partial<NovelConfig>)
+                  } else {
+                    updateNovelConfig({
+                      [item.field]: aiChatModel || " ",
+                    } as Partial<NovelConfig>)
+                  }
+                }}
+                modelValue={displayValue}
+                onModelChange={(model) => updateNovelConfig({
+                  [item.field]: model,
+                } as Partial<NovelConfig>)}
+                renderHint={settingTooltip}
+                testState={state}
+                onTest={() => runModelTest(item.task)}
+                followLabel={t("novel.settings.followDefaultModel")}
+                testLoadingLabel={t("novel.settings.testingModel")}
+                testLabel={t("novel.settings.testModel")}
+              />
             )
           })}
         </div>

+ 2 - 0
src/components/settings/settings-view.tsx

@@ -339,6 +339,7 @@ export function SettingsView() {
       saveSourceWatchConfig,
       saveRevisionFeedbackWindowConfig,
       saveNovelConfig,
+      saveDefaultLlmModel,
       saveOutputLanguage,
       saveMaxHistoryMessages,
       saveUiFontSizeScale,
@@ -461,6 +462,7 @@ export function SettingsView() {
 
     setNovelConfig(draft.novelConfig)
     await saveNovelConfig(draft.novelConfig, project?.id, project?.path)
+    await saveDefaultLlmModel(draft.novelConfig.defaultLlmModel)
 
     setOutputLanguage(draft.outputLanguage)
     await saveOutputLanguage(draft.outputLanguage, project?.id)

+ 16 - 13
src/i18n/en.json

@@ -757,8 +757,7 @@
         "apiKeyPlaceholderCustom": "Leave blank if no key is required (local model)",
         "apiKeyPlaceholder": "Enter API Key",
         "fetchModels": "Fetch Models",
-        "defaultLlmModel": "Default Model",
-        "defaultLlmModelHint": "The default model used by background tasks such as memory extraction and character extraction. If empty, the current AI chat model will be used.",
+        "pleaseSelectModel": "Please select a model first",
         "loadingModels": "Fetching...",
         "savedBadge": "Saved",
         "configuredBadge": "Configured",
@@ -1212,17 +1211,21 @@
       "deepChapterReviewHint": "When enabled, deep generation reviews the draft and auto-revises blocking issues. When off, the draft goes straight to the light de-AI polish, saving the review and revision calls at the cost of one continuity gate. On by default.",
       "reviewReasoningEffort": "Review thinking effort",
       "reviewReasoningEffortHint": "Controls how deeply the AI review (including six-dimension review) reasons. Lower effort is cheaper and faster but weakens the continuity and canon-conflict gate. High by default.",
-      "taskModelsTitle": "Task Model Routing",
-      "taskModelsHint": "These are LLM models for writing, review, summary, and extraction, not vector embedding models. Empty fields use the current main model.",
-      "writingModel": "Writing Model (empty = use default)",
-      "writingModelHint": "Used for drafting, continuing, and rewriting prose. Defaults to the global LLM model; choose a model only when writing tasks should use a separate model.",
-      "reviewModel": "Review Model (empty = use default)",
-      "reviewModelHint": "Used for chapter review, continuity checks, and auto-review before save. Defaults to the global LLM model.",
-      "summaryModel": "Summary Model (empty = use default)",
-      "summaryModelHint": "Used for chapter summaries, long-context compression, and preparing source material before writing. Defaults to the global LLM model.",
-      "extractModel": "Extract Model (not embedding, empty = use default)",
-      "extractModelHint": "Used for outline ingestion and chapter ingestion to extract characters, locations, events, relationships, and canon facts. Empty uses the current main LLM model; this is not a vector embedding model.",
-      "followChatModel": "Follow AI chat model",
+      "taskModelsTitle": "Per-Step Models (Novel Writing)",
+      "taskModelsHint": "While writing a novel, the review, summary, and extract steps can each use their own model. By default they all follow the Default Model above, so you don't have to set anything (only when the default model is also empty do they fall back to the model selected in the chat box). Only when a step should always use a different model, uncheck its Follow default model and pick one on the right.",
+      "defaultLlmModel": "Default Model (General)",
+      "defaultLlmModelHint": "Used by every feature except writing prose: review, summary, extract, plus Dismantling, Story Simulation, character Souls, AI rewrite/polish, duplicate-entry detection, and automatic source import. Setting a model here is recommended. When left empty (Follow chat model checked), it falls back to the model selected in the chat box.",
+      "defaultLlmModelScopeNote": "Review, summary, and extract while writing a novel all use this model by default; set them individually in Per-Step Models below only when needed.",
+      "writingModel": "Writing Model",
+      "writingModelHint": "Used while writing a novel for drafting, continuing, and rewriting prose, using the model selected in the chat box.",
+      "reviewModel": "Review Model",
+      "reviewModelHint": "Used while writing a novel for review, continuity checks, and auto-review before save. Follows the Default Model above by default (falls back to the chat model only when the default is also unset); to always use a different model, uncheck Follow default model and pick one on the right.",
+      "summaryModel": "Summary Model",
+      "summaryModelHint": "Used while writing a novel to generate chapter summaries, compress overly long context, and prepare source material before writing. Follows the Default Model above by default (falls back to the chat model only when the default is also unset); to always use a different model, uncheck Follow default model and pick one on the right.",
+      "extractModel": "Extract Model (not an embedding model)",
+      "extractModelHint": "Used while writing a novel to extract characters, locations, events, relationships, and canon facts from outlines and chapters. Follows the Default Model above by default (falls back to the chat model only when the default is also unset); to always use a different model, uncheck Follow default model and pick one on the right. Note: this is not the embedding model used for vector search.",
+      "followChatModel": "Follow chat model",
+      "followDefaultModel": "Follow default model",
       "fetchModels": "Fetch Models",
       "loadingModels": "Fetching...",
       "modelListSuccess": "Fetched {{count}} models.",

+ 13 - 11
src/i18n/zh.json

@@ -469,8 +469,6 @@
         "apiKeyPlaceholderCustom": "输入 Key,留空表示使用本地或免密服务",
         "apiKeyPlaceholder": "输入 API Key",
         "fetchModels": "拉取模型",
-        "defaultLlmModel": "默认模型",
-        "defaultLlmModelHint": "AI 会话提取记忆、提取角色等后台任务默认使用的模型。留空则使用 AI 会话当前选中的模型。",
         "pleaseSelectModel": "请先选择模型",
         "loadingModels": "拉取中...",
         "savedBadge": "已保存",
@@ -1082,17 +1080,21 @@
       "deepChapterReviewHint": "开启后,深度生成在初稿后会进行 AI 审稿,发现阻断问题时自动返修。关闭则初稿直接进入简单审查与去 AI 味,省下审稿与返修调用,但少了一道连贯性把关。默认开启。",
       "reviewReasoningEffort": "审稿思考档位",
       "reviewReasoningEffortHint": "控制 AI 审稿(含六维审查)的思考深度。档位越低越省 Token、越快,但连贯性与设定冲突的把关会变弱。默认「高」。",
-      "taskModelsTitle": "任务模型路由",
-      "taskModelsHint": "下面都是 LLM 写作、审稿、摘要和提取模型,不是向量嵌入模型。留空时使用当前主模型。",
-      "writingModel": "写作模型(留空使用默认)",
-      "writingModelHint": "用于生成、续写和改写正文。默认使用上方全局 LLM 模型;只有想让写作任务单独使用另一个模型时才需要选择。",
+      "taskModelsTitle": "分环节指定模型(写小说)",
+      "taskModelsHint": "写小说时,审稿、摘要、提取信息这几个环节可以各用一个模型。默认它们都跟随上面的「默认模型」,你不用管(默认模型也没设时,才用聊天框底部选中的模型兜底)。只有某个环节想固定用别的模型时,才取消它的「跟随默认模型」并在右侧选一个。",
+      "defaultLlmModel": "默认模型(通用)",
+      "defaultLlmModelHint": "写正文以外的功能都用它:审稿、摘要、提取,以及拆书、剧情推演、角色灵魂、AI 改写/润色、重复条目检测、资料自动导入等。建议在这里指定一个模型。留空(勾选「跟随聊天模型」)时,才用聊天框底部选中的模型兜底。",
+      "defaultLlmModelScopeNote": "写小说的审稿、摘要、提取默认都用这个模型;需要时可在下方「分环节指定模型」里单独指定。",
+      "writingModel": "写作模型",
+      "writingModelHint": "写小说时用于生成、续写和改写正文,直接使用聊天框底部选中的模型。",
       "reviewModel": "审稿模型",
-      "reviewModelHint": "用于章节审稿、连贯性检查和保存前自动审稿。留空则跟随 AI 会话当前选中的模型。",
+      "reviewModelHint": "写小说时用于审稿、检查前后是否连贯、保存前自动审稿。默认跟随上面的「默认模型」(默认模型也没设时才用聊天模型兜底);想固定用别的模型,就取消「跟随默认模型」并在右侧选一个。",
       "summaryModel": "摘要模型",
-      "summaryModelHint": "用于章节摘要、长上下文压缩和写作前的资料整理。留空则跟随 AI 会话当前选中的模型。",
-      "extractModel": "提取模型(非嵌入模型)",
-      "extractModelHint": "用于大纲摄取和章节摄取,抽取人物、地点、事件、关系和设定。留空则跟随 AI 会话当前选中的模型;这不是向量嵌入模型。",
-      "followChatModel": "跟随 AI 会话模型",
+      "summaryModelHint": "写小说时用于生成章节摘要、压缩过长的上下文、写作前整理资料。默认跟随上面的「默认模型」(默认模型也没设时才用聊天模型兜底);想固定用别的模型,就取消「跟随默认模型」并在右侧选一个。",
+      "extractModel": "提取模型(不是向量模型)",
+      "extractModelHint": "写小说时用于从大纲和章节里提取人物、地点、事件、关系和设定。默认跟随上面的「默认模型」(默认模型也没设时才用聊天模型兜底);想固定用别的模型,就取消「跟随默认模型」并在右侧选一个。注意:这不是用来做向量检索的嵌入模型。",
+      "followChatModel": "跟随聊天模型",
+      "followDefaultModel": "跟随默认模型",
       "fetchModels": "拉取模型",
       "loadingModels": "拉取中...",
       "modelListSuccess": "已拉取 {{count}} 个模型。",

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

@@ -6,7 +6,9 @@ describe("changelog", () => {
     const entries = allChangelog()
     const versions = entries.map((entry) => entry.version)
 
-    expect(versions.slice(0, 24)).toEqual([
+    expect(versions.slice(0, 26)).toEqual([
+      "2.2.32",
+      "2.2.31",
       "2.2.30",
       "2.2.29",
       "2.2.27",
@@ -32,7 +34,7 @@ describe("changelog", () => {
       "2.2.0",
       "2.1.0",
     ])
-    expect(versions[24]).toBe("2.0.0")
+    expect(versions[26]).toBe("2.0.0")
 
     for (let patch = 1; patch <= 6; patch += 1) {
       expect(versions).not.toContain(`2.2.${patch}`)

+ 18 - 0
src/lib/changelog.ts

@@ -78,6 +78,22 @@ const TWO_POINT_TWO_ELEVEN_CHANGELOG: ChangelogEntry = {
   },
 }
 
+const TWO_POINT_TWO_THIRTY_TWO_CHANGELOG: ChangelogEntry = {
+  version: "2.2.32",
+  date: "2026-07-04",
+  highlights: {
+    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, and extract follow the Default Model by default.",
+      "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.",
+    ],
+    zh: [
+      "重构写小说的模型选择逻辑,统一为「该环节单独设置 > 默认模型 > 聊天模型兜底」:聊天框选中的模型只用于聊天和写正文,审稿、摘要、提取等环节默认跟随「默认模型」。",
+      "设置页各模型说明全部改写为通俗易懂的表述;「跟随」复选框拆分为「跟随默认模型」与「跟随聊天模型」,避免同一控件在不同位置含义相反。",
+    ],
+  },
+}
+
 const TWO_POINT_TWO_THIRTY_ONE_CHANGELOG: ChangelogEntry = {
   version: "2.2.31",
   date: "2026-06-30",
@@ -717,6 +733,7 @@ export const CHANGELOG: ChangelogEntry[] = [
 ]
 
 export function currentVersionChangelog(version: string): ChangelogEntry[] {
+  if (version === TWO_POINT_TWO_THIRTY_TWO_CHANGELOG.version) return [TWO_POINT_TWO_THIRTY_TWO_CHANGELOG]
   if (version === TWO_POINT_TWO_THIRTY_ONE_CHANGELOG.version) return [TWO_POINT_TWO_THIRTY_ONE_CHANGELOG]
   if (version === TWO_POINT_TWO_THIRTY_CHANGELOG.version) return [TWO_POINT_TWO_THIRTY_CHANGELOG]
   if (version === TWO_POINT_TWO_TWENTY_NINE_CHANGELOG.version) return [TWO_POINT_TWO_TWENTY_NINE_CHANGELOG]
@@ -752,6 +769,7 @@ export function currentVersionChangelog(version: string): ChangelogEntry[] {
 
 export function allChangelog(): ChangelogEntry[] {
   return [
+    TWO_POINT_TWO_THIRTY_TWO_CHANGELOG,
     TWO_POINT_TWO_THIRTY_ONE_CHANGELOG,
     TWO_POINT_TWO_THIRTY_CHANGELOG,
     TWO_POINT_TWO_TWENTY_NINE_CHANGELOG,

+ 34 - 0
src/lib/llm-model-keys.ts

@@ -0,0 +1,34 @@
+import type { ProviderConfigs } from "@/stores/wiki-store"
+
+export function hasAvailableModels(providerConfigs: ProviderConfigs): boolean {
+  for (const key of Object.keys(providerConfigs)) {
+    const config = providerConfigs[key]
+    if (key.startsWith("custom-")) {
+      if (config.enabled === false) continue
+    } else {
+      if (config.enabled !== true) continue
+    }
+    if (config.savedModels && config.savedModels.length > 0) {
+      return true
+    }
+  }
+  return false
+}
+
+export function getFirstAvailableModelKey(providerConfigs: ProviderConfigs): string {
+  for (const key of Object.keys(providerConfigs)) {
+    if (key.startsWith("custom-")) continue
+    const config = providerConfigs[key]
+    if (config.enabled !== true) continue
+    const first = config.savedModels?.[0]
+    if (first) return `${key}/${first.model}`
+  }
+  for (const key of Object.keys(providerConfigs)) {
+    if (!key.startsWith("custom-")) continue
+    const config = providerConfigs[key]
+    if (config.enabled === false) continue
+    const first = config.savedModels?.[0]
+    if (first) return `${key}/${first.model}`
+  }
+  return ""
+}

+ 87 - 31
src/lib/novel/model-resolver.ts

@@ -5,10 +5,55 @@ import { hasUsableLlm } from "@/lib/has-usable-llm"
 
 export type NovelTaskType = "writing" | "review" | "summary" | "extract" | "lint"
 
+const UNUSABLE_LLM_CONFIG: Pick<LlmConfig, "provider" | "apiKey" | "model"> = {
+  provider: "openai",
+  apiKey: "",
+  model: "",
+}
+
 function isConfigUsable(cfg: LlmConfig, providerConfigs: Record<string, ProviderOverride>): boolean {
   return hasUsableLlm(cfg, providerConfigs)
 }
 
+function toUnusableConfig(baseConfig: LlmConfig): LlmConfig {
+  return { ...baseConfig, ...UNUSABLE_LLM_CONFIG }
+}
+
+export function isModelKeyRegistered(
+  targetModel: string,
+  providerConfigs: Record<string, ProviderOverride>,
+): boolean {
+  const trimmed = targetModel.trim()
+  if (!trimmed) return false
+
+  const slashIdx = trimmed.indexOf("/")
+  if (slashIdx > 0) {
+    const providerId = trimmed.slice(0, slashIdx)
+    const modelId = trimmed.slice(slashIdx + 1)
+    return !!providerConfigs[providerId]?.savedModels?.some((m) => m.model === modelId)
+  }
+
+  for (const override of Object.values(providerConfigs)) {
+    if (override.savedModels?.some((m) => m.model === trimmed)) {
+      return true
+    }
+  }
+  return false
+}
+
+function resolveRegisteredModel(
+  targetModel: string,
+  baseConfig: LlmConfig,
+  providerConfigs: Record<string, ProviderOverride>,
+): LlmConfig | null {
+  const trimmed = targetModel.trim()
+  if (!trimmed || !isModelKeyRegistered(trimmed, providerConfigs)) {
+    return null
+  }
+  const cfg = resolveModelConfig(trimmed, baseConfig, providerConfigs)
+  return isConfigUsable(cfg, providerConfigs) ? cfg : null
+}
+
 export function resolveModelConfig(
   targetModel: string,
   baseConfig: LlmConfig,
@@ -40,32 +85,33 @@ export function resolveModelConfig(
   return { ...baseConfig, model: targetModel }
 }
 
+function resolveProjectDefaultLlmModel(): string {
+  const { novelConfig, defaultLlmModel } = useWikiStore.getState()
+  return novelConfig.defaultLlmModel?.trim() || defaultLlmModel?.trim() || ""
+}
+
 /**
  * 解析后台任务的默认模型。
  * 优先级:defaultLlmModel > aiChatModel
  * 不回退到 baseConfig(llmConfig),避免静默使用已禁用的 CLI provider。
- * 用于提取记忆、提取角色等后台 AI 任务。
+ * 用于导入队列、书籍分析、去重等通用后台任务;不含小说章节/大纲摄取(见 resolveNovelModel)
  */
 export function resolveDefaultModel(baseConfig: LlmConfig): LlmConfig {
-  const { providerConfigs, defaultLlmModel, aiChatModel } = useWikiStore.getState()
+  const { providerConfigs, aiChatModel } = useWikiStore.getState()
 
-  const defaultModel = defaultLlmModel?.trim()
+  const defaultModel = resolveProjectDefaultLlmModel()
   if (defaultModel) {
-    const cfg = resolveModelConfig(defaultModel, baseConfig, providerConfigs)
-    if (isConfigUsable(cfg, providerConfigs)) {
-      return cfg
-    }
+    const cfg = resolveRegisteredModel(defaultModel, baseConfig, providerConfigs)
+    if (cfg) return cfg
   }
 
   const chatModel = aiChatModel?.trim()
   if (chatModel && chatModel !== defaultModel) {
-    const cfg = resolveModelConfig(chatModel, baseConfig, providerConfigs)
-    if (isConfigUsable(cfg, providerConfigs)) {
-      return cfg
-    }
+    const cfg = resolveRegisteredModel(chatModel, baseConfig, providerConfigs)
+    if (cfg) return cfg
   }
 
-  return { ...baseConfig, apiKey: "", model: "" }
+  return toUnusableConfig(baseConfig)
 }
 
 export function resolveNovelModel(
@@ -74,38 +120,48 @@ export function resolveNovelModel(
   taskType: NovelTaskType,
 ): LlmConfig {
   const modelMap: Record<NovelTaskType, string> = {
-    writing: "", // 写作模型已移除,始终使用 AI 会话当前模型
+    writing: "", // 写作正文属于“聊天写小说”,直接用聊天模型(下方特判)
     review: novelConfig.reviewModel,
     summary: novelConfig.summaryModel,
     extract: novelConfig.extractModel,
     lint: novelConfig.reviewModel,
   }
 
-  const { providerConfigs, defaultLlmModel, aiChatModel } = useWikiStore.getState()
+  const { providerConfigs, aiChatModel } = useWikiStore.getState()
+  const chatModel = aiChatModel?.trim()
 
+  // 写作正文:聊天模型优先,默认模型兜底。
+  if (taskType === "writing") {
+    if (chatModel) {
+      const cfg = resolveRegisteredModel(chatModel, llmConfig, providerConfigs)
+      if (cfg) return cfg
+    }
+    const defaultModel = resolveProjectDefaultLlmModel()
+    if (defaultModel && defaultModel !== chatModel) {
+      const cfg = resolveRegisteredModel(defaultModel, llmConfig, providerConfigs)
+      if (cfg) return cfg
+    }
+    return toUnusableConfig(llmConfig)
+  }
+
+  // 其余任务级模型(审稿/摘要/提取/lint):
+  //   任务单独设置 > 默认模型 > 聊天模型(仅默认未设置时兜底)
   const taskModel = modelMap[taskType]
   if (taskModel?.trim()) {
-    const cfg = resolveModelConfig(taskModel, llmConfig, providerConfigs)
-    if (isConfigUsable(cfg, providerConfigs)) {
-      return cfg
-    }
+    const cfg = resolveRegisteredModel(taskModel, llmConfig, providerConfigs)
+    if (cfg) return cfg
   }
 
-  const chatModel = aiChatModel?.trim()
-  if (chatModel) {
-    const cfg = resolveModelConfig(chatModel, llmConfig, providerConfigs)
-    if (isConfigUsable(cfg, providerConfigs)) {
-      return cfg
-    }
+  const defaultModel = resolveProjectDefaultLlmModel()
+  if (defaultModel) {
+    const cfg = resolveRegisteredModel(defaultModel, llmConfig, providerConfigs)
+    if (cfg) return cfg
   }
 
-  const defaultModel = defaultLlmModel?.trim()
-  if (defaultModel && defaultModel !== chatModel) {
-    const cfg = resolveModelConfig(defaultModel, llmConfig, providerConfigs)
-    if (isConfigUsable(cfg, providerConfigs)) {
-      return cfg
-    }
+  if (chatModel && chatModel !== defaultModel) {
+    const cfg = resolveRegisteredModel(chatModel, llmConfig, providerConfigs)
+    if (cfg) return cfg
   }
 
-  return { ...llmConfig, apiKey: "", model: "" }
+  return toUnusableConfig(llmConfig)
 }

+ 11 - 7
src/lib/novel/novel-model-test.ts

@@ -1,8 +1,8 @@
 import { streamChat } from "@/lib/llm-client"
-import { resolveNovelModel } from "@/lib/novel/model-resolver"
+import { resolveDefaultModel, resolveNovelModel } from "@/lib/novel/model-resolver"
 import type { LlmConfig, NovelConfig } from "@/stores/wiki-store"
 
-export type TestableNovelModelTask = "writing" | "review" | "summary" | "extract"
+export type TestableNovelModelTask = "writing" | "review" | "summary" | "extract" | "workflow"
 
 export interface NovelModelTestResult {
   model: string
@@ -12,6 +12,7 @@ export interface NovelModelTestResult {
 
 const TEST_PROMPTS: Record<TestableNovelModelTask, string> = {
   writing: "你正在执行小说写作模型测试。请只回复“写作模型测试成功”。",
+  workflow: "你正在执行默认模型(工作流)测试。请只回复“默认模型测试成功”。",
   review: "你正在执行小说审稿模型测试。请只回复“审稿模型测试成功”。",
   summary: "你正在执行小说摘要模型测试。请只回复“摘要模型测试成功”。",
   extract: "你正在执行小说资料提取模型测试。请只回复“提取模型测试成功”。",
@@ -22,7 +23,9 @@ export async function testNovelModel(
   novelConfig: NovelConfig,
   taskType: TestableNovelModelTask,
 ): Promise<NovelModelTestResult> {
-  const effectiveConfig = resolveNovelModel(llmConfig, novelConfig, taskType)
+  const effectiveConfig = taskType === "workflow"
+    ? resolveDefaultModel(llmConfig)
+    : resolveNovelModel(llmConfig, novelConfig, taskType)
   const model = effectiveConfig.model.trim()
   if (!model) {
     throw new Error("请先配置主模型或当前小说专用模型后再测试。")
@@ -56,10 +59,11 @@ export async function testNovelModel(
     throw new Error("模型已连接,但没有返回可用内容。")
   }
 
-  const modelKey = `${taskType}Model` as const
-
-  // writingModel 已移除,writing 任务始终视为回退到 AI 会话模型
-  const usedFallbackModel = taskType === "writing" ? true : !novelConfig[modelKey].trim()
+  const usedFallbackModel = taskType === "workflow"
+    ? !novelConfig.defaultLlmModel.trim()
+    : taskType === "writing"
+      ? true
+      : !novelConfig[`${taskType}Model` as "reviewModel" | "summaryModel" | "extractModel"].trim()
 
   return {
     model,

+ 19 - 3
src/lib/project-store.ts

@@ -484,14 +484,28 @@ export async function saveNovelConfig(config: NovelConfig, projectId?: string, p
   }
 }
 
+async function maybeMigrateLegacyDefaultLlmModel(
+  config: NovelConfig,
+  projectId?: string,
+  projectPath?: string,
+): Promise<NovelConfig> {
+  if (config.defaultLlmModel.trim()) return config
+  const legacyGlobal = await loadDefaultLlmModel()
+  if (!legacyGlobal?.trim()) return config
+  const migrated = { ...config, defaultLlmModel: legacyGlobal.trim() }
+  await saveNovelConfig(migrated, projectId, projectPath)
+  return migrated
+}
+
 export async function loadNovelConfig(projectId?: string, projectPath?: string): Promise<NovelConfig | null> {
   if (projectPath) {
     try {
       const filePath = novelConfigFilePath(projectPath)
       if (await fileExists(filePath)) {
         const raw = await readFile(filePath)
-        const config = JSON.parse(raw)
-        return normalizeNovelConfig(config)
+        const config = normalizeNovelConfig(JSON.parse(raw))
+        if (!config) return null
+        return maybeMigrateLegacyDefaultLlmModel(config, projectId, projectPath)
       }
     } catch {
       // fall through to global store
@@ -515,7 +529,8 @@ export async function loadNovelConfig(projectId?: string, projectPath?: string):
       // non-critical migration
     }
   }
-  return config
+  if (!config) return null
+  return maybeMigrateLegacyDefaultLlmModel(config, projectId, projectPath)
 }
 
 const RERANK_CONFIG_KEY = "rerankConfig"
@@ -653,6 +668,7 @@ function normalizeNovelConfig(
     deepPreviousChaptersAnalysis: config.deepPreviousChaptersAnalysis ?? DEFAULT_NOVEL_CONFIG.deepPreviousChaptersAnalysis,
     deepChapterReview: config.deepChapterReview ?? DEFAULT_NOVEL_CONFIG.deepChapterReview,
     reviewReasoningEffort: config.reviewReasoningEffort ?? DEFAULT_NOVEL_CONFIG.reviewReasoningEffort,
+    defaultLlmModel: config.defaultLlmModel ?? DEFAULT_NOVEL_CONFIG.defaultLlmModel,
     writingModel: config.writingModel ?? DEFAULT_NOVEL_CONFIG.writingModel,
     reviewModel: config.reviewModel ?? DEFAULT_NOVEL_CONFIG.reviewModel,
     summaryModel: config.summaryModel ?? DEFAULT_NOVEL_CONFIG.summaryModel,

+ 10 - 2
src/stores/wiki-store.ts

@@ -302,6 +302,8 @@ export interface NovelConfig {
   deepChapterReview: boolean
   /** 审稿(含六维审查)使用的 reasoning 档位。下调可省审稿推理 Token,但连贯性把关会变弱(默认 high)。 */
   reviewReasoningEffort: "low" | "medium" | "high"
+  /** 默认模型(工作流):拆文库、剧情推演室、去重等。空字符串表示跟随 AI 会话模型。 */
+  defaultLlmModel: string
   writingModel: string
   reviewModel: string
   summaryModel: string
@@ -327,6 +329,7 @@ export const DEFAULT_NOVEL_CONFIG: NovelConfig = {
   deepPreviousChaptersAnalysis: false,
   deepChapterReview: true,
   reviewReasoningEffort: "high",
+  defaultLlmModel: "",
   writingModel: "",
   reviewModel: "",
   summaryModel: "",
@@ -549,7 +552,7 @@ interface WikiState {
   refreshGraph: (() => void) | null
   llmConfig: LlmConfig
   aiChatModel: string
-  /** 默认模型:AI会话提取记忆、提取角色等后台任务默认使用的模型(格式: "providerId/modelId",留空则使用 AI 会话当前模型) */
+  /** 默认模型(工作流):拆文库、导入队列、去重、角色 aura 等。章节/大纲记忆摄取见 novelConfig.extractModel */
   defaultLlmModel: string
   /** Per-provider-preset stored overrides (API key, model, endpoint, …). */
   providerConfigs: ProviderConfigs
@@ -857,7 +860,12 @@ export const useWikiStore = create<WikiState>((set) => ({
   setNovelMode: (novelMode) => set({ novelMode }),
   setChatEditModeEnabled: (chatEditModeEnabled) => set({ chatEditModeEnabled }),
   setDeepChapterEnabled: (deepChapterEnabled) => set({ deepChapterEnabled }),
-  setNovelConfig: (config) => set((state) => ({ novelConfig: { ...state.novelConfig, ...config } })),
+  setNovelConfig: (config) => set((state) => ({
+    novelConfig: { ...state.novelConfig, ...config },
+    ...(config.defaultLlmModel !== undefined
+      ? { defaultLlmModel: config.defaultLlmModel }
+      : {}),
+  })),
   setCommunitySummaryError: (communitySummaryError) => set({ communitySummaryError }),
   setSearchHistory: (searchHistory) => set({ searchHistory }),
   setSearchTrigger: (searchTrigger) => set({ searchTrigger }),