Przeglądaj źródła

feat(chat): 写作与大纲新增思考深度滑块

在写作和大纲聊天面板的模型选择器旁增加思考深度滑块,档位为
默认 / 关闭 / 低 / 中 / 高 / 最大,写作与大纲各存一份全局值。

滑块只作用于所选聊天模型真正出稿的那次调用:写作侧落在
chapterWritingLlmConfig,穿透正文的初稿、扩写、返修和阶段6局部修改;
主 Agent 的编排模型与正文流程内部重解析的 workflowConfig 均不受影响。
大纲侧复用已有的 outlineBudgetStage 判断,只有 generation 轮次套档位,
意图分析与计划要素盘点保持原样,续传和重新生成两条分支同样覆盖。

档位写入 LlmConfig.reasoning 而非 RequestOverrides:streamChat 与
大纲/章节的 budget planner 都从 config.reasoning 推导输出 token 地板,
走 override 会导致调高档位后正文被思考吃光。

「默认」档原样返回 config,沿用设置里该 provider 的推理配置,
不会抹掉用户配的 custom budgetTokens。

新增 modelSupportsReasoningControl 做保守 allow-list 判定,复用
buildBody 已有的启发式避免逻辑漂移;模型不支持(Claude Code CLI、
Cursor CLI、gpt-4o、Claude 3.5、Gemini 1.5 等)或写作快速模式下
不渲染滑块。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 3 tygodni temu
rodzic
commit
c14ee2c4d9

+ 3 - 1
src/App.tsx

@@ -6,7 +6,7 @@ import { isTauri, pickDirectory } from "@/lib/platform"
 import { useChatStore } from "@/stores/chat-store"
 import { useOutlineChatStore } from "@/stores/outline-chat-store"
 import { openProject, fileExists, listDirectory, readFile } from "@/commands/fs"
-import { getLastProject, saveLastProject, loadLlmConfig, loadAiChatModel, loadAiWorkflowMode, loadDefaultLlmModel, loadLanguage, loadEmbeddingConfig, loadProviderConfigs, loadActivePresetId, loadProxyConfig, loadNovelMode, loadNovelConfig, loadRevisionFeedbackWindowConfig, loadTheme, loadMaxHistoryMessages, loadUiFontFamily, loadVisualStyle, saveLlmConfig, loadLastReadChapter, loadMcpConfig, loadSearchApiConfig, loadOutlineWorkflowMode } from "@/lib/project-store"
+import { getLastProject, saveLastProject, loadLlmConfig, loadAiChatModel, loadAiWorkflowMode, loadDefaultLlmModel, loadLanguage, loadEmbeddingConfig, loadProviderConfigs, loadActivePresetId, loadProxyConfig, loadNovelMode, loadNovelConfig, loadRevisionFeedbackWindowConfig, loadTheme, loadMaxHistoryMessages, loadUiFontFamily, loadVisualStyle, saveLlmConfig, loadLastReadChapter, loadMcpConfig, loadSearchApiConfig, loadOutlineWorkflowMode, loadAiChatReasoningDepth, loadAiOutlineReasoningDepth } from "@/lib/project-store"
 import { loadReviewItems, loadChatHistory, saveChatHistory, saveReviewItems } from "@/lib/persist"
 import { initializeAiOutlineModelFromStorage } from "@/lib/ai-outline-model-initialization"
 import { setupAutoSave, teardownAutoSave } from "@/lib/auto-save"
@@ -241,6 +241,8 @@ function App() {
         if (savedOutlineWorkflowMode) {
           useWikiStore.getState().setOutlineWorkflowMode(savedOutlineWorkflowMode)
         }
+        useWikiStore.getState().setAiChatReasoningDepth(await loadAiChatReasoningDepth())
+        useWikiStore.getState().setAiOutlineReasoningDepth(await loadAiOutlineReasoningDepth())
         const savedDefaultLlmModel = await loadDefaultLlmModel()
         if (savedDefaultLlmModel) {
           useWikiStore.getState().setDefaultLlmModel(savedDefaultLlmModel)

+ 37 - 8
src/components/chat/chat-panel.tsx

@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"
 import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
 import { ChatMessage, StreamingMessage } from "./chat-message"
 import { ChatModelSelector } from "./chat-model-selector"
+import { ReasoningDepthControl } from "./reasoning-depth-control"
 import { useSourceFiles } from "./chat-shared"
 import { useStreamingText } from "@/hooks/use-streaming-text"
 import {
@@ -87,7 +88,8 @@ import {
   canCreateNewConversation,
   EMPTY_CONVERSATION_CREATE_REASON,
 } from "@/lib/conversation-create-guard"
-import { saveAiChatModel, saveAiWorkflowMode } from "@/lib/project-store"
+import { saveAiChatModel, saveAiChatReasoningDepth, saveAiWorkflowMode } from "@/lib/project-store"
+import { resolveModelConfig } from "@/lib/novel/model-resolver"
 import {
   buildGoldenThreeChapterDirective,
   detectGoldenThreeChapterRequest,
@@ -939,6 +941,9 @@ export function ChatPanel() {
   const bindingVersion = useWikiStore((s) => s.bindingVersion)
   const aiChatModel = useWikiStore((s) => s.aiChatModel)
   const setAiChatModel = useWikiStore((s) => s.setAiChatModel)
+  const providerConfigs = useWikiStore((s) => s.providerConfigs)
+  const aiChatReasoningDepth = useWikiStore((s) => s.aiChatReasoningDepth)
+  const setAiChatReasoningDepth = useWikiStore((s) => s.setAiChatReasoningDepth)
   const chatEditModeEnabled = useWikiStore((s) => s.chatEditModeEnabled)
   const selectedFile = useWikiStore((s) => s.selectedFile)
 
@@ -961,6 +966,20 @@ export function ChatPanel() {
   const workflowModeDropdownRef = useRef<HTMLDivElement | null>(null)
   const planExecuteEnabled = useWikiStore((s) => s.planExecuteEnabled)
   const setPlanExecuteEnabled = useWikiStore((s) => s.setPlanExecuteEnabled)
+
+  /**
+   * The config the thinking-depth slider steers, or null to hide the slider.
+   *
+   * Depth is stamped onto `chapterWritingLlmConfig`, which only reaches a
+   * request through `run_chapter_workflow`. Fast mode has the main agent draft
+   * inline instead of calling that tool, so the knob would be inert there.
+   */
+  const reasoningDepthTargetConfig = useMemo(
+    () => aiWorkflowMode === "fast"
+      ? null
+      : resolveModelConfig(aiChatModel, llmConfig, providerConfigs),
+    [aiWorkflowMode, aiChatModel, llmConfig, providerConfigs],
+  )
   const [isSavingChapter, setIsSavingChapter] = useState(false)
   // 故事框架绑定状态
   const [activeBinding, setActiveBinding] = useState<{ binding: FrameworkBinding; framework: StoryFramework } | null>(null)
@@ -2861,13 +2880,23 @@ export function ChatPanel() {
                 />
               }
               rightControls={
-                <ChatModelSelector
-                  value={aiChatModel}
-                  onChange={(model) => {
-                    setAiChatModel(model)
-                    void saveAiChatModel(model)
-                  }}
-                />
+                <>
+                  <ReasoningDepthControl
+                    value={aiChatReasoningDepth}
+                    onChange={(depth) => {
+                      setAiChatReasoningDepth(depth)
+                      void saveAiChatReasoningDepth(depth)
+                    }}
+                    modelConfig={reasoningDepthTargetConfig}
+                  />
+                  <ChatModelSelector
+                    value={aiChatModel}
+                    onChange={(model) => {
+                      setAiChatModel(model)
+                      void saveAiChatModel(model)
+                    }}
+                  />
+                </>
               }
               insertTokensRef={insertReferenceTokensRef}
               onChange={updateReferenceDraft}

+ 175 - 0
src/components/chat/reasoning-depth-control.spec.tsx

@@ -0,0 +1,175 @@
+// @vitest-environment jsdom
+import { act } from "react"
+import { createElement } from "react"
+import { createRoot } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+import { ReasoningDepthControl } from "./reasoning-depth-control"
+
+vi.mock("react-i18next", async (importOriginal) => ({
+  ...(await importOriginal<typeof import("react-i18next")>()),
+  useTranslation: () => ({ t: (key: string) => key }),
+}))
+
+const reasoningModel: LlmConfig = {
+  provider: "openai",
+  apiKey: "key",
+  model: "gpt-5",
+  ollamaUrl: "",
+  customEndpoint: "",
+  maxContextSize: 204800,
+}
+
+const plainModel: LlmConfig = { ...reasoningModel, model: "gpt-4o" }
+
+let container: HTMLDivElement
+let root: ReturnType<typeof createRoot>
+
+function render(node: Parameters<typeof root.render>[0]) {
+  act(() => {
+    root.render(node)
+  })
+}
+
+/**
+ * React tracks the last value it wrote to an input, so assigning `.value`
+ * directly makes the change invisible to its synthetic event system. Going
+ * through the prototype setter keeps the tracker in sync.
+ */
+function setRangeValue(input: HTMLInputElement, value: string) {
+  const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")
+  descriptor?.set?.call(input, value)
+}
+
+async function flushAnimationFrames() {
+  for (let i = 0; i < 3; i++) {
+    await act(async () => {
+      await new Promise((resolve) => requestAnimationFrame(() => resolve(null)))
+    })
+  }
+}
+
+describe("ReasoningDepthControl", () => {
+  beforeEach(() => {
+    ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+    container = document.createElement("div")
+    document.body.appendChild(container)
+    root = createRoot(container)
+  })
+
+  afterEach(() => {
+    act(() => {
+      root.unmount()
+    })
+    container.remove()
+  })
+
+  it("renders nothing when the model's thinking cannot be steered", () => {
+    render(createElement(ReasoningDepthControl, {
+      value: "high",
+      onChange: vi.fn(),
+      modelConfig: plainModel,
+    }))
+
+    expect(container.querySelector("button")).toBeNull()
+  })
+
+  it("renders nothing when there is no target config, as in writing fast mode", () => {
+    render(createElement(ReasoningDepthControl, {
+      value: "high",
+      onChange: vi.fn(),
+      modelConfig: null,
+    }))
+
+    expect(container.querySelector("button")).toBeNull()
+  })
+
+  it("shows the current depth on the trigger for a reasoning model", () => {
+    render(createElement(ReasoningDepthControl, {
+      value: "medium",
+      onChange: vi.fn(),
+      modelConfig: reasoningModel,
+    }))
+
+    const trigger = container.querySelector("button")
+    expect(trigger).not.toBeNull()
+    expect(trigger?.textContent).toContain("chat.reasoningDepth.medium")
+  })
+
+  it("reports the stop the slider was dragged to", async () => {
+    const onChange = vi.fn()
+    render(createElement(ReasoningDepthControl, {
+      value: "auto",
+      onChange,
+      modelConfig: reasoningModel,
+    }))
+
+    act(() => {
+      container.querySelector("button")?.click()
+    })
+    // The popover measures the trigger across two animation frames before it
+    // has a position to render at.
+    await flushAnimationFrames()
+
+    const slider = document.querySelector<HTMLInputElement>('input[type="range"]')
+    expect(slider).not.toBeNull()
+    expect(slider?.value).toBe("0")
+
+    act(() => {
+      setRangeValue(slider!, "4")
+      slider!.dispatchEvent(new Event("input", { bubbles: true }))
+    })
+
+    expect(onChange).toHaveBeenCalledWith("high")
+  })
+
+  it("jumps to a stop when its tick label is clicked", async () => {
+    const onChange = vi.fn()
+    render(createElement(ReasoningDepthControl, {
+      value: "auto",
+      onChange,
+      modelConfig: reasoningModel,
+    }))
+
+    act(() => {
+      container.querySelector("button")?.click()
+    })
+    await flushAnimationFrames()
+
+    const slider = document.querySelector<HTMLInputElement>('input[type="range"]')
+    const ticks = Array.from(
+      slider!.nextElementSibling!.querySelectorAll<HTMLButtonElement>("button"),
+    )
+    expect(ticks.map((tick) => tick.textContent)).toEqual([
+      "chat.reasoningDepth.auto",
+      "chat.reasoningDepth.off",
+      "chat.reasoningDepth.low",
+      "chat.reasoningDepth.medium",
+      "chat.reasoningDepth.high",
+      "chat.reasoningDepth.max",
+    ])
+
+    act(() => {
+      ticks[1].click()
+    })
+
+    expect(onChange).toHaveBeenCalledWith("off")
+  })
+
+  it("keeps the trigger inert while disabled", async () => {
+    const onChange = vi.fn()
+    render(createElement(ReasoningDepthControl, {
+      value: "auto",
+      onChange,
+      modelConfig: reasoningModel,
+      disabled: true,
+    }))
+
+    act(() => {
+      container.querySelector("button")?.click()
+    })
+    await flushAnimationFrames()
+
+    expect(document.querySelector('input[type="range"]')).toBeNull()
+  })
+})

+ 168 - 0
src/components/chat/reasoning-depth-control.tsx

@@ -0,0 +1,168 @@
+import { useCallback, useEffect, useRef, useState } from "react"
+import { useTranslation } from "react-i18next"
+import { Brain } from "lucide-react"
+import { createPortal } from "react-dom"
+import { Button } from "@/components/ui/button"
+import { getChatModelDropdownStyle } from "@/components/chat/chat-model-selector"
+import { modelSupportsReasoningControl } from "@/lib/llm-providers"
+import {
+  REASONING_DEPTH_STEPS,
+  reasoningDepthFromIndex,
+  reasoningDepthToIndex,
+  type ReasoningDepth,
+} from "@/lib/reasoning-depth"
+import type { LlmConfig } from "@/stores/wiki-store"
+
+interface ReasoningDepthControlProps {
+  value: ReasoningDepth
+  onChange: (depth: ReasoningDepth) => void
+  /**
+   * The config the depth will be stamped onto. `null`, or a model whose
+   * thinking cannot be steered, hides the control entirely — a visible knob
+   * that the wire would drop is worse than no knob.
+   */
+  modelConfig: LlmConfig | null
+  disabled?: boolean
+}
+
+const SLIDER_TRACK_FILLED = "#4f46e5"
+const SLIDER_TRACK_EMPTY = "#e5e7eb"
+
+export function ReasoningDepthControl({
+  value,
+  onChange,
+  modelConfig,
+  disabled,
+}: ReasoningDepthControlProps) {
+  const { t } = useTranslation()
+  const [open, setOpen] = useState(false)
+  const triggerRef = useRef<HTMLButtonElement>(null)
+  const [dropdownStyle, setDropdownStyle] = useState<
+    ReturnType<typeof getChatModelDropdownStyle> | null
+  >(null)
+
+  const updatePosition = useCallback(() => {
+    const trigger = triggerRef.current
+    if (!trigger) return
+    setDropdownStyle(
+      getChatModelDropdownStyle(trigger.getBoundingClientRect(), {
+        width: window.innerWidth,
+        height: window.innerHeight,
+      }),
+    )
+  }, [])
+
+  useEffect(() => {
+    if (!open) {
+      setDropdownStyle(null)
+      return
+    }
+    let frame2 = 0
+    const frame1 = requestAnimationFrame(() => {
+      frame2 = requestAnimationFrame(() => {
+        updatePosition()
+      })
+    })
+    const handleReposition = () => updatePosition()
+    window.addEventListener("resize", handleReposition)
+    window.addEventListener("scroll", handleReposition, true)
+    return () => {
+      cancelAnimationFrame(frame1)
+      cancelAnimationFrame(frame2)
+      window.removeEventListener("resize", handleReposition)
+      window.removeEventListener("scroll", handleReposition, true)
+    }
+  }, [open, updatePosition])
+
+  useEffect(() => {
+    if (!open) return
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === "Escape") setOpen(false)
+    }
+    window.addEventListener("keydown", handleKeyDown)
+    return () => window.removeEventListener("keydown", handleKeyDown)
+  }, [open])
+
+  if (!modelConfig || !modelSupportsReasoningControl(modelConfig)) return null
+
+  const activeIndex = reasoningDepthToIndex(value)
+  const lastIndex = REASONING_DEPTH_STEPS.length - 1
+  const filledPercent = (activeIndex / lastIndex) * 100
+
+  return (
+    <div className="relative">
+      <Button
+        ref={triggerRef}
+        type="button"
+        variant="outline"
+        onClick={() => !disabled && setOpen(!open)}
+        disabled={disabled}
+        title={t("chat.reasoningDepth.label")}
+        aria-label={t("chat.reasoningDepth.label")}
+        className="h-8 shrink-0 gap-1.5 px-2 text-xs"
+      >
+        <Brain className="h-3.5 w-3.5 shrink-0 opacity-70" />
+        <span className="truncate">{t(`chat.reasoningDepth.${value}`)}</span>
+      </Button>
+
+      {open && dropdownStyle && createPortal(
+        <>
+          <div
+            className="fixed inset-0"
+            style={{ zIndex: 9998 }}
+            onClick={() => setOpen(false)}
+          />
+          <div
+            className="fixed rounded-md border bg-popover p-3 shadow-lg"
+            style={{
+              right: dropdownStyle.right,
+              top: dropdownStyle.top,
+              bottom: dropdownStyle.bottom,
+              width: dropdownStyle.width,
+              zIndex: 9999,
+            }}
+          >
+            <div className="mb-2 text-xs font-medium">
+              {t("chat.reasoningDepth.label")}
+            </div>
+            <input
+              type="range"
+              min={0}
+              max={lastIndex}
+              step={1}
+              value={activeIndex}
+              aria-label={t("chat.reasoningDepth.label")}
+              onChange={(e) => onChange(reasoningDepthFromIndex(Number(e.target.value)))}
+              className="h-2 w-full cursor-pointer appearance-none rounded-lg accent-primary"
+              style={{
+                background: `linear-gradient(to right, ${SLIDER_TRACK_FILLED} ${filledPercent}%, ${SLIDER_TRACK_EMPTY} ${filledPercent}%)`,
+              }}
+            />
+            <div className="mt-1 flex justify-between">
+              {REASONING_DEPTH_STEPS.map((step, index) => (
+                <button
+                  key={step}
+                  type="button"
+                  onClick={() => onChange(step)}
+                  // The first stop means "defer to the provider setting", not
+                  // "less than off", so it is italicised out of the ramp.
+                  className={`px-0.5 text-[9px] ${index === 0 ? "italic" : ""} ${
+                    index === activeIndex
+                      ? "font-bold text-primary"
+                      : "text-muted-foreground/50"
+                  }`}
+                >
+                  {t(`chat.reasoningDepth.${step}`)}
+                </button>
+              ))}
+            </div>
+            <p className="mt-2 text-[10px] text-muted-foreground">
+              {t("chat.reasoningDepth.hint")}
+            </p>
+          </div>
+        </>,
+        document.body,
+      )}
+    </div>
+  )
+}

+ 59 - 18
src/components/sources/outline-chat-panel.tsx

@@ -29,7 +29,11 @@ import {
 } from "@/lib/agent/workflow-mode";
 import { OUTPUT_TRUNCATED_ERROR_MARKER } from "@/lib/llm-client";
 import { Button } from "@/components/ui/button";
-import { saveAiOutlineModel, saveOutlineWorkflowMode } from "@/lib/project-store";
+import {
+  saveAiOutlineModel,
+  saveAiOutlineReasoningDepth,
+  saveOutlineWorkflowMode,
+} from "@/lib/project-store";
 import {
   useOutlineChatStore,
   type OutlineMultiAgentRunState,
@@ -146,6 +150,8 @@ import {
   thinkingMinMaxTokens,
 } from "@/lib/llm-providers";
 import { ChatModelSelector } from "@/components/chat/chat-model-selector";
+import { ReasoningDepthControl } from "@/components/chat/reasoning-depth-control";
+import { applyReasoningDepth } from "@/lib/reasoning-depth";
 import { ContextUsageRing } from "@/components/chat/context-usage-ring";
 import { highlightCode } from "@/lib/streaming-code-highlight";
 import { separateThinking } from "@/lib/separate-thinking";
@@ -1433,6 +1439,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   const aiOutlineModel = useWikiStore((s) => s.aiOutlineModel);
   const defaultLlmModel = useWikiStore((s) => s.defaultLlmModel);
   const setAiOutlineModel = useWikiStore((s) => s.setAiOutlineModel);
+  const aiOutlineReasoningDepth = useWikiStore((s) => s.aiOutlineReasoningDepth);
+  const setAiOutlineReasoningDepth = useWikiStore((s) => s.setAiOutlineReasoningDepth);
   const outlineWorkflowMode = resolveOutlineWorkflowMode(
     useWikiStore((s) => s.outlineWorkflowMode),
   );
@@ -1538,6 +1546,15 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
     return getEffectiveMaxContextSize(config);
   }, [effectiveOutlineModelId, llmConfig, novelConfig, providerConfigs]);
 
+  /** The config the thinking-depth slider steers, or null to hide the slider. */
+  const reasoningDepthTargetConfig = useMemo(() => {
+    let config = resolveNovelModel(llmConfig, novelConfig, "writing");
+    if (effectiveOutlineModelId) {
+      config = resolveModelConfig(effectiveOutlineModelId, config, providerConfigs);
+    }
+    return config;
+  }, [effectiveOutlineModelId, llmConfig, novelConfig, providerConfigs]);
+
   const [inputValue, setInputValue] = useState("");
   const deferredInputValue = useDeferredValue(inputValue);
   const liveContextUsage = useMemo(() => {
@@ -2133,6 +2150,18 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           providerConfigs,
         );
       }
+      // Intent analysis and plan element-check turns are the orchestration
+      // half of the outline loop; only generation turns actually emit outline
+      // content, so only they take the footer's thinking depth. Stamping it
+      // here rather than at the request keeps the budget planner below in
+      // sync, since it derives its output floor from `config.reasoning`.
+      const outlineBudgetStage: OutlineBudgetStage = options.intentPhase === "intent_analysis"
+        || options.planPhase !== undefined
+        ? "analysis"
+        : "generation";
+      if (outlineBudgetStage === "generation") {
+        effectiveLlmConfig = applyReasoningDepth(effectiveLlmConfig, aiOutlineReasoningDepth);
+      }
       const effectiveModelId = effectiveOutlineModelId || effectiveLlmConfig.model || "";
       if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
         addMessage(convId, {
@@ -2295,10 +2324,6 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       // 避免整段结果被静默丢弃。
       let bestGeneratedText = "";
       let deliverableTruncated = false;
-      const outlineBudgetStage: OutlineBudgetStage = options.intentPhase === "intent_analysis"
-        || options.planPhase !== undefined
-        ? "analysis"
-        : "generation";
       const outlineRequestBudget = planOutlineRequestBudget({
         maxContextSize: effectiveLlmConfig.maxContextSize,
         stage: outlineBudgetStage,
@@ -3397,6 +3422,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       novelConfig,
       providerConfigs,
       effectiveOutlineModelId,
+      aiOutlineReasoningDepth,
       activeConv,
       activeConversationId,
       createConversation,
@@ -3775,6 +3801,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       if (effectiveOutlineModelId) {
         effectiveLlmConfig = resolveModelConfig(effectiveOutlineModelId, effectiveLlmConfig, providerConfigs);
       }
+      // Resuming picks up a generation run, so it takes the footer depth.
+      effectiveLlmConfig = applyReasoningDepth(effectiveLlmConfig, aiOutlineReasoningDepth);
       const effectiveModelId = effectiveOutlineModelId || effectiveLlmConfig.model || "";
       if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
         toast.error("请先在设置中配置并选择一个可用的 AI 模型。");
@@ -4115,7 +4143,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         clearStreamingContent(capturedConvId);
       }
     },
-    [project, activeConversationId, llmConfig, novelConfig, effectiveOutlineModelId, providerConfigs, outlineWritingSkills, startConversationRun, stopConversationRun, clearStreamingContent, setConversationContextSummary],
+    [project, activeConversationId, llmConfig, novelConfig, effectiveOutlineModelId, aiOutlineReasoningDepth, providerConfigs, outlineWritingSkills, startConversationRun, stopConversationRun, clearStreamingContent, setConversationContextSummary],
   );
 
   const handleFocusInput = useCallback(() => {
@@ -4199,6 +4227,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           providerConfigs,
         );
       }
+      // Regenerating replays a generation turn, so it takes the footer depth.
+      effectiveLlmConfig = applyReasoningDepth(effectiveLlmConfig, aiOutlineReasoningDepth);
       const effectiveModelId = effectiveOutlineModelId || effectiveLlmConfig.model || "";
       if (!hasUsableLlm(effectiveLlmConfig, providerConfigs)) {
         addMessage(activeConversationId, {
@@ -4693,6 +4723,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       novelConfig,
       providerConfigs,
       effectiveOutlineModelId,
+      aiOutlineReasoningDepth,
       activeConv,
       activeConversationId,
       addMessage,
@@ -5288,18 +5319,28 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           }
           rightControls={
             hasAvailableModels ? (
-              <ChatModelSelector
-                value={localModelId}
-                onChange={(value) => {
-                  setLocalModelId(value);
-                  setAiOutlineModel(value);
-                  if (activeConversationId) {
-                    setConversationModel(activeConversationId, value);
-                  }
-                  persistOutlineModel(value);
-                }}
-                disabled={false}
-              />
+              <>
+                <ReasoningDepthControl
+                  value={aiOutlineReasoningDepth}
+                  onChange={(depth) => {
+                    setAiOutlineReasoningDepth(depth);
+                    void saveAiOutlineReasoningDepth(depth);
+                  }}
+                  modelConfig={reasoningDepthTargetConfig}
+                />
+                <ChatModelSelector
+                  value={localModelId}
+                  onChange={(value) => {
+                    setLocalModelId(value);
+                    setAiOutlineModel(value);
+                    if (activeConversationId) {
+                      setConversationModel(activeConversationId, value);
+                    }
+                    persistOutlineModel(value);
+                  }}
+                  disabled={false}
+                />
+              </>
             ) : (
               <p
                 className="max-w-48 truncate text-xs text-destructive"

+ 120 - 0
src/hooks/use-agent-config.spec.ts

@@ -12,6 +12,7 @@ import type { UserSkillConfig } from "@/lib/novel/user-skill-store"
 import type { McpConfig } from "@/lib/mcp/config"
 import type { UseAgentConfigResult } from "@/hooks/use-agent-config"
 import type { AiWorkflowMode } from "@/lib/agent/workflow-mode"
+import type { ReasoningDepth } from "@/lib/reasoning-depth"
 
 const baseLlmConfig: LlmConfig = {
   provider: "openai",
@@ -34,6 +35,7 @@ interface StoreStates {
     searchApiConfig: SearchApiConfig
     mcpConfig: McpConfig
     aiWorkflowMode: AiWorkflowMode
+    aiChatReasoningDepth: ReasoningDepth
   }>
   chat?: Partial<{
     conversations: Conversation[]
@@ -79,6 +81,7 @@ async function renderHook(systemPrompt: string, overrides: StoreStates & {
     mcpConfig: { servers: [] } as McpConfig,
     novelMode: true,
     aiWorkflowMode: "standard" as AiWorkflowMode,
+    aiChatReasoningDepth: "auto" as ReasoningDepth,
     ...overrides.wiki,
   }
 
@@ -361,6 +364,123 @@ describe("useAgentConfig", () => {
     await cleanup()
   }, 15000)
 
+  it("applies the thinking depth to chapter body generation without touching the orchestration model", async () => {
+    const providerConfigs: ProviderConfigs = {
+      custom: {
+        enabled: true,
+        apiKey: "test-key",
+        reasoning: { mode: "auto" },
+        savedModels: [
+          { id: "writer", name: "Writer", model: "writer-model", createdAt: 1 },
+          { id: "workflow", name: "Workflow", model: "workflow-model", createdAt: 2 },
+        ],
+      },
+    }
+    const { result, cleanup } = await renderHook("test prompt", {
+      wiki: {
+        aiChatModel: "custom/writer-model",
+        defaultLlmModel: "custom/workflow-model",
+        novelConfig: { ...DEFAULT_NOVEL_CONFIG, defaultLlmModel: "custom/workflow-model" },
+        providerConfigs,
+        project: { path: "/tmp/project" } as WikiProject,
+        aiChatReasoningDepth: "high",
+      },
+      skillConfig: {
+        version: 1,
+        defaultSkillId: "built-in:comprehensive",
+        disabledSkillIds: [],
+        projectSkills: [],
+        builtInSkillOverrides: [],
+        lastChapterDeAiSkillId: null,
+      },
+    })
+
+    // The orchestrating agent runs on the default model and must keep whatever
+    // reasoning its own provider config specifies.
+    expect(result.config?.llmConfig.model).toBe("workflow-model")
+    expect(result.config?.llmConfig.reasoning).toEqual({ mode: "auto" })
+
+    const workflowTool = result.registry.get("run_chapter_workflow")
+    const deepChapterModule = await import("@/lib/novel/deep-chapter-generation")
+    const runDeepChapterGeneration = vi.mocked(deepChapterModule.runDeepChapterGeneration)
+    runDeepChapterGeneration.mockResolvedValueOnce({
+      finalContent: "正文",
+      taskBrief: "任务书",
+      draftContent: "初稿",
+      reviewResults: [],
+      revised: false,
+    })
+    await workflowTool?.execute({ userRequest: "写第三章" })
+
+    expect(runDeepChapterGeneration).toHaveBeenCalledWith(
+      expect.objectContaining({
+        llmConfig: expect.objectContaining({
+          model: "writer-model",
+          reasoning: { mode: "high" },
+        }),
+      }),
+      expect.any(Object),
+      undefined,
+      undefined,
+    )
+
+    await cleanup()
+  }, 15000)
+
+  it("leaves the chapter writing config untouched at the default depth", async () => {
+    const providerConfigs: ProviderConfigs = {
+      custom: {
+        enabled: true,
+        apiKey: "test-key",
+        reasoning: { mode: "custom", budgetTokens: 20000 },
+        savedModels: [
+          { id: "writer", name: "Writer", model: "writer-model", createdAt: 1 },
+        ],
+      },
+    }
+    const { result, cleanup } = await renderHook("test prompt", {
+      wiki: {
+        aiChatModel: "custom/writer-model",
+        providerConfigs,
+        project: { path: "/tmp/project" } as WikiProject,
+        aiChatReasoningDepth: "auto",
+      },
+      skillConfig: {
+        version: 1,
+        defaultSkillId: "built-in:comprehensive",
+        disabledSkillIds: [],
+        projectSkills: [],
+        builtInSkillOverrides: [],
+        lastChapterDeAiSkillId: null,
+      },
+    })
+
+    const workflowTool = result.registry.get("run_chapter_workflow")
+    const deepChapterModule = await import("@/lib/novel/deep-chapter-generation")
+    const runDeepChapterGeneration = vi.mocked(deepChapterModule.runDeepChapterGeneration)
+    runDeepChapterGeneration.mockResolvedValueOnce({
+      finalContent: "正文",
+      taskBrief: "任务书",
+      draftContent: "初稿",
+      reviewResults: [],
+      revised: false,
+    })
+    await workflowTool?.execute({ userRequest: "写第三章" })
+
+    expect(runDeepChapterGeneration).toHaveBeenCalledWith(
+      expect.objectContaining({
+        llmConfig: expect.objectContaining({
+          reasoning: { mode: "custom", budgetTokens: 20000 },
+        }),
+      }),
+      expect.any(Object),
+      undefined,
+      undefined,
+    )
+
+    await cleanup()
+  }, 15000)
+
   it("uses the selected chat model for fast-mode conversation output instead of the default agent model", async () => {
     const providerConfigs: ProviderConfigs = {
       custom: {

+ 13 - 1
src/hooks/use-agent-config.ts

@@ -6,6 +6,7 @@ import { loadDeAiSkillConfig, type DeAiSkillConfig } from "@/lib/novel/de-ai-ski
 import { loadAllLinkedSkillsContent, loadUserSkillConfig, resolveEnabledWritingSkills } from "@/lib/novel/user-skill-store"
 import type { UserSkill } from "@/lib/novel/skill-library"
 import { resolveAgentSessionModel, resolveModelConfig } from "@/lib/novel/model-resolver"
+import { applyReasoningDepth } from "@/lib/reasoning-depth"
 import { runDeepChapterGeneration } from "@/lib/novel/deep-chapter-generation"
 import { normalizePath } from "@/lib/path-utils"
 import { ToolRegistry } from "@/lib/agent/registry"
@@ -41,6 +42,7 @@ export function useAgentConfig(
   const searchApiConfig = useWikiStore((s) => s.searchApiConfig)
   const mcpConfig = useWikiStore((s) => s.mcpConfig)
   const aiWorkflowMode = useWikiStore((s) => s.aiWorkflowMode)
+  const aiChatReasoningDepth = useWikiStore((s) => s.aiChatReasoningDepth)
 
   const chatConversations = useChatStore((s) => s.conversations)
   const chatMessages = useChatStore((s) => s.messages)
@@ -132,7 +134,16 @@ export function useAgentConfig(
       }
     }
 
-    const chapterWritingLlmConfig = resolveModelConfig(aiChatModel, baseLlmConfig, providerConfigs)
+    // The thinking-depth slider is stamped here and nowhere else. This config
+    // reaches `deep-chapter-generation` as `writingConfig` (passed through
+    // verbatim) and drives the draft, expansion, revision and local-polish
+    // calls. The orchestrating `agentLlmConfig` above, and the auxiliary
+    // `workflowConfig` the workflow re-resolves from the default model, both
+    // stay on whatever reasoning their own provider config specifies.
+    const chapterWritingLlmConfig = applyReasoningDepth(
+      resolveModelConfig(aiChatModel, baseLlmConfig, providerConfigs),
+      aiChatReasoningDepth,
+    )
     const registry = new ToolRegistry()
     const wikiPath = `${normalizePath(projectPath)}/wiki`
     const novelMode = useWikiStore.getState().novelMode
@@ -183,6 +194,7 @@ export function useAgentConfig(
     providerConfigs,
     mcpConfig,
     aiWorkflowMode,
+    aiChatReasoningDepth,
     getSearchApiConfig,
     getUserSkills,
     systemPrompt,

+ 10 - 0
src/i18n/en.json

@@ -392,6 +392,16 @@
     "selectModel": "Select model",
     "searchModel": "Search models...",
     "noModelFound": "No matching model",
+    "reasoningDepth": {
+      "label": "Thinking depth",
+      "auto": "Default",
+      "off": "Off",
+      "low": "Low",
+      "medium": "Medium",
+      "high": "High",
+      "max": "Max",
+      "hint": "Applies only to the selected chat model's drafting call; the default orchestration model is unaffected. \"Default\" keeps the provider's reasoning setting from Settings."
+    },
     "contextUsage": {
       "title": "Context Usage",
       "percentFull": "{{percent}}% Full",

+ 10 - 0
src/i18n/zh.json

@@ -263,6 +263,16 @@
     "selectModel": "选择模型",
     "searchModel": "搜索模型...",
     "noModelFound": "未找到匹配的模型",
+    "reasoningDepth": {
+      "label": "思考深度",
+      "auto": "默认",
+      "off": "关闭",
+      "low": "低",
+      "medium": "中",
+      "high": "高",
+      "max": "最大",
+      "hint": "只影响所选聊天模型的出稿调用,不改变编排用的默认模型。「默认」表示沿用设置里该供应商的推理配置。"
+    },
     "contextUsage": {
       "title": "上下文用量",
       "percentFull": "{{percent}}% 已占用",

+ 118 - 1
src/lib/llm-providers.spec.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from "vitest"
-import { getCustomCompatibleHeaders, getProviderConfig, parseGoogleLine, parseOpenAiSseError, withCustomOriginHeader } from "./llm-providers"
+import { getCustomCompatibleHeaders, getProviderConfig, modelSupportsReasoningControl, parseGoogleLine, parseOpenAiSseError, withCustomOriginHeader } from "./llm-providers"
 import { filterDeAiOutput } from "./novel/de-ai-output"
 import type { LlmConfig, ReasoningMode } from "@/stores/wiki-store"
 
@@ -754,3 +754,120 @@ describe("Gemini thought summaries", () => {
     expect(body.generationConfig).toBeUndefined()
   })
 })
+
+describe("modelSupportsReasoningControl", () => {
+  it("hides the control for local CLI providers that ignore config.reasoning", () => {
+    // Claude Code takes no thinking parameter; Cursor CLI reads effort out of
+    // the model id (`model[reasoning_effort=high]`) instead.
+    expect(modelSupportsReasoningControl(customConfig({
+      provider: "claude-code",
+      model: "claude-sonnet-4-5",
+    }))).toBe(false)
+    expect(modelSupportsReasoningControl(customConfig({
+      provider: "cursor-cli",
+      model: "gpt-5.4",
+    }))).toBe(false)
+  })
+
+  it("offers the control for Codex CLI, which maps the mode onto turn effort", () => {
+    expect(modelSupportsReasoningControl(customConfig({
+      provider: "codex-cli",
+      model: "gpt-5.4-codex",
+    }))).toBe(true)
+  })
+
+  it("gates Gemini on the same version check that guards thinkingConfig", () => {
+    expect(modelSupportsReasoningControl(customConfig({
+      provider: "google",
+      model: "gemini-2.5-pro",
+    }))).toBe(true)
+    expect(modelSupportsReasoningControl(customConfig({
+      provider: "google",
+      model: "gemini-1.5-pro",
+    }))).toBe(false)
+  })
+
+  it("requires Claude 3.7+ on the Anthropic wire, since 3.5 rejects thinking", () => {
+    expect(modelSupportsReasoningControl(customConfig({
+      provider: "anthropic",
+      model: "claude-3-7-sonnet-latest",
+    }))).toBe(true)
+    expect(modelSupportsReasoningControl(customConfig({
+      provider: "anthropic",
+      model: "claude-sonnet-4-5",
+    }))).toBe(true)
+    expect(modelSupportsReasoningControl(customConfig({
+      provider: "anthropic",
+      model: "claude-3-5-sonnet-20241022",
+    }))).toBe(false)
+  })
+
+  it("applies the Anthropic gate to MiniMax and anthropic_messages custom endpoints", () => {
+    expect(modelSupportsReasoningControl(customConfig({
+      provider: "minimax",
+      model: "MiniMax-M2",
+    }))).toBe(true)
+    expect(modelSupportsReasoningControl(customConfig({
+      apiMode: "anthropic_messages",
+      model: "claude-opus-4-1",
+    }))).toBe(true)
+    expect(modelSupportsReasoningControl(customConfig({
+      apiMode: "anthropic_messages",
+      model: "claude-3-haiku-20240307",
+    }))).toBe(false)
+  })
+
+  it("offers the control only for reasoning models on the OpenAI wire", () => {
+    expect(modelSupportsReasoningControl(customConfig({
+      provider: "openai",
+      model: "gpt-5.4",
+    }))).toBe(true)
+    expect(modelSupportsReasoningControl(customConfig({
+      provider: "openai",
+      model: "o3",
+    }))).toBe(true)
+    // gpt-4o either ignores reasoning_effort or 400s on it.
+    expect(modelSupportsReasoningControl(customConfig({
+      provider: "openai",
+      model: "gpt-4o",
+    }))).toBe(false)
+  })
+
+  it("recognises vendor-prefixed reasoning ids served by OpenAI-compatible gateways", () => {
+    expect(modelSupportsReasoningControl(customConfig({
+      model: "openai/o3",
+      customEndpoint: "https://openrouter.ai/api/v1",
+    }))).toBe(true)
+    expect(modelSupportsReasoningControl(customConfig({
+      model: "x-ai/grok-4-reasoning",
+      customEndpoint: "https://openrouter.ai/api/v1",
+    }))).toBe(true)
+    expect(modelSupportsReasoningControl(customConfig({
+      model: "meta-llama/llama-3.3-70b-instruct",
+      customEndpoint: "https://openrouter.ai/api/v1",
+    }))).toBe(false)
+  })
+
+  it("covers the vendor-specific thinking branches of buildOpenAiCompatibleBody", () => {
+    expect(modelSupportsReasoningControl(customConfig({
+      model: "deepseek-chat",
+      customEndpoint: "https://api.deepseek.com/v1",
+    }))).toBe(true)
+    expect(modelSupportsReasoningControl(customConfig({
+      model: "qwen3-235b-a22b",
+    }))).toBe(true)
+    expect(modelSupportsReasoningControl(customConfig({
+      model: "glm-5",
+      customEndpoint: "https://open.bigmodel.cn/api/paas/v4",
+    }))).toBe(true)
+  })
+
+  it("does not offer the control for GLM-5 outside the official Zhipu endpoint", () => {
+    // Third-party GLM deployments may not accept the top-level thinking
+    // object, which is why the body builder gates on the endpoint too.
+    expect(modelSupportsReasoningControl(customConfig({
+      model: "glm-5",
+      customEndpoint: "https://api.atlascloud.ai/v1",
+    }))).toBe(false)
+  })
+})

+ 73 - 0
src/lib/llm-providers.ts

@@ -831,6 +831,79 @@ function isOpenAiStrictCompletionModel(config: LlmConfig): boolean {
   return config.provider === "custom" && isAzureOpenAiEndpoint(config.customEndpoint)
 }
 
+/**
+ * Model families that honour Anthropic extended thinking. Claude 3.5 and
+ * earlier reject a `thinking` block with a 400, so the version gate is
+ * load-bearing rather than cosmetic.
+ *
+ * Covers Claude 3.7, the 4.x families in both the `claude-opus-4` and
+ * `claude-4-opus` spellings, and MiniMax M-series reasoning models served
+ * over the Messages wire.
+ */
+function isAnthropicThinkingModel(model: string): boolean {
+  const normalized = model.trim().toLowerCase()
+  if (/claude[-_]?3[.\-_]?7/.test(normalized)) return true
+  if (/claude[-_](?:opus|sonnet|haiku)[-_]?([4-9]|\d{2,})/.test(normalized)) return true
+  if (/claude[-_]?([4-9]|\d{2,})(?:[.\-_]|$)/.test(normalized)) return true
+  if (/minimax[-_]?m\d/.test(normalized) || /^m\d+$/.test(normalized)) return true
+  return false
+}
+
+/**
+ * Reasoning models reached through OpenAI-compatible aggregators, where the
+ * id usually carries a vendor prefix (`openai/o3`, `x-ai/grok-4-reasoning`).
+ * `isOpenAiStrictCompletionModel` only matches bare ids on first-party
+ * OpenAI/Azure because it also governs the `max_completion_tokens` rewrite,
+ * so aggregator ids need their own gate.
+ */
+function isPrefixedReasoningModel(model: string): boolean {
+  const normalized = model.trim().toLowerCase()
+  const tail = normalized.includes("/")
+    ? normalized.slice(normalized.lastIndexOf("/") + 1)
+    : normalized
+  return /^gpt-5(?:[.\-_]|$)/.test(tail)
+    || /^o\d+(?:[.\-_]|$)/.test(tail)
+    || /(?:^|[-_])(?:reasoning|thinking)(?:[-_]|$)/.test(tail)
+}
+
+/**
+ * Whether thinking depth can actually be steered for this model. False means
+ * every explicit reasoning mode would either be dropped on the floor or
+ * rejected outright, so callers should hide the control rather than offer a
+ * knob that does nothing.
+ *
+ * Deliberately a conservative allow-list instead of the inverse of the wire
+ * adaptations. `buildOpenAiCompatibleBody` sends `reasoning_effort` to any
+ * openai/azure/custom provider that asks for it, which non-reasoning models
+ * like gpt-4o either ignore or 400 on, and the Anthropic path has the same
+ * problem with `thinking` on Claude 3.5. Listing only the families known to
+ * honour the parameter keeps a useless slider off the screen.
+ */
+export function modelSupportsReasoningControl(config: LlmConfig): boolean {
+  // Claude Code CLI takes no thinking parameter at all, and Cursor CLI
+  // carries effort inside the model id (`model[reasoning_effort=high]`)
+  // rather than reading config.reasoning.
+  if (config.provider === "claude-code" || config.provider === "cursor-cli") return false
+  // Codex CLI maps the mode straight onto `turn/start.effort`.
+  if (config.provider === "codex-cli") return true
+  if (config.provider === "google") return googleModelSupportsThinkingConfig(config.model)
+
+  const anthropicWire = config.provider === "anthropic"
+    || config.provider === "minimax"
+    || (config.provider === "custom" && config.apiMode === "anthropic_messages")
+  if (anthropicWire) return isAnthropicThinkingModel(config.model)
+
+  if (config.provider === "custom" && config.apiMode === "responses") {
+    return isOpenAiStrictCompletionModel(config) || isPrefixedReasoningModel(config.model)
+  }
+
+  if (isDeepSeekEndpoint(config)) return true
+  if (isChatTemplateThinkingModel(config.model) || isMiMoEndpoint(config)) return true
+  if (isGLMThinkingModel(config.model) && isZhipuEndpoint(config)) return true
+  if (isOpenAiStrictCompletionModel(config)) return true
+  return isPrefixedReasoningModel(config.model)
+}
+
 function adaptOpenAiStrictCompletionBody(config: LlmConfig, body: Record<string, unknown>): void {
   if (!isOpenAiStrictCompletionModel(config)) return
 

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

@@ -29,6 +29,7 @@ import {
   type AiWorkflowMode,
   type OutlineWorkflowMode,
 } from "@/lib/agent/workflow-mode"
+import { normalizeReasoningDepth, type ReasoningDepth } from "@/lib/reasoning-depth"
 
 const RECENT_PROJECTS_KEY = "recentProjects"
 const LAST_PROJECT_KEY = "lastProject"
@@ -146,6 +147,8 @@ const AI_CHAT_MODEL_KEY = "aiChatModel"
 const AI_OUTLINE_MODEL_KEY = "aiOutlineModel"
 const AI_WORKFLOW_MODE_KEY = "aiWorkflowMode"
 const OUTLINE_WORKFLOW_MODE_KEY = "outlineWorkflowMode"
+const AI_CHAT_REASONING_DEPTH_KEY = "aiChatReasoningDepth"
+const AI_OUTLINE_REASONING_DEPTH_KEY = "aiOutlineReasoningDepth"
 let aiOutlineModelSaveRevision = 0
 let latestAiOutlineModel = ""
 const DEFAULT_LLM_MODEL_KEY = "defaultLlmModel"
@@ -243,6 +246,50 @@ export async function loadOutlineWorkflowMode(): Promise<OutlineWorkflowMode | n
   return isOutlineWorkflowMode(saved) ? saved : null
 }
 
+/**
+ * Serialise writes to one key so a slow earlier write cannot land after a
+ * newer value. Dragging the thinking-depth slider fires one save per step
+ * and `store.set` is async, so last-writer-wins has to be enforced here —
+ * same reason `saveAiOutlineModel` carries its own revision counter.
+ */
+function createLatestWinsWriter(key: string): (value: string) => Promise<void> {
+  let revision = 0
+  let latest = ""
+  return async (value: string) => {
+    const writeRevision = ++revision
+    latest = value
+    const store = await getStore()
+    await store.set(key, value)
+
+    let persistedRevision = writeRevision
+    while (persistedRevision !== revision) {
+      persistedRevision = revision
+      await store.set(key, latest)
+    }
+  }
+}
+
+const writeAiChatReasoningDepth = createLatestWinsWriter(AI_CHAT_REASONING_DEPTH_KEY)
+const writeAiOutlineReasoningDepth = createLatestWinsWriter(AI_OUTLINE_REASONING_DEPTH_KEY)
+
+export async function saveAiChatReasoningDepth(depth: ReasoningDepth): Promise<void> {
+  await writeAiChatReasoningDepth(normalizeReasoningDepth(depth))
+}
+
+export async function loadAiChatReasoningDepth(): Promise<ReasoningDepth> {
+  const store = await getStore()
+  return normalizeReasoningDepth(await store.get<unknown>(AI_CHAT_REASONING_DEPTH_KEY))
+}
+
+export async function saveAiOutlineReasoningDepth(depth: ReasoningDepth): Promise<void> {
+  await writeAiOutlineReasoningDepth(normalizeReasoningDepth(depth))
+}
+
+export async function loadAiOutlineReasoningDepth(): Promise<ReasoningDepth> {
+  const store = await getStore()
+  return normalizeReasoningDepth(await store.get<unknown>(AI_OUTLINE_REASONING_DEPTH_KEY))
+}
+
 export async function saveDefaultLlmModel(model: string): Promise<void> {
   const store = await getStore()
   await store.set(DEFAULT_LLM_MODEL_KEY, model)

+ 104 - 0
src/lib/reasoning-depth.spec.ts

@@ -0,0 +1,104 @@
+import { describe, expect, it } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+import {
+  REASONING_DEPTH_STEPS,
+  applyReasoningDepth,
+  normalizeReasoningDepth,
+  reasoningDepthFromIndex,
+  reasoningDepthToIndex,
+} from "./reasoning-depth"
+
+const baseConfig: LlmConfig = {
+  provider: "openai",
+  apiKey: "key",
+  model: "gpt-5",
+  ollamaUrl: "",
+  customEndpoint: "",
+  maxContextSize: 204800,
+}
+
+describe("normalizeReasoningDepth", () => {
+  it("keeps every slider stop", () => {
+    for (const step of REASONING_DEPTH_STEPS) {
+      expect(normalizeReasoningDepth(step)).toBe(step)
+    }
+  })
+
+  it("falls back to auto for the custom mode the settings page can still hold", () => {
+    expect(normalizeReasoningDepth("custom")).toBe("auto")
+  })
+
+  it("falls back to auto for garbage", () => {
+    expect(normalizeReasoningDepth(undefined)).toBe("auto")
+    expect(normalizeReasoningDepth(null)).toBe("auto")
+    expect(normalizeReasoningDepth(3)).toBe("auto")
+    expect(normalizeReasoningDepth("HIGH")).toBe("auto")
+  })
+})
+
+describe("reasoning depth index mapping", () => {
+  it("puts auto at the left end and max at the right", () => {
+    expect(reasoningDepthToIndex("auto")).toBe(0)
+    expect(reasoningDepthToIndex("max")).toBe(REASONING_DEPTH_STEPS.length - 1)
+  })
+
+  it("round-trips every stop", () => {
+    for (const step of REASONING_DEPTH_STEPS) {
+      expect(reasoningDepthFromIndex(reasoningDepthToIndex(step))).toBe(step)
+    }
+  })
+
+  it("clamps out-of-range slider positions instead of returning undefined", () => {
+    expect(reasoningDepthFromIndex(-4)).toBe("auto")
+    expect(reasoningDepthFromIndex(99)).toBe("max")
+    expect(reasoningDepthFromIndex(Number.NaN)).toBe("auto")
+  })
+})
+
+describe("applyReasoningDepth", () => {
+  it("leaves the config untouched at the auto stop", () => {
+    const configured: LlmConfig = {
+      ...baseConfig,
+      reasoning: { mode: "custom", budgetTokens: 20000 },
+    }
+    expect(applyReasoningDepth(configured, "auto")).toBe(configured)
+  })
+
+  it("does not erase a custom budget configured in settings", () => {
+    const configured: LlmConfig = {
+      ...baseConfig,
+      reasoning: { mode: "custom", budgetTokens: 20000 },
+    }
+    expect(applyReasoningDepth(configured, "auto").reasoning).toEqual({
+      mode: "custom",
+      budgetTokens: 20000,
+    })
+  })
+
+  it("overrides the provider mode for every explicit stop", () => {
+    for (const step of ["off", "low", "medium", "high", "max"] as const) {
+      const applied = applyReasoningDepth(
+        { ...baseConfig, reasoning: { mode: "auto" } },
+        step,
+      )
+      expect(applied.reasoning?.mode).toBe(step)
+    }
+  })
+
+  it("stamps a mode onto a config that had no reasoning block at all", () => {
+    expect(applyReasoningDepth(baseConfig, "high").reasoning).toEqual({ mode: "high" })
+  })
+
+  it("never mutates the input config", () => {
+    const configured: LlmConfig = { ...baseConfig, reasoning: { mode: "off" } }
+    applyReasoningDepth(configured, "max")
+    expect(configured.reasoning).toEqual({ mode: "off" })
+  })
+
+  it("treats an unrecognised persisted depth as auto rather than mode undefined", () => {
+    const configured: LlmConfig = { ...baseConfig, reasoning: { mode: "low" } }
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const applied = applyReasoningDepth(configured, undefined as any)
+    expect(applied.reasoning).toEqual({ mode: "low" })
+  })
+})

+ 73 - 0
src/lib/reasoning-depth.ts

@@ -0,0 +1,73 @@
+import type { LlmConfig, ReasoningMode } from "@/stores/wiki-store"
+
+/**
+ * Slider stops for the chat-model thinking depth control, ordered left to
+ * right. `auto` sits at index 0 as the "default" stop: it means *defer*, not
+ * "less than off", so the UI must mark it apart from the monotonic tail.
+ *
+ * `custom` is intentionally absent. A token budget needs a number field,
+ * which belongs in settings next to the provider config, not in a chat
+ * footer popover.
+ */
+export const REASONING_DEPTH_STEPS = [
+  "auto",
+  "off",
+  "low",
+  "medium",
+  "high",
+  "max",
+] as const satisfies readonly ReasoningMode[]
+
+export type ReasoningDepth = (typeof REASONING_DEPTH_STEPS)[number]
+
+export const DEFAULT_REASONING_DEPTH: ReasoningDepth = "auto"
+
+function isReasoningDepth(value: unknown): value is ReasoningDepth {
+  return typeof value === "string"
+    && (REASONING_DEPTH_STEPS as readonly string[]).includes(value)
+}
+
+/**
+ * Coerce a persisted or user-supplied value onto a slider stop. Anything
+ * unrecognised — including the `custom` mode that settings can still hold —
+ * falls back to `auto`, which leaves the provider config untouched.
+ */
+export function normalizeReasoningDepth(value: unknown): ReasoningDepth {
+  return isReasoningDepth(value) ? value : DEFAULT_REASONING_DEPTH
+}
+
+export function reasoningDepthToIndex(depth: ReasoningDepth): number {
+  const index = REASONING_DEPTH_STEPS.indexOf(depth)
+  return index === -1 ? 0 : index
+}
+
+export function reasoningDepthFromIndex(index: number): ReasoningDepth {
+  if (!Number.isFinite(index)) return DEFAULT_REASONING_DEPTH
+  const clamped = Math.min(
+    REASONING_DEPTH_STEPS.length - 1,
+    Math.max(0, Math.round(index)),
+  )
+  return REASONING_DEPTH_STEPS[clamped]
+}
+
+/**
+ * Stamp a slider depth onto the config that will actually be sent.
+ *
+ * The value has to land on `LlmConfig.reasoning` rather than
+ * `RequestOverrides.reasoning`: `streamChat` and the outline/chapter budget
+ * planners all derive their output-token floor from `config.reasoning`, so an
+ * override-only path would ask for deep thinking without widening the output
+ * allowance and the model would spend the whole budget on reasoning with no
+ * content left.
+ *
+ * The `auto` stop returns the config untouched so the provider-level setting
+ * — including a `custom` budget the user configured in settings — keeps
+ * applying. That is what makes the default stop non-destructive.
+ */
+export function applyReasoningDepth(config: LlmConfig, depth: ReasoningDepth): LlmConfig {
+  // Normalized rather than trusted: a garbled persisted value reaching the
+  // wire as `mode: undefined` would silently disable every thinking branch.
+  const mode = normalizeReasoningDepth(depth)
+  if (mode === "auto") return config
+  return { ...config, reasoning: { ...config.reasoning, mode } }
+}

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

@@ -20,6 +20,11 @@ import {
   normalizeUiFontFamily,
   type UiFontFamily,
 } from "@/lib/font-settings"
+import {
+  DEFAULT_REASONING_DEPTH,
+  normalizeReasoningDepth,
+  type ReasoningDepth,
+} from "@/lib/reasoning-depth"
 import {
   DEFAULT_VISUAL_STYLE,
   VISUAL_STYLE_STORAGE_KEY,
@@ -597,6 +602,14 @@ interface WikiState {
   /** Dedicated global AI outline model key; isolated from AI chat. */
   aiOutlineModel: string
   aiOutlineModelRevision: number
+  /**
+   * Thinking depth for the chat model picked in the writing footer. Applies
+   * only to chapter body generation (`chapterWritingLlmConfig`), never to the
+   * orchestrating agent, which runs on the default model.
+   */
+  aiChatReasoningDepth: ReasoningDepth
+  /** Thinking depth for the outline chat model; applies to generation turns only. */
+  aiOutlineReasoningDepth: ReasoningDepth
   /** 默认模型(工作流):拆文库、导入队列、去重、角色 aura 等。章节/大纲记忆摄取见 novelConfig.extractModel */
   defaultLlmModel: string
   /** Per-provider-preset stored overrides (API key, model, endpoint, …). */
@@ -672,6 +685,8 @@ interface WikiState {
   setLlmConfig: (config: LlmConfig) => void
   setAiChatModel: (model: string) => void
   setAiOutlineModel: (model: string) => void
+  setAiChatReasoningDepth: (depth: ReasoningDepth) => void
+  setAiOutlineReasoningDepth: (depth: ReasoningDepth) => void
   setDefaultLlmModel: (model: string) => void
   setProviderConfigs: (configs: ProviderConfigs) => void
   setActivePresetId: (id: string | null) => void
@@ -760,6 +775,8 @@ export const useWikiStore = create<WikiState>((set) => ({
   aiChatModel: "",
   aiOutlineModel: "",
   aiOutlineModelRevision: 0,
+  aiChatReasoningDepth: DEFAULT_REASONING_DEPTH,
+  aiOutlineReasoningDepth: DEFAULT_REASONING_DEPTH,
   defaultLlmModel: "",
   providerConfigs: {},
   activePresetId: null,
@@ -932,6 +949,12 @@ export const useWikiStore = create<WikiState>((set) => ({
     aiOutlineModel,
     aiOutlineModelRevision: state.aiOutlineModelRevision + 1,
   })),
+  setAiChatReasoningDepth: (depth) => set({
+    aiChatReasoningDepth: normalizeReasoningDepth(depth),
+  }),
+  setAiOutlineReasoningDepth: (depth) => set({
+    aiOutlineReasoningDepth: normalizeReasoningDepth(depth),
+  }),
   setDefaultLlmModel: (defaultLlmModel) => set({ defaultLlmModel }),
   setProviderConfigs: (providerConfigs) => set({
     providerConfigs: normalizeProviderConfigs(providerConfigs),