Răsfoiți Sursa

feat(novel): 去 AI 味支持单独配置模型

新增 deAiModel 配置项,章节预览、选区去 AI 味与深度生成阶段6
统一走 resolveNovelModel 解析;设置页小说侧边栏增加「设置模型」提示。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 3 luni în urmă
părinte
comite
6b3e4e9a14

+ 5 - 3
src/components/layout/preview-panel.tsx

@@ -2,7 +2,7 @@ import { type CSSProperties, Suspense, lazy, useEffect, useCallback, useRef, use
 import { useTranslation } from "react-i18next"
 import { Check, MoreHorizontal, X } from "lucide-react"
 import { useWikiStore } from "@/stores/wiki-store"
-import { resolveDefaultModel } from "@/lib/novel/model-resolver"
+import { resolveDefaultModel, resolveNovelModel } 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"
@@ -827,7 +827,7 @@ export function PreviewPanel() {
     setDeAiProcessing(true)
     setDeAiSkillName(skillName)
     const state = useWikiStore.getState()
-    const llmConfig = resolveDefaultModel(state.llmConfig)
+    const llmConfig = resolveNovelModel(state.llmConfig, state.novelConfig, "deAi")
     if (!hasUsableLlm(llmConfig, state.providerConfigs)) {
       setDeAiProcessing(false)
       setSaveStatus("未配置可用的 AI 模型,无法去AI味")
@@ -900,7 +900,9 @@ export function PreviewPanel() {
   ) => {
     if (!selection.text.trim()) return
     const state = useWikiStore.getState()
-    const llmConfig = resolveDefaultModel(state.llmConfig)
+    const llmConfig = action === "de-ai"
+      ? resolveNovelModel(state.llmConfig, state.novelConfig, "deAi")
+      : resolveDefaultModel(state.llmConfig)
     if (!hasUsableLlm(llmConfig, state.providerConfigs)) {
       setSaveStatus("未配置可用的 AI 模型,无法处理选中文本")
       return

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

@@ -123,6 +123,7 @@ export function NovelSection({ draft, setDraft }: Props) {
     review: undefined,
     summary: undefined,
     extract: undefined,
+    deAi: undefined,
   })
 
   const updateNovelConfig = async (patch: Partial<NovelConfig>) => {
@@ -141,6 +142,7 @@ export function NovelSection({ draft, setDraft }: Props) {
     { task: "review", field: "reviewModel" },
     { task: "summary", field: "summaryModel" },
     { task: "extract", field: "extractModel" },
+    { task: "deAi", field: "deAiModel" },
   ] as const), [])
 
   const settingTooltip = (key: string) => (

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

@@ -58,6 +58,8 @@ interface Category {
    *  switching language in Settings → Interface takes effect without
    *  remounting this component (Bug #53). */
   labelKey: string
+  /** Optional muted subtitle under the label (e.g. novel → model setup). */
+  hintKey?: string
   icon: typeof Bot
 }
 
@@ -67,7 +69,7 @@ const CATEGORIES: Category[] = [
   { id: "embedding", labelKey: "settings.categories.embedding", icon: Database },
   { id: "network", labelKey: "settings.categories.network", icon: Network },
   { id: "interface", labelKey: "settings.categories.interface", icon: Palette },
-  { id: "novel", labelKey: "settings.categories.novel", icon: BookOpen },
+  { id: "novel", labelKey: "settings.categories.novel", hintKey: "settings.categories.novelHint", icon: BookOpen },
   { id: "usage-guide", labelKey: "settings.categories.usageGuide", icon: HelpCircle },
   { id: "maintenance", labelKey: "settings.categories.maintenance", icon: Wrench },
   { id: "data-management", labelKey: "settings.categories.dataManagement", icon: Archive },
@@ -571,7 +573,14 @@ export function SettingsView() {
                     isActive ? "text-primary" : "text-muted-foreground/80 group-hover:text-accent-foreground"
                   }`}
                 />
-                <span className="truncate">{t(c.labelKey)}</span>
+                <span className="flex min-w-0 flex-1 flex-col items-start">
+                  <span className="truncate">{t(c.labelKey)}</span>
+                  {c.hintKey ? (
+                    <span className="truncate text-[10px] leading-tight text-muted-foreground/80">
+                      {t(c.hintKey)}
+                    </span>
+                  ) : null}
+                </span>
               </button>
             )
           })}

+ 5 - 2
src/i18n/en.json

@@ -691,6 +691,7 @@
       "output": "Output",
       "interface": "Interface",
       "novel": "Novel",
+      "novelHint": "Model settings",
       "usageGuide": "Usage Guide",
       "maintenance": "Maintenance",
       "feedback": "Feedback",
@@ -1212,10 +1213,10 @@
       "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": "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.",
+      "taskModelsHint": "While writing a novel, the review, summary, extract, and de-AI 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.",
+      "defaultLlmModelScopeNote": "Review, summary, extract, and de-AI 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",
@@ -1224,6 +1225,8 @@
       "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.",
+      "deAiModel": "De-AI Model",
+      "deAiModelHint": "Used while writing a novel for chapter preview de-AI, selection de-AI, and the final light review and de-AI pass in deep generation. 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.",
       "followChatModel": "Follow chat model",
       "followDefaultModel": "Follow default model",
       "fetchModels": "Fetch Models",

+ 5 - 2
src/i18n/zh.json

@@ -403,6 +403,7 @@
       "output": "输出偏好",
       "interface": "界面",
       "novel": "小说",
+      "novelHint": "设置模型",
       "usageGuide": "软件使用说明",
       "maintenance": "维护",
       "feedback": "反馈",
@@ -1081,10 +1082,10 @@
       "reviewReasoningEffort": "审稿思考档位",
       "reviewReasoningEffortHint": "控制 AI 审稿(含六维审查)的思考深度。档位越低越省 Token、越快,但连贯性与设定冲突的把关会变弱。默认「高」。",
       "taskModelsTitle": "分环节指定模型(写小说)",
-      "taskModelsHint": "写小说时,审稿、摘要、提取信息这几个环节可以各用一个模型。默认它们都跟随上面的「默认模型」,你不用管(默认模型也没设时,才用聊天框底部选中的模型兜底)。只有某个环节想固定用别的模型时,才取消它的「跟随默认模型」并在右侧选一个。",
+      "taskModelsHint": "写小说时,审稿、摘要、提取信息、去 AI 味这几个环节可以各用一个模型。默认它们都跟随上面的「默认模型」,你不用管(默认模型也没设时,才用聊天框底部选中的模型兜底)。只有某个环节想固定用别的模型时,才取消它的「跟随默认模型」并在右侧选一个。",
       "defaultLlmModel": "默认模型(通用)",
       "defaultLlmModelHint": "写正文以外的功能都用它:审稿、摘要、提取,以及拆书、剧情推演、角色灵魂、AI 改写/润色、重复条目检测、资料自动导入等。建议在这里指定一个模型。留空(勾选「跟随聊天模型」)时,才用聊天框底部选中的模型兜底。",
-      "defaultLlmModelScopeNote": "写小说的审稿、摘要、提取默认都用这个模型;需要时可在下方「分环节指定模型」里单独指定。",
+      "defaultLlmModelScopeNote": "写小说的审稿、摘要、提取、去 AI 味默认都用这个模型;需要时可在下方「分环节指定模型」里单独指定。",
       "writingModel": "写作模型",
       "writingModelHint": "写小说时用于生成、续写和改写正文,直接使用聊天框底部选中的模型。",
       "reviewModel": "审稿模型",
@@ -1093,6 +1094,8 @@
       "summaryModelHint": "写小说时用于生成章节摘要、压缩过长的上下文、写作前整理资料。默认跟随上面的「默认模型」(默认模型也没设时才用聊天模型兜底);想固定用别的模型,就取消「跟随默认模型」并在右侧选一个。",
       "extractModel": "提取模型(不是向量模型)",
       "extractModelHint": "写小说时用于从大纲和章节里提取人物、地点、事件、关系和设定。默认跟随上面的「默认模型」(默认模型也没设时才用聊天模型兜底);想固定用别的模型,就取消「跟随默认模型」并在右侧选一个。注意:这不是用来做向量检索的嵌入模型。",
+      "deAiModel": "去 AI 味模型",
+      "deAiModelHint": "写小说时用于章节预览的去 AI 味、选区去 AI 味,以及深度生成最后一遍简单审查与去 AI 味。默认跟随上面的「默认模型」(默认模型也没设时才用聊天模型兜底);想固定用别的模型,就取消「跟随默认模型」并在右侧选一个。",
       "followChatModel": "跟随聊天模型",
       "followDefaultModel": "跟随默认模型",
       "fetchModels": "拉取模型",

+ 4 - 2
src/lib/changelog.ts

@@ -83,12 +83,14 @@ const TWO_POINT_TWO_THIRTY_TWO_CHANGELOG: ChangelogEntry = {
   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.",
+      "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.",
     ],
     zh: [
-      "重构写小说的模型选择逻辑,统一为「该环节单独设置 > 默认模型 > 聊天模型兜底」:聊天框选中的模型只用于聊天和写正文,审稿、摘要、提取等环节默认跟随「默认模型」。",
+      "重构写小说的模型选择逻辑,统一为「该环节单独设置 > 默认模型 > 聊天模型兜底」:聊天框选中的模型只用于聊天和写正文,审稿、摘要、提取、去 AI 味等环节默认跟随「默认模型」。",
+      "小说设置新增「去 AI 味模型」,可单独指定章节预览去 AI 味、选区去 AI 味与深度生成阶段6使用的模型。",
       "设置页各模型说明全部改写为通俗易懂的表述;「跟随」复选框拆分为「跟随默认模型」与「跟随聊天模型」,避免同一控件在不同位置含义相反。",
     ],
   },

+ 6 - 4
src/lib/novel/deep-chapter-generation.ts

@@ -1,6 +1,7 @@
 import type { LlmConfig } from "@/stores/wiki-store"
 import { streamChat, type ChatMessage, type RequestOverrides, type StreamCallbacks } from "@/lib/llm-client"
 import { useWikiStore } from "@/stores/wiki-store"
+import { resolveNovelModel } from "./model-resolver"
 import { buildContextPack, contextPackToPrompt, type ContextPack } from "./context-engine"
 import { reviewChapter, type NovelReviewResult } from "./review-adapter"
 import type { TaskRouteResult } from "./task-router"
@@ -149,9 +150,10 @@ export async function runDeepChapterGeneration(
 ): Promise<DeepChapterGenerationResult> {
   assertNotAborted(signal)
   const resumeCheckpoint = input.resumeCheckpoint
+  const novelConfig = useWikiStore.getState().novelConfig
   const writingConfig = resolveWritingConfig(input.llmConfig)
+  const deAiConfig = resolveNovelModel(input.llmConfig, novelConfig, "deAi")
   const lengthSpec = resolveCurrentChapterLengthSpec()
-  const novelConfig = useWikiStore.getState().novelConfig
   const { loadSmartDeAiSkill } = await import("./de-ai-adapter")
 
   // 将在阶段1构建contextPack后再加载skill(需要contextPack用于场景检测)
@@ -430,7 +432,7 @@ export async function runDeepChapterGeneration(
   }
 
   const finalContent = await finalPolishChapter(
-    writingConfig,
+    deAiConfig,
     outlinePrompt,
     contextPrompt,
     taskBrief,
@@ -461,7 +463,7 @@ export async function runDeepChapterGeneration(
 }
 
 async function finalPolishChapter(
-  writingConfig: LlmConfig,
+  deAiConfig: LlmConfig,
   outlinePrompt: string,
   contextPrompt: string,
   taskBrief: string,
@@ -478,7 +480,7 @@ async function finalPolishChapter(
   assertNotAborted(signal)
   callbacks.onThinking?.(formatStageThinking("阶段6:简单审查与去AI味", "正在进行最后一遍简单审查,去除复读、机械套话和 AI 味。"))
   const polished = await collectModelText(
-    writingConfig,
+    deAiConfig,
     [{
       role: "user",
       content: buildDeepChapterFinalPolishPrompt(

+ 2 - 1
src/lib/novel/model-resolver.ts

@@ -3,7 +3,7 @@ import { LLM_PRESETS } from "@/components/settings/llm-presets"
 import { resolveConfig } from "@/components/settings/preset-resolver"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 
-export type NovelTaskType = "writing" | "review" | "summary" | "extract" | "lint"
+export type NovelTaskType = "writing" | "review" | "summary" | "extract" | "lint" | "deAi"
 
 const UNUSABLE_LLM_CONFIG: Pick<LlmConfig, "provider" | "apiKey" | "model"> = {
   provider: "openai",
@@ -125,6 +125,7 @@ export function resolveNovelModel(
     summary: novelConfig.summaryModel,
     extract: novelConfig.extractModel,
     lint: novelConfig.reviewModel,
+    deAi: novelConfig.deAiModel,
   }
 
   const { providerConfigs, aiChatModel } = useWikiStore.getState()

+ 3 - 2
src/lib/novel/novel-model-test.ts

@@ -2,7 +2,7 @@ import { streamChat } from "@/lib/llm-client"
 import { resolveDefaultModel, resolveNovelModel } from "@/lib/novel/model-resolver"
 import type { LlmConfig, NovelConfig } from "@/stores/wiki-store"
 
-export type TestableNovelModelTask = "writing" | "review" | "summary" | "extract" | "workflow"
+export type TestableNovelModelTask = "writing" | "review" | "summary" | "extract" | "deAi" | "workflow"
 
 export interface NovelModelTestResult {
   model: string
@@ -16,6 +16,7 @@ const TEST_PROMPTS: Record<TestableNovelModelTask, string> = {
   review: "你正在执行小说审稿模型测试。请只回复“审稿模型测试成功”。",
   summary: "你正在执行小说摘要模型测试。请只回复“摘要模型测试成功”。",
   extract: "你正在执行小说资料提取模型测试。请只回复“提取模型测试成功”。",
+  deAi: "你正在执行小说去AI味模型测试。请只回复“去AI味模型测试成功”。",
 }
 
 export async function testNovelModel(
@@ -63,7 +64,7 @@ export async function testNovelModel(
     ? !novelConfig.defaultLlmModel.trim()
     : taskType === "writing"
       ? true
-      : !novelConfig[`${taskType}Model` as "reviewModel" | "summaryModel" | "extractModel"].trim()
+      : !novelConfig[`${taskType}Model` as "reviewModel" | "summaryModel" | "extractModel" | "deAiModel"].trim()
 
   return {
     model,

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

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

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

@@ -308,6 +308,8 @@ export interface NovelConfig {
   reviewModel: string
   summaryModel: string
   extractModel: string
+  /** 去 AI 味:章节预览去 AI 味、深度生成阶段6。空字符串表示跟随默认模型。 */
+  deAiModel: string
   /** 社区摘要自动提取:开启后每 N 章用 LLM 为图谱社区生成叙事摘要,用于回答全局性问题(默认开)。 */
   communitySummaryEnabled: boolean
   /** 社区摘要提取间隔:每摄取多少章后自动重建一次社区摘要(默认 5)。 */
@@ -334,6 +336,7 @@ export const DEFAULT_NOVEL_CONFIG: NovelConfig = {
   reviewModel: "",
   summaryModel: "",
   extractModel: "",
+  deAiModel: "",
   communitySummaryEnabled: true,
   communitySummaryInterval: 5,
   communitySummaryAsync: true,