Kaynağa Gözat

fix(settings): 让写作设置重新接到真实运行路径

批量去 AI 味并发数此前只写进配置,章节队列并不读取;单章目标字数 UI 允许 500–20000,运行时却钳成 2000–6000;修改反馈窗口和对话历史只改 draft,不点保存就不生效。本次把这些项接到实际消费点,检索 TOPK 同时约束图谱检索,前情分析在标准模式也可开启,并移除已被工作流模式架空的审稿开关。

Co-authored-by: darknessomi <darknessomi@users.noreply.github.com>
Cursor Agent 1 ay önce
ebeveyn
işleme
63e0166967

+ 11 - 0
src/components/layout/preview-panel.tsx

@@ -55,6 +55,7 @@ import { registerEditorDiskSyncHandler } from "@/lib/editor-disk-sync-session"
 import { registerEditorExternalUpdateHandler } from "@/lib/editor-external-update-session"
 import { createChapterExternalUpdateCoordinator } from "@/lib/chapter-external-update-coordinator"
 import { applyOpenChapterBodyUpdate, createDeAiBatchChapterApplier } from "@/lib/novel/de-ai-batch/chapter-apply"
+import { acquireDeAiChapterSlot } from "@/lib/novel/de-ai-batch/chapter-concurrency"
 import { toast } from "@/lib/toast"
 import { selectProjectDeAiReview, selectProjectDeAiTasks, useDeAiTaskStore } from "@/stores/de-ai-task-store"
 import { DeAiBatchReviewDialog } from "@/components/novel/de-ai-batch-review-dialog"
@@ -1003,9 +1004,12 @@ export function PreviewPanel() {
       modelName: modelLabel,
       sourceContent: source,
     })
+    const release = await acquireDeAiChapterSlot()
     let result = ""
     let doneCalled = false
     try {
+      const current = useDeAiTaskStore.getState().tasks.find((task) => task.id === taskId)
+      if (!current || current.status === "cancelled") return
       await streamChat(
         llmConfig,
         buildDeAiRewriteMessages(source, skillContent),
@@ -1037,6 +1041,8 @@ export function PreviewPanel() {
       if (!doneCalled) {
         useDeAiTaskStore.getState().failTask(taskId, String(err))
       }
+    } finally {
+      release()
     }
   }, [syncDiskBeforeAction, selectedFile, project, chapterHeader, chapterDeAiOptions.currentSkillId])
 
@@ -1769,8 +1775,11 @@ export function PreviewPanel() {
                 useDeAiTaskStore.getState().failTask(chapterId, "未配置可用的 AI 模型")
                 return
               }
+              const release = await acquireDeAiChapterSlot()
               let result = ""
               try {
+                const current = useDeAiTaskStore.getState().tasks.find((item) => item.id === chapterId)
+                if (!current || current.status === "cancelled") return
                 const source = extractDeAiChapterText(task.sourceContent)
                 await streamChat(
                   llmConfig,
@@ -1787,6 +1796,8 @@ export function PreviewPanel() {
                 )
               } catch (err) {
                 useDeAiTaskStore.getState().failTask(chapterId, String(err))
+              } finally {
+                release()
               }
             }}
             onCancelChapter={(_taskId, chapterId) => {

+ 2 - 1
src/components/novel/de-ai-batch-entry.spec.ts

@@ -29,10 +29,11 @@ describe("de-ai batch entry and settings", () => {
     expect(previewPanel).toContain("buildDeAiRewriteMessages(source, task.skillContent)")
   })
 
-  it("小说设置包含默认 3、范围 1–5 的批量并发设置", () => {
+  it("小说设置包含默认 3、范围 1–5 的批量并发设置,并接到章节去 AI 味队列", () => {
     expect(wikiStore).toContain("deAiBatchConcurrency: 3")
     expect(novelSection).toContain("min={1}")
     expect(novelSection).toContain("max={5}")
     expect(novelSection).toContain("deAiBatchConcurrency")
+    expect(previewPanel).toContain("acquireDeAiChapterSlot")
   })
 })

+ 40 - 37
src/components/settings/sections/novel-section.tsx

@@ -6,13 +6,16 @@ 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, saveDefaultLlmModel } from "@/lib/project-store"
+import { saveNovelConfig, saveDefaultLlmModel, saveRevisionFeedbackWindowConfig, saveMaxHistoryMessages } from "@/lib/project-store"
 import { getFirstAvailableModelKey, hasAvailableModels } from "@/lib/llm-model-keys"
+import { notifyDeAiChapterConcurrencyChanged } from "@/lib/novel/de-ai-batch/chapter-concurrency"
+import { CHAPTER_TARGET_CHARS_MAX, CHAPTER_TARGET_CHARS_MIN } from "@/lib/novel/deep-chapter-prompts"
+import { useChatStore } from "@/stores/chat-store"
 
 import { testNovelModel, type TestableNovelModelTask } from "@/lib/novel/novel-model-test"
 import { ChatModelSelector } from "@/components/chat/chat-model-selector"
 import type { SettingsDraft, DraftSetter } from "../settings-types"
-import type { NovelConfig } from "@/stores/wiki-store"
+import type { NovelConfig, RevisionFeedbackWindowConfig } from "@/stores/wiki-store"
 
 interface Props {
   draft: SettingsDraft
@@ -100,6 +103,8 @@ function NovelModelPickerBlock({
 export function NovelSection({ draft, setDraft }: Props) {
   const { t } = useTranslation()
   const setNovelConfigStore = useWikiStore((s) => s.setNovelConfig)
+  const setRevisionFeedbackWindowConfig = useWikiStore((s) => s.setRevisionFeedbackWindowConfig)
+  const setMaxHistoryMessages = useChatStore((s) => s.setMaxHistoryMessages)
   const llmConfig = useWikiStore((s) => s.llmConfig)
   const aiChatModel = useWikiStore((s) => s.aiChatModel)
   const providerConfigs = useWikiStore((s) => s.providerConfigs)
@@ -131,6 +136,22 @@ export function NovelSection({ draft, setDraft }: Props) {
     setDraft("novelConfig", newConfig)
     setNovelConfigStore(patch)
     await saveNovelConfig(newConfig, project?.id, project?.path)
+    if (patch.deAiBatchConcurrency !== undefined) {
+      notifyDeAiChapterConcurrencyChanged()
+    }
+  }
+
+  const updateFeedbackWindow = async (patch: Partial<RevisionFeedbackWindowConfig>) => {
+    const next = { ...draft.revisionFeedbackWindowConfig, ...patch }
+    setDraft("revisionFeedbackWindowConfig", next)
+    setRevisionFeedbackWindowConfig(next)
+    await saveRevisionFeedbackWindowConfig(next, project?.id, project?.path)
+  }
+
+  const updateMaxHistoryMessages = async (count: number) => {
+    setDraft("maxHistoryMessages", count)
+    setMaxHistoryMessages(count)
+    await saveMaxHistoryMessages(count, project?.id, project?.path)
   }
 
   const updateWorkflowDefaultModel = async (model: string) => {
@@ -263,10 +284,13 @@ export function NovelSection({ draft, setDraft }: Props) {
           </div>
 
           <div className="space-y-2">
-            <Label htmlFor="de-ai-batch-concurrency-setting">批量去 AI 味并发作品数</Label>
+            <div className="flex items-center gap-1.5">
+              <Label htmlFor="de-ai-batch-concurrency-setting">{t("novel.settings.deAiBatchConcurrency")}</Label>
+              {settingTooltip("deAiBatchConcurrencyHint")}
+            </div>
             <Input
               id="de-ai-batch-concurrency-setting"
-              aria-label="批量去 AI 味并发作品数"
+              aria-label={t("novel.settings.deAiBatchConcurrency")}
               type="number"
               min={1}
               max={5}
@@ -276,7 +300,7 @@ export function NovelSection({ draft, setDraft }: Props) {
               })}
               className="w-24"
             />
-            <p className="text-xs text-muted-foreground">默认同时处理 3 个作品,可设置 1–5;超出后按添加顺序排队。</p>
+            <p className="text-xs text-muted-foreground">{t("novel.settings.deAiBatchConcurrencyHint")}</p>
           </div>
 
           <div className="space-y-2">
@@ -308,7 +332,7 @@ export function NovelSection({ draft, setDraft }: Props) {
                   <button
                     key={n}
                     type="button"
-                    onClick={() => setDraft("maxHistoryMessages", n)}
+                    onClick={() => void updateMaxHistoryMessages(n)}
                     className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
                       active
                         ? "border-primary bg-primary text-primary-foreground"
@@ -335,12 +359,15 @@ export function NovelSection({ draft, setDraft }: Props) {
             </div>
             <Input
               type="number"
-              min={500}
-              max={20000}
+              min={CHAPTER_TARGET_CHARS_MIN}
+              max={CHAPTER_TARGET_CHARS_MAX}
               step={100}
               value={draft.novelConfig.chapterTargetChars}
               onChange={(e) => updateNovelConfig({
-                chapterTargetChars: Math.max(500, Math.min(20000, Number(e.target.value) || 3000)),
+                chapterTargetChars: Math.max(
+                  CHAPTER_TARGET_CHARS_MIN,
+                  Math.min(CHAPTER_TARGET_CHARS_MAX, Number(e.target.value) || 3000),
+                ),
               })}
               className="w-32"
             />
@@ -389,26 +416,6 @@ export function NovelSection({ draft, setDraft }: Props) {
             </button>
           </div>
 
-          <div className="flex items-center justify-between gap-3">
-            <div className="flex items-center gap-1.5">
-              <Label>{t("novel.settings.deepChapterReview")}</Label>
-              {settingTooltip("deepChapterReviewHint")}
-            </div>
-            <button
-              type="button"
-              onClick={() => updateNovelConfig({ deepChapterReview: !draft.novelConfig.deepChapterReview })}
-              className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
-                draft.novelConfig.deepChapterReview ? "bg-primary" : "bg-input"
-              }`}
-            >
-              <span
-                className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform ${
-                  draft.novelConfig.deepChapterReview ? "translate-x-5" : "translate-x-0"
-                }`}
-              />
-            </button>
-          </div>
-
           <div className="flex items-center justify-between gap-3">
             <div className="flex items-center gap-1.5">
               <Label>{t("novel.settings.reviewReasoningEffort")}</Label>
@@ -562,8 +569,7 @@ export function NovelSection({ draft, setDraft }: Props) {
               type="number"
               min={0}
               value={draft.revisionFeedbackWindowConfig.lookbackChapterCount}
-              onChange={(event) => setDraft("revisionFeedbackWindowConfig", {
-                ...draft.revisionFeedbackWindowConfig,
+              onChange={(event) => void updateFeedbackWindow({
                 lookbackChapterCount: Math.max(0, Number(event.target.value) || 0),
               })}
               className="w-24 rounded-md border bg-background px-3 py-1.5 text-sm"
@@ -595,8 +601,7 @@ export function NovelSection({ draft, setDraft }: Props) {
             </div>
             <button
               type="button"
-              onClick={() => setDraft("revisionFeedbackWindowConfig", {
-                ...draft.revisionFeedbackWindowConfig,
+              onClick={() => void updateFeedbackWindow({
                 currentChapterIncludeShouldImprove: !draft.revisionFeedbackWindowConfig.currentChapterIncludeShouldImprove,
               })}
               className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
@@ -630,8 +635,7 @@ export function NovelSection({ draft, setDraft }: Props) {
             </div>
             <button
               type="button"
-              onClick={() => setDraft("revisionFeedbackWindowConfig", {
-                ...draft.revisionFeedbackWindowConfig,
+              onClick={() => void updateFeedbackWindow({
                 previousChapterCarryEnabled: !draft.revisionFeedbackWindowConfig.previousChapterCarryEnabled,
               })}
               className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
@@ -665,8 +669,7 @@ export function NovelSection({ draft, setDraft }: Props) {
             </div>
             <button
               type="button"
-              onClick={() => setDraft("revisionFeedbackWindowConfig", {
-                ...draft.revisionFeedbackWindowConfig,
+              onClick={() => void updateFeedbackWindow({
                 lookbackIncludeMustFixOnly: !draft.revisionFeedbackWindowConfig.lookbackIncludeMustFixOnly,
               })}
               className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${

+ 8 - 6
src/i18n/en.json

@@ -1358,7 +1358,9 @@
       "recentSummaryWindow": "Recent Chapter Text Window",
       "recentSummaryWindowHint": "Controls how many recent chapters are included in the writing context. The system reads text excerpts from these chapters and combines them with chapter summaries to preserve continuity; higher values use more context.",
       "searchTopK": "Search Result Count (Retrieval TOPK)",
-      "searchTopKHint": "Controls how many relevant memory records are retrieved and injected into the writing context. Higher values add more references, but can add noise and consume context.",
+      "searchTopKHint": "Controls how many relevant memory records (vector, keyword, and graph) are retrieved and injected into the writing context. Higher values add more references, but can add noise and consume context.",
+      "deAiBatchConcurrency": "De-AI batch concurrent chapters",
+      "deAiBatchConcurrencyHint": "How many chapters can run de-AI at the same time. Default 3, range 1–5; extra chapters queue in click order so the model quota is not flooded.",
       "chatHistoryLength": "Chat History Length",
       "chatHistoryLengthHint": "Number of previous messages sent to the AI with each request. More history gives fuller context but uses more tokens.",
       "chatHistoryLengthHelp": "Controls how many AI chat messages are included in each request. Higher values preserve more conversation context and consume more tokens.",
@@ -1369,10 +1371,10 @@
       "autoIngestOnSaveHint": "When enabled, saving a canon chapter automatically extracts characters, locations, events, relationships, and canon facts into the novel memory library for later search and continuation.",
       "reviewBeforeSave": "Auto-review before save",
       "reviewBeforeSaveHint": "When enabled, canon chapter saves first call the review model to check continuity, canon conflicts, and issues that should be fixed.",
-      "deepPreviousChaptersAnalysis": "Deep mode: prior-chapters analysis (extra call)",
-      "deepPreviousChaptersAnalysisHint": "When enabled, deep generation of chapter 2+ first reads and LLM-analyzes the full text of the previous chapters — stronger continuity, but one extra call per chapter and more tokens. When off, recent summaries and the previous chapter ending from the memory library are still injected. Off by default to save tokens.",
+      "deepPreviousChaptersAnalysis": "Prior-chapters analysis (extra call)",
+      "deepPreviousChaptersAnalysisHint": "When enabled, standard/strict generation of chapter 2+ first reads and LLM-analyzes the full text of the previous chapters — stronger continuity, but one extra call per chapter and more tokens. Fast mode always skips this. When off, recent summaries and the previous chapter ending from the memory library are still injected. Off by default to save tokens.",
       "deepChapterReview": "Deep mode: AI review & auto-revision",
-      "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.",
+      "deepChapterReviewHint": "Superseded by the Fast/Standard/Strict workflow selector: strict always reviews and revises; fast and standard skip. Kept only for old saved configs.",
       "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)",
@@ -1383,9 +1385,9 @@
       "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.",
+      "reviewModelHint": "Used while writing a novel for review, continuity checks, and manual review in the Review Center. 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.",
+      "summaryModelHint": "Used while writing a novel for graph community summaries (global questions such as factions and relationship networks). Chapter summaries are generated together with the extract model. 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",

+ 8 - 6
src/i18n/zh.json

@@ -1227,7 +1227,9 @@
       "recentSummaryWindow": "最近章节正文窗口",
       "recentSummaryWindowHint": "控制写作时纳入上下文的最近章节数量。系统会读取这些章节的正文片段,并结合章节摘要帮助模型承接近期剧情;数值越大越连贯,但会占用更多上下文。",
       "searchTopK": "检索结果数量(检索 TOPK)",
-      "searchTopKHint": "控制从小说记忆库中检索并注入上下文的相关资料条数。数值越大,参考资料越多,但噪音和上下文占用也会增加。",
+      "searchTopKHint": "控制从小说记忆库(含向量、关键词和图谱)检索并注入上下文的相关资料条数。数值越大,参考资料越多,但噪音和上下文占用也会增加。",
+      "deAiBatchConcurrency": "批量去 AI 味并发章节数",
+      "deAiBatchConcurrencyHint": "同时处理多少章去 AI 味。默认 3,范围 1–5;超出后按点击顺序排队,避免一次打满模型配额。",
       "chatHistoryLength": "对话历史长度",
       "chatHistoryLengthHint": "每次请求发给 AI 的历史消息条数。多 = 上下文更完整但更费 token。",
       "chatHistoryLengthHelp": "控制 AI 会话中每次请求携带多少条历史消息。数量越多,AI 记得的上下文越完整,但消耗的 token 也越多。",
@@ -1238,10 +1240,10 @@
       "autoIngestOnSaveHint": "开启后,章节保存为正式章节时会自动从正文中提取人物、地点、事件、关系和设定,写入小说记忆库,方便后续检索和续写。",
       "reviewBeforeSave": "保存前自动审稿",
       "reviewBeforeSaveHint": "开启后,保存正式章节前会先调用审稿模型检查连贯性、设定冲突和需要修复的问题。",
-      "deepPreviousChaptersAnalysis": "深度模式:前情分析(额外调用)",
-      "deepPreviousChaptersAnalysisHint": "开启后,深度生成第2章及以后时会先读取并用 LLM 分析前几章完整正文,连贯性更强但每章多一次调用、更费 Token。关闭时仍会注入记忆库的近期摘要与上一章结尾。默认关闭以省 Token。",
+      "deepPreviousChaptersAnalysis": "前情分析(额外调用)",
+      "deepPreviousChaptersAnalysisHint": "开启后,标准/严格模式在生成第2章及以后时会先读取并用 LLM 分析前几章完整正文,连贯性更强但每章多一次调用、更费 Token。快速模式始终跳过。关闭时仍会注入记忆库的近期摘要与上一章结尾。默认关闭以省 Token。",
       "deepChapterReview": "深度模式:AI 审稿与自动返修",
-      "deepChapterReviewHint": "开启后,深度生成在初稿后会进行 AI 审稿,发现阻断问题时自动返修。关闭则初稿直接进入简单审查与去 AI 味,省下审稿与返修调用,但少了一道连贯性把关。默认开启。",
+      "deepChapterReviewHint": "此开关已被聊天栏的快速/标准/严格模式取代:严格模式始终审稿与返修,快速和标准模式跳过。保留仅兼容旧配置。",
       "reviewReasoningEffort": "审稿思考档位",
       "reviewReasoningEffortHint": "控制 AI 审稿(含六维审查)的思考深度。档位越低越省 Token、越快,但连贯性与设定冲突的把关会变弱。默认「高」。",
       "taskModelsTitle": "分环节指定模型(写小说)",
@@ -1252,9 +1254,9 @@
       "writingModel": "写作模型",
       "writingModelHint": "写小说时用于生成、续写和改写正文,直接使用聊天框底部选中的模型。",
       "reviewModel": "审稿模型",
-      "reviewModelHint": "写小说时用于审稿、检查前后是否连贯、保存前自动审稿。默认跟随上面的「默认模型」(默认模型也没设时才用聊天模型兜底);想固定用别的模型,就取消「跟随默认模型」并在右侧选一个。",
+      "reviewModelHint": "写小说时用于审稿、检查前后是否连贯,以及审查中心的手动审稿。默认跟随上面的「默认模型」(默认模型也没设时才用聊天模型兜底);想固定用别的模型,就取消「跟随默认模型」并在右侧选一个。",
       "summaryModel": "摘要模型",
-      "summaryModelHint": "写小说时用于生成章节摘要、压缩过长的上下文、写作前整理资料。默认跟随上面的「默认模型」(默认模型也没设时才用聊天模型兜底);想固定用别的模型,就取消「跟随默认模型」并在右侧选一个。",
+      "summaryModelHint": "写小说时用于图谱社区摘要(回答全局性问题,如角色阵营、关系网络)。章节摘要随提取模型一起生成。默认跟随上面的「默认模型」(默认模型也没设时才用聊天模型兜底);想固定用别的模型,就取消「跟随默认模型」并在右侧选一个。",
       "extractModel": "提取模型(非嵌入模型)",
       "extractModelHint": "写小说时用于从大纲和章节里提取人物、地点、事件、关系和设定。默认跟随上面的「默认模型」(默认模型也没设时才用聊天模型兜底);想固定用别的模型,就取消「跟随默认模型」并在右侧选一个。注意:这不是向量嵌入模型,也不是用来做向量检索的模型。",
       "deAiModel": "去 AI 味模型",

+ 2 - 0
src/lib/changelog.ts

@@ -13,9 +13,11 @@ const THREE_POINT_ONE_FIVE_CHANGELOG: ChangelogEntry = {
   highlights: {
     en: [
       "[MiMo Thinking Fix] Fixed Xiaomi MiMo models hitting the thinking token cap and producing no response. MiMo is now correctly recognized as a chat-template thinking model, so the auto-retry with thinking disabled works properly. Also added max_tokens protection for all OpenAI-compatible thinking models (Qwen3, MiMo, DeepSeek, GLM-5+) to ensure the response has enough room for both reasoning and answer.",
+      "[Writing Settings] Wired de-AI batch concurrency to the chapter queue, stopped silently clamping chapter target length to 2000–6000, persisted the revision-feedback window and chat history immediately, applied retrieval Top-K to graph search, enabled prior-chapter analysis in standard mode, and removed the dead AI-review toggle superseded by workflow modes.",
     ],
     zh: [
       "【小米 MiMo 思考上限修复】修复使用小米 MiMo 模型时频繁提示「思考上限」、只输出思考内容不出正文的问题。MiMo 现在被正确识别为 chat_template_kwargs 类型思考模型,自动关闭思考重试可正常生效;同时为所有 OpenAI 兼容思考模型(Qwen3、MiMo、DeepSeek、GLM-5+)增加 max_tokens 输出保护,确保思考和正文都有足够 token 空间,不再因思考耗尽输出配额而空响应",
+      "【写作设置生效修复】批量去 AI 味并发数接到当前章节队列;单章目标字数不再被运行时钳成 2000–6000;修改反馈窗口与对话历史立即落盘;检索 TOPK 同时约束图谱检索;前情分析在标准模式也可生效;移除已被工作流模式架空的「AI 审稿与自动返修」开关",
     ],
   },
 };

+ 2 - 1
src/lib/novel/context-data-sources.ts

@@ -433,7 +433,8 @@ export const graphSearchResultsDataSource: DataSource<string> = {
     return await searchGraphRelevantContent(
       context.projectPath,
       context.task,
-      context.chapterNumber
+      context.chapterNumber,
+      context.config.searchTopK,
     )
   },
 }

+ 6 - 4
src/lib/novel/context-engine.ts

@@ -983,7 +983,9 @@ export async function searchGraphRelevantContent(
   pp: string,
   task: string,
   _chapterNumber: number | undefined,
+  limit = 10,
 ): Promise<string> {
+  const topK = Math.max(1, Math.floor(limit) || 10)
   try {
     const { buildRetrievalGraph, getRelatedNodes } = await import("@/lib/graph-relevance")
     const graph = await buildRetrievalGraph(pp)
@@ -1039,7 +1041,7 @@ export async function searchGraphRelevantContent(
     scoredNodes.sort((a, b) => b.relevance - a.relevance)
     const topNodes = await rerankCandidates(
       task,
-      scoredNodes.slice(0, 10).map((node, index) => ({
+      scoredNodes.slice(0, topK).map((node, index) => ({
         id: `graph:${index}:${node.title}`,
         title: node.title,
         snippet: node.snippet,
@@ -1047,10 +1049,10 @@ export async function searchGraphRelevantContent(
         relevance: node.relevance,
       })),
       {
-        topK: 10,
+        topK,
         purpose: "用于补充图谱关联上下文,优先保留和当前任务最直接相关的关联节点。",
       },
-    ).catch(() => scoredNodes.slice(0, 10))
+    ).catch(() => scoredNodes.slice(0, topK))
 
     const nodeResults = topNodes.length > 0
       ? topNodes.map(
@@ -1062,7 +1064,7 @@ export async function searchGraphRelevantContent(
     let communityResults = ""
     try {
       const { searchCommunitySummaries } = await import("./community-summary")
-      communityResults = await searchCommunitySummaries(pp, task, 3)
+      communityResults = await searchCommunitySummaries(pp, task, topK)
     } catch {
       // 社区摘要检索失败不影响主流程
     }

+ 85 - 0
src/lib/novel/de-ai-batch/chapter-concurrency.spec.ts

@@ -0,0 +1,85 @@
+import { afterEach, describe, expect, it } from "vitest"
+import { DEFAULT_NOVEL_CONFIG, useWikiStore } from "@/stores/wiki-store"
+import {
+  acquireDeAiChapterSlot,
+  getDeAiChapterConcurrencySnapshot,
+  notifyDeAiChapterConcurrencyChanged,
+  resetDeAiChapterConcurrencyForTests,
+} from "./chapter-concurrency"
+
+function deferred<T = void>() {
+  let resolve!: (value: T) => void
+  const promise = new Promise<T>((res) => {
+    resolve = res
+  })
+  return { promise, resolve }
+}
+
+async function flush(): Promise<void> {
+  await new Promise((resolve) => setTimeout(resolve, 0))
+}
+
+describe("chapter de-AI concurrency gate", () => {
+  afterEach(() => {
+    resetDeAiChapterConcurrencyForTests()
+    useWikiStore.setState({ novelConfig: DEFAULT_NOVEL_CONFIG })
+  })
+
+  it("reads deAiBatchConcurrency from novelConfig and queues overflow FIFO", async () => {
+    useWikiStore.setState({
+      novelConfig: { ...DEFAULT_NOVEL_CONFIG, deAiBatchConcurrency: 2 },
+    })
+    const started: number[] = []
+    const gates = [deferred(), deferred(), deferred()]
+
+    const run = async (index: number) => {
+      const release = await acquireDeAiChapterSlot()
+      started.push(index)
+      await gates[index].promise
+      release()
+    }
+
+    const tasks = [run(0), run(1), run(2)]
+    await flush()
+    expect(started).toEqual([0, 1])
+    expect(getDeAiChapterConcurrencySnapshot()).toMatchObject({ active: 2, queued: 1, limit: 2 })
+
+    gates[0].resolve()
+    await flush()
+    expect(started).toEqual([0, 1, 2])
+
+    gates[1].resolve()
+    gates[2].resolve()
+    await Promise.all(tasks)
+    expect(getDeAiChapterConcurrencySnapshot()).toMatchObject({ active: 0, queued: 0 })
+  })
+
+  it("pumps queued work when the setting is raised", async () => {
+    useWikiStore.setState({
+      novelConfig: { ...DEFAULT_NOVEL_CONFIG, deAiBatchConcurrency: 1 },
+    })
+    const started: number[] = []
+    const gate = deferred()
+
+    const run = async (index: number) => {
+      const release = await acquireDeAiChapterSlot()
+      started.push(index)
+      await gate.promise
+      release()
+    }
+
+    void run(0)
+    void run(1)
+    await flush()
+    expect(started).toEqual([0])
+
+    useWikiStore.setState({
+      novelConfig: { ...DEFAULT_NOVEL_CONFIG, deAiBatchConcurrency: 3 },
+    })
+    notifyDeAiChapterConcurrencyChanged()
+    await flush()
+    expect(started).toEqual([0, 1])
+
+    gate.resolve()
+  })
+})

+ 49 - 0
src/lib/novel/de-ai-batch/chapter-concurrency.ts

@@ -0,0 +1,49 @@
+import { useWikiStore } from "@/stores/wiki-store"
+import { clampDeAiBatchConcurrency } from "./scheduler"
+
+let active = 0
+const waiters: Array<() => void> = []
+
+function currentLimit(): number {
+  return clampDeAiBatchConcurrency(useWikiStore.getState().novelConfig.deAiBatchConcurrency)
+}
+
+function pump(): void {
+  while (waiters.length > 0 && active < currentLimit()) {
+    const next = waiters.shift()
+    next?.()
+  }
+}
+
+export function notifyDeAiChapterConcurrencyChanged(): void {
+  pump()
+}
+
+export function acquireDeAiChapterSlot(): Promise<() => void> {
+  return new Promise((resolve) => {
+    const tryAcquire = () => {
+      if (active >= currentLimit()) {
+        waiters.push(tryAcquire)
+        return
+      }
+      active += 1
+      let released = false
+      resolve(() => {
+        if (released) return
+        released = true
+        active = Math.max(0, active - 1)
+        pump()
+      })
+    }
+    tryAcquire()
+  })
+}
+
+export function getDeAiChapterConcurrencySnapshot(): { active: number; queued: number; limit: number } {
+  return { active, queued: waiters.length, limit: currentLimit() }
+}
+
+export function resetDeAiChapterConcurrencyForTests(): void {
+  active = 0
+  waiters.length = 0
+}

+ 7 - 12
src/lib/novel/deep-chapter-generation.ts

@@ -219,7 +219,6 @@ export function shouldUseDeepChapterGeneration(
 
 interface ChapterWorkflowProfile {
   mode: AiWorkflowMode;
-  runPreviousChaptersAnalysis: boolean;
   runExecutionContractBuild: boolean;
   runAiReview: boolean;
   runFinalPolish: boolean;
@@ -236,7 +235,6 @@ function resolveChapterWorkflowProfile(
   if (resolvedMode === "fast") {
     return {
       mode: "fast",
-      runPreviousChaptersAnalysis: false,
       runExecutionContractBuild: false,
       runAiReview: false,
       runFinalPolish: false,
@@ -249,7 +247,6 @@ function resolveChapterWorkflowProfile(
   if (resolvedMode === "standard") {
     return {
       mode: "standard",
-      runPreviousChaptersAnalysis: false,
       runExecutionContractBuild: false,
       runAiReview: false,
       runFinalPolish: false,
@@ -261,7 +258,6 @@ function resolveChapterWorkflowProfile(
   }
   return {
     mode: "strict",
-    runPreviousChaptersAnalysis: true,
     runExecutionContractBuild: true,
     runAiReview: true,
     runFinalPolish: true,
@@ -539,10 +535,11 @@ export async function runDeepChapterGeneration(
   // 将在阶段1构建contextPack后再加载skill(需要contextPack用于场景检测)
   let customDeAiSkill: string | null = null;
 
-  // 阶段0:前情分析(仅当章节号>1,且设置开启时;记忆库的近期摘要与上一章结尾仍会注入)
+  // 阶段0:前情分析。快速模式始终跳过;标准/严格模式跟随写作设置。
+  // 记忆库的近期摘要与上一章结尾仍会注入。
   let previousChaptersAnalysis = "";
   if (
-    workflowProfile.runPreviousChaptersAnalysis &&
+    workflowProfile.mode !== "fast" &&
     input.chapterNumber &&
     input.chapterNumber > 1 &&
     !resumeCheckpoint &&
@@ -1028,9 +1025,9 @@ export async function runDeepChapterGeneration(
     "校验与修正",
     "检查正文完整性、剧情连续性、人物一致性和阻断问题。",
   );
-  const shouldRunAiReview =
-    workflowProfile.runAiReview &&
-    (workflowProfile.mode === "strict" || novelConfig.deepChapterReview);
+  // 审稿由工作流模式决定:严格模式始终审稿,快速/标准模式跳过。
+  // 旧的「深度模式审稿」开关已被模式选择器取代。
+  const shouldRunAiReview = workflowProfile.runAiReview;
   if (!hasCheckpointReview(resumeCheckpoint)) {
     if (!shouldRunAiReview) {
       completeChapterWorkflowStep(
@@ -1285,9 +1282,7 @@ export async function runDeepChapterGeneration(
   });
 
   // 阶段5.5:返修后复审(只在发生了返修时执行,只审查角色一致性维度,降低token消耗,不再自动返修避免循环)
-  const shouldRunPostRevisionReview =
-    workflowProfile.runPostRevisionReview &&
-    (workflowProfile.mode === "strict" || novelConfig.deepChapterReview);
+  const shouldRunPostRevisionReview = workflowProfile.runPostRevisionReview;
   if (revised && shouldRunPostRevisionReview) {
     const postRevisionWorkflowStep: ChapterWorkflowStepSpec = {
       name: "chapter_post_revision_review",

+ 10 - 3
src/lib/novel/deep-chapter-prompts.spec.ts

@@ -1,5 +1,7 @@
 import { describe, expect, it } from "vitest"
 import {
+  CHAPTER_TARGET_CHARS_MAX,
+  CHAPTER_TARGET_CHARS_MIN,
   DEEP_CHAPTER_DRAFT_MAX_CHARS,
   DEEP_CHAPTER_MIN_CHARS,
   DEEP_CHAPTER_TARGET_CHARS,
@@ -28,9 +30,14 @@ describe("resolveChapterLengthSpec", () => {
     expect(spec).not.toHaveProperty("maxOutputTokens")
   })
 
-  it("clamps unreasonable targets", () => {
-    expect(resolveChapterLengthSpec(10).targetChars).toBe(2000)
-    expect(resolveChapterLengthSpec(999999).targetChars).toBe(6000)
+  it("clamps unreasonable targets to the writing-settings range", () => {
+    expect(resolveChapterLengthSpec(10).targetChars).toBe(CHAPTER_TARGET_CHARS_MIN)
+    expect(resolveChapterLengthSpec(999999).targetChars).toBe(CHAPTER_TARGET_CHARS_MAX)
+  })
+
+  it("honors a configured target outside the old 2000–6000 hard clamp", () => {
+    expect(resolveChapterLengthSpec(800).targetChars).toBe(800)
+    expect(resolveChapterLengthSpec(12000).targetChars).toBe(12000)
   })
 })
 

+ 4 - 1
src/lib/novel/deep-chapter-prompts.ts

@@ -5,6 +5,9 @@ import { CHINESE_NOVEL_DE_AI_RULES } from "./de-ai-rules"
 export const DEEP_CHAPTER_TARGET_CHARS = 3000
 export const DEEP_CHAPTER_MIN_CHARS = 2200
 export const DEEP_CHAPTER_DRAFT_MAX_CHARS = 3500
+/** 与写作设置 UI 的单章目标字数范围保持一致,避免设置值被运行时悄悄钳死。 */
+export const CHAPTER_TARGET_CHARS_MIN = 500
+export const CHAPTER_TARGET_CHARS_MAX = 20000
 
 /** 章节生成字数规格:由设置中的“单章目标字数”推算(issue #8)。
  *  输出 token 预算由 planChapterRequestBudget 按窗口比例规划,不再挂在此规格上。 */
@@ -22,7 +25,7 @@ export const DEFAULT_CHAPTER_LENGTH_SPEC: ChapterLengthSpec = {
 
 export function resolveChapterLengthSpec(targetChars?: number): ChapterLengthSpec {
   const target = Number.isFinite(targetChars) && (targetChars as number) > 0
-    ? Math.max(2000, Math.min(6000, Math.round(targetChars as number)))
+    ? Math.max(CHAPTER_TARGET_CHARS_MIN, Math.min(CHAPTER_TARGET_CHARS_MAX, Math.round(targetChars as number)))
     : DEEP_CHAPTER_TARGET_CHARS
   if (target === DEEP_CHAPTER_TARGET_CHARS) return DEFAULT_CHAPTER_LENGTH_SPEC
   return {

+ 116 - 0
src/lib/writing-settings-effectiveness.spec.ts

@@ -0,0 +1,116 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+import {
+  CHAPTER_TARGET_CHARS_MAX,
+  CHAPTER_TARGET_CHARS_MIN,
+} from "@/lib/novel/deep-chapter-prompts"
+
+const novelSection = readFileSync(
+  resolve(__dirname, "../components/settings/sections/novel-section.tsx"),
+  "utf8",
+)
+const previewPanel = readFileSync(
+  resolve(__dirname, "../components/layout/preview-panel.tsx"),
+  "utf8",
+)
+const contextEngine = readFileSync(
+  resolve(__dirname, "./novel/context-engine.ts"),
+  "utf8",
+)
+const contextDataSources = readFileSync(
+  resolve(__dirname, "./novel/context-data-sources.ts"),
+  "utf8",
+)
+const deepChapter = readFileSync(
+  resolve(__dirname, "./novel/deep-chapter-generation.ts"),
+  "utf8",
+)
+const chapterIngest = readFileSync(
+  resolve(__dirname, "./novel/chapter-ingest.ts"),
+  "utf8",
+)
+const communitySummary = readFileSync(
+  resolve(__dirname, "./novel/community-summary.ts"),
+  "utf8",
+)
+const revisionFeedback = readFileSync(
+  resolve(__dirname, "./novel/revision-feedback.ts"),
+  "utf8",
+)
+const modelResolver = readFileSync(
+  resolve(__dirname, "./novel/model-resolver.ts"),
+  "utf8",
+)
+const reviewAdapter = readFileSync(
+  resolve(__dirname, "./novel/review-adapter.ts"),
+  "utf8",
+)
+const chatPanel = readFileSync(
+  resolve(__dirname, "../components/chat/chat-panel.tsx"),
+  "utf8",
+)
+
+describe("writing settings still reach runtime", () => {
+  it("persists novelConfig, feedback window, and chat history immediately from the writing settings panel", () => {
+    expect(novelSection).toContain("saveNovelConfig")
+    expect(novelSection).toContain("saveRevisionFeedbackWindowConfig")
+    expect(novelSection).toContain("saveMaxHistoryMessages")
+    expect(novelSection).toContain("updateFeedbackWindow")
+    expect(novelSection).toContain("updateMaxHistoryMessages")
+  })
+
+  it("keeps chapter target chars UI and runtime clamp on the same 500–20000 range", () => {
+    expect(CHAPTER_TARGET_CHARS_MIN).toBe(500)
+    expect(CHAPTER_TARGET_CHARS_MAX).toBe(20000)
+    expect(novelSection).toContain("CHAPTER_TARGET_CHARS_MIN")
+    expect(novelSection).toContain("CHAPTER_TARGET_CHARS_MAX")
+    expect(novelSection).not.toContain("Math.max(2000, Math.min(6000")
+  })
+
+  it("wires de-AI batch concurrency into the live chapter queue", () => {
+    expect(novelSection).toContain("deAiBatchConcurrency")
+    expect(novelSection).toContain("notifyDeAiChapterConcurrencyChanged")
+    expect(previewPanel).toContain("acquireDeAiChapterSlot")
+  })
+
+  it("uses recentSummaryWindow and searchTopK in context loading, including graph search", () => {
+    expect(novelSection).toContain("recentSummaryWindow")
+    expect(novelSection).toContain("searchTopK")
+    expect(contextEngine).toContain("recentSummaryWindow: novelConfig.recentSummaryWindow")
+    expect(contextEngine).toContain("searchTopK: novelConfig.searchTopK")
+    expect(contextDataSources).toContain("context.config.searchTopK")
+    expect(contextEngine).toContain("const topK = Math.max(1, Math.floor(limit) || 10)")
+    expect(contextEngine).not.toContain("topK: 10")
+  })
+
+  it("honors prior-chapter analysis in standard and strict modes, not only the old deep-only gate", () => {
+    expect(novelSection).toContain("deepPreviousChaptersAnalysis")
+    expect(deepChapter).toContain("workflowProfile.mode !== \"fast\"")
+    expect(deepChapter).toContain("novelConfig.deepPreviousChaptersAnalysis")
+    expect(deepChapter).not.toContain("runPreviousChaptersAnalysis")
+  })
+
+  it("does not expose the dead deepChapterReview toggle that workflow modes already replaced", () => {
+    expect(novelSection).not.toContain("deepChapterReview")
+    expect(deepChapter).toContain("const shouldRunAiReview = workflowProfile.runAiReview")
+    expect(deepChapter).not.toContain("novelConfig.deepChapterReview")
+  })
+
+  it("keeps community summary, ingest-on-save, review effort, and task models on live paths", () => {
+    expect(communitySummary).toContain("novelConfig.communitySummaryEnabled")
+    expect(communitySummary).toContain("novelConfig.communitySummaryInterval")
+    expect(chapterIngest).toContain("novelConfig.communitySummaryAsync")
+    expect(previewPanel).toContain("novelConfig.autoIngestOnSave")
+    expect(reviewAdapter).toContain("reviewReasoningEffort")
+    expect(modelResolver).toContain("review: novelConfig.reviewModel")
+    expect(modelResolver).toContain("summary: novelConfig.summaryModel")
+    expect(modelResolver).toContain("extract: novelConfig.extractModel")
+    expect(modelResolver).toContain("deAi: novelConfig.deAiModel")
+    expect(chatPanel).toContain("maxHistoryMessages")
+    expect(revisionFeedback).toContain("config.currentChapterIncludeShouldImprove")
+    expect(revisionFeedback).toContain("config.previousChapterCarryEnabled")
+    expect(revisionFeedback).toContain("config.lookbackChapterCount")
+    expect(revisionFeedback).toContain("config.lookbackIncludeMustFixOnly")
+  })
+})

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

@@ -315,9 +315,9 @@ export interface NovelConfig {
   chapterTargetChars: number
   autoIngestOnSave: boolean
   autoExtractOnImport: boolean
-  /** 深度生成阶段0:读取并 LLM 分析前几章完整正文。关闭可省一次调用,记忆库的近期摘要与上一章结尾仍会注入(默认关)。 */
+  /** 前情分析:读取并 LLM 分析前几章完整正文。快速模式始终跳过;标准/严格模式跟随此开关。关闭时记忆库的近期摘要与上一章结尾仍会注入(默认关)。 */
   deepPreviousChaptersAnalysis: boolean
-  /** 深度生成阶段4-5:AI 审稿 + 自动返修。关闭则初稿直接进入简单审查与去AI味,省审稿与返修调用(默认开)。 */
+  /** @deprecated 已被聊天栏工作流模式取代:严格模式始终审稿,快速/标准模式跳过。保留字段仅兼容旧配置。 */
   deepChapterReview: boolean
   /** 审稿(含六维审查)使用的 reasoning 档位。下调可省审稿推理 Token,但连贯性把关会变弱(默认 high)。 */
   reviewReasoningEffort: "low" | "medium" | "high"
@@ -329,7 +329,7 @@ export interface NovelConfig {
   extractModel: string
   /** 去 AI 味:章节预览去 AI 味、深度生成阶段6。空字符串表示跟随默认模型。 */
   deAiModel: string
-  /** 批量去 AI 味同时运行的作品 Agent 数,范围 1–5。 */
+  /** 批量去 AI 味同时运行的章节数,范围 1–5。 */
   deAiBatchConcurrency: number
   /** 社区摘要自动提取:开启后每 N 章用 LLM 为图谱社区生成叙事摘要,用于回答全局性问题(默认开)。 */
   communitySummaryEnabled: boolean