Jelajahi Sumber

fix(llm): 修复上下文预算单位语义并收敛 max_tokens

将 maxContextSize 按模型 token 窗口处理,避免中文场景可用窗口被四倍缩小;
仅在调用方显式传入时外发 max_tokens,并保留 200K 上下文最低地板。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 2 bulan lalu
induk
melakukan
bcb8c3eb78

+ 2 - 2
src/App.tsx

@@ -17,7 +17,7 @@ import { WelcomeScreen } from "@/components/project/welcome-screen"
 import { CreateProjectDialog } from "@/components/project/create-project-dialog"
 import { formatAppTitle } from "@/lib/app-title"
 import { resetProjectState } from "@/lib/reset-project-state"
-import { LLM_PRESETS } from "@/components/settings/llm-presets"
+import { findLlmPresetById } from "@/components/settings/llm-presets"
 import { resolveConfig } from "@/components/settings/preset-resolver"
 import { toast } from "@/lib/toast"
 import type { WikiProject } from "@/types/wiki"
@@ -303,7 +303,7 @@ function App() {
           // `llmConfig` snapshot from a previous launch would keep the
           // old value. Overrides still win, so an explicit user choice
           // is preserved.
-          const preset = LLM_PRESETS.find((p) => p.id === savedActivePreset)
+          const preset = findLlmPresetById(savedActivePreset)
           if (preset) {
             const currentFallback = useWikiStore.getState().llmConfig
             const override = (savedProviderConfigs ?? {})[savedActivePreset]

+ 2 - 2
src/components/chat/chat-model-selector.tsx

@@ -4,7 +4,7 @@ import { ChevronDown, Check } from "lucide-react"
 import { createPortal } from "react-dom"
 import { Button } from "@/components/ui/button"
 import { useWikiStore, type SavedModel } from "@/stores/wiki-store"
-import { LLM_PRESETS } from "@/components/settings/llm-presets"
+import { findLlmPresetById } from "@/components/settings/llm-presets"
 import { getEffectiveSavedModels, isProviderAvailable } from "@/lib/llm-model-keys"
 
 interface ChatModelSelectorProps {
@@ -85,7 +85,7 @@ export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelect
       if (!isProviderAvailable(key, config)) continue
       const models = getEffectiveSavedModels(config)
       if (models.length > 0) {
-        const preset = LLM_PRESETS.find((p) => p.id === key)
+        const preset = findLlmPresetById(key)
         groups.push({
           id: key,
           label: preset?.label || config.label || key,

+ 7 - 10
src/components/settings/context-size-selector.tsx

@@ -1,10 +1,6 @@
-const CONTEXT_PRESETS = [
-  { value: 4096, label: "4K" },
-  { value: 8192, label: "8K" },
-  { value: 16384, label: "16K" },
-  { value: 32768, label: "32K" },
-  { value: 65536, label: "64K" },
-  { value: 131072, label: "128K" },
+import { normalizeUserLlmContextSize } from "@/lib/llm-context-size"
+
+export const CONTEXT_PRESETS = [
   { value: 204800, label: "200K" },
   { value: 262144, label: "256K" },
   { value: 524288, label: "512K" },
@@ -24,8 +20,9 @@ export function ContextSizeSelector({
   value: number
   onChange: (v: number) => void
 }) {
+  const normalizedValue = normalizeUserLlmContextSize(value)
   const closestIndex = CONTEXT_PRESETS.reduce((best, preset, i) => {
-    return Math.abs(preset.value - value) < Math.abs(CONTEXT_PRESETS[best].value - value)
+    return Math.abs(preset.value - normalizedValue) < Math.abs(CONTEXT_PRESETS[best].value - normalizedValue)
       ? i
       : best
   }, 0)
@@ -34,9 +31,9 @@ export function ContextSizeSelector({
   return (
     <div>
       <div className="flex items-center justify-between mb-2">
-        <span className="text-sm font-medium">{formatSize(value)}</span>
+        <span className="text-sm font-medium">{formatSize(normalizedValue)}</span>
         <span className="text-xs text-muted-foreground">
-          ~{Math.floor((value * 0.6) / 1000)}K chars for wiki content
+          ~{Math.floor((normalizedValue * 0.6) / 1000)}K chars for wiki content
         </span>
       </div>
       <input

+ 38 - 24
src/components/settings/llm-presets.ts

@@ -1,4 +1,5 @@
 import type { AzureModelFamily } from "@/stores/wiki-store"
+import { MIN_USER_LLM_CONTEXT_SIZE } from "@/lib/llm-context-size"
 
 /**
  * Curated LLM provider presets.
@@ -56,7 +57,7 @@ export interface LlmPreset {
   suggestedModels?: string[]
   /** Custom providers only: which wire protocol to speak. */
   apiMode?: CustomApiMode
-  /** Suggested context window; user can override. */
+  /** Suggested character-budget window; user can override. */
   suggestedContextSize?: number
 }
 
@@ -87,7 +88,7 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
       "claude-3-5-sonnet-20241022",
       "claude-3-5-haiku-20241022",
     ],
-    suggestedContextSize: 200000,
+    suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },
   {
     id: "claude-code-cli",
@@ -105,7 +106,7 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
       "claude-sonnet-4-5-20250929",
       "claude-haiku-4-5-20251001",
     ],
-    suggestedContextSize: 200000,
+    suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },
   {
     id: "codex-cli",
@@ -120,7 +121,7 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
       "gpt-5.3-codex-spark",
       "gpt-5.2",
     ],
-    suggestedContextSize: 200000,
+    suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },
   {
     id: "cursor-cli",
@@ -141,7 +142,7 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
       "gpt-5.5-medium",
       "claude-opus-4-7-thinking-max",
     ],
-    suggestedContextSize: 200000,
+    suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },
   {
     id: "openai",
@@ -163,7 +164,7 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
       "o1-mini",
       "gpt-4-turbo",
     ],
-    suggestedContextSize: 128000,
+    suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },
   {
     id: "google",
@@ -191,7 +192,7 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
     baseUrl: "https://your-resource.openai.azure.com",
     defaultModel: "your-deployment-name",
     azureApiVersion: "2024-10-21",
-    suggestedContextSize: 128000,
+    suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },
   {
     id: "deepseek",
@@ -240,7 +241,7 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
       "openai/gpt-5.5",
       "google/gemini-3.5-flash",
     ],
-    suggestedContextSize: 128000,
+    suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },
   {
     id: "groq",
@@ -250,19 +251,18 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
     baseUrl: "https://api.groq.com/openai/v1",
     defaultModel: "llama-3.3-70b-versatile",
     apiMode: "chat_completions",
-    // Groq hosts open-weight models; list stays current-practical picks.
+    // The writing floor is 204800 characters, approximately 51200 model
+    // tokens. Models below that real context window are not suggested.
     suggestedModels: [
       "llama-3.3-70b-versatile",
       "llama-3.1-8b-instant",
       "llama-3.1-70b-versatile",
-      "mixtral-8x7b-32768",
-      "gemma2-9b-it",
       "moonshotai/kimi-k2-instruct",
       "openai/gpt-oss-120b",
       "openai/gpt-oss-20b",
       "qwen/qwen3-32b",
     ],
-    suggestedContextSize: 128000,
+    suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },
   {
     id: "xai",
@@ -282,7 +282,7 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
       "grok-code-fast-1",
       "grok-2-vision-1212",
     ],
-    suggestedContextSize: 131072,
+    suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },
   {
     id: "nvidia-nim",
@@ -319,7 +319,7 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
       "mistralai/mixtral-8x22b-instruct",
       "mistralai/mistral-large-2-instruct",
     ],
-    suggestedContextSize: 128000,
+    suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },
   {
     id: "kimi",
@@ -407,7 +407,7 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
       "glm-4v-plus",
       "glm-zero-preview",
     ],
-    suggestedContextSize: 128000,
+    suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },
   {
     id: "minimax-global",
@@ -422,7 +422,7 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
     // dropped — users who still need them can type the id into the
     // custom input.
     suggestedModels: ["MiniMax-M3", "MiniMax-M2.7"],
-    suggestedContextSize: 200000,
+    suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },
   {
     id: "minimax-cn",
@@ -433,7 +433,7 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
     defaultModel: "MiniMax-M3",
     apiMode: "anthropic_messages",
     suggestedModels: ["MiniMax-M3", "MiniMax-M2.7"],
-    suggestedContextSize: 200000,
+    suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },
   {
     id: "bailian-coding",
@@ -466,7 +466,7 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
       "qwen3-coder-next",
       "glm-4.7",
     ],
-    suggestedContextSize: 131072,
+    suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },
   {
     id: "xiaomi-mimo",
@@ -521,7 +521,7 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
       "GLM-4.7",
       "DeepSeek-V3",
     ],
-    suggestedContextSize: 128000,
+    suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },
   {
     id: "ollama-local",
@@ -531,7 +531,7 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
     baseUrl: "http://localhost:11434",
     // Intentionally no suggestedModels: local set depends on what the
     // user has actually pulled / loaded. Kept as free-text input.
-    suggestedContextSize: 32768,
+    suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },
   {
     id: "ollama-cloud",
@@ -548,7 +548,7 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
       "kimi-k2:1t",
       "deepseek-v3.1:671b",
     ],
-    suggestedContextSize: 128000,
+    suggestedContextSize: MIN_USER_LLM_CONTEXT_SIZE,
   },
   {
     id: "custom",
@@ -563,9 +563,23 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
   },
 ]
 
-export const LLM_PRESETS: LlmPreset[] = RAW_LLM_PRESETS.filter(
-  (preset, index) => preset.id !== "custom" || index === 0,
-)
+const ALL_LLM_PRESETS: LlmPreset[] = RAW_LLM_PRESETS
+  .filter((preset, index) => preset.id !== "custom" || index === 0)
+  .map((preset) => ({
+    ...preset,
+    suggestedContextSize: Math.max(
+      preset.suggestedContextSize ?? MIN_USER_LLM_CONTEXT_SIZE,
+      MIN_USER_LLM_CONTEXT_SIZE,
+    ),
+  }))
+
+/** All providers remain visible and default to at least the writing floor. */
+export const LLM_PRESETS: LlmPreset[] = ALL_LLM_PRESETS
+
+/** Resolve provider configurations by their stable preset id. */
+export function findLlmPresetById(id: string): LlmPreset | undefined {
+  return ALL_LLM_PRESETS.find((preset) => preset.id === id)
+}
 
 /**
  * Best-effort reverse lookup: given the current LlmConfig fields, which

+ 24 - 1
src/components/settings/llm-wiki-model-settings.spec.ts

@@ -1,4 +1,6 @@
 import { describe, expect, it } from "vitest"
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
 import { getProviderConfig } from "@/lib/llm-providers"
 import type { LlmConfig } from "@/stores/wiki-store"
 import zh from "@/i18n/zh.json"
@@ -23,7 +25,16 @@ function preset(id: string) {
 }
 
 describe("QMAI model settings", () => {
-  it("includes the built-in provider rows below the custom row", () => {
+  it("renders the 200K long-writing requirement on the LLM settings page", () => {
+    const source = readFileSync(
+      resolve(__dirname, "sections/llm-provider-section.tsx"),
+      "utf8",
+    )
+    expect(source).toContain("settings.sections.llm.longWritingContextTitle")
+    expect(source).toContain("settings.sections.llm.longWritingContextHint")
+  })
+
+  it("keeps every built-in provider and gives each one at least 204800", () => {
     expect(LLM_PRESETS[0]?.id).toBe("custom")
 
     expect(LLM_PRESETS.map((item) => item.id)).toEqual([
@@ -52,6 +63,16 @@ describe("QMAI model settings", () => {
       "ollama-local",
       "ollama-cloud",
     ])
+    for (const item of LLM_PRESETS) {
+      expect(item.suggestedContextSize).toBeGreaterThanOrEqual(204_800)
+    }
+  })
+
+  it("removes only known models below the writing window from suggestions", () => {
+    const groq = preset("groq")
+    expect(groq.suggestedModels).not.toContain("mixtral-8x7b-32768")
+    expect(groq.suggestedModels).not.toContain("gemma2-9b-it")
+    expect(groq.defaultModel).toBe("llama-3.3-70b-versatile")
   })
 
   it("resolves DeepSeek to an OpenAI-compatible custom endpoint", () => {
@@ -128,6 +149,8 @@ describe("QMAI model settings", () => {
     expect(llm.collapse).toBe("收起配置")
     expect(llm.apiKeyPlaceholder).toBe("输入 API Key")
     expect(llm.activeBadge).toBe("当前使用")
+    expect(llm.longWritingContextTitle).toContain("至少 200K")
+    expect(llm.longWritingContextHint).toContain("204800")
     expect(JSON.stringify(llm)).not.toContain("??")
   })
 })

+ 4 - 2
src/components/settings/preset-resolver.ts

@@ -3,6 +3,7 @@ import type { ProviderOverride } from "@/stores/wiki-store"
 import { AZURE_OPENAI_API_VERSION } from "@/lib/azure-openai"
 import { getEffectiveMaxContextSize } from "@/lib/llm-providers"
 import type { LlmPreset } from "./llm-presets"
+import { normalizeUserLlmContextSize } from "@/lib/llm-context-size"
 
 /**
  * Build a full LlmConfig from a preset template + the user's saved
@@ -17,8 +18,9 @@ export function resolveConfig(
   const ov = override ?? {}
   const apiKey = ov.apiKey ?? ""
   const model = ov.model?.trim() || preset.defaultModel || ""
-  const rawMaxContextSize =
-    ov.maxContextSize ?? preset.suggestedContextSize ?? fallback.maxContextSize
+  const rawMaxContextSize = normalizeUserLlmContextSize(
+    ov.maxContextSize ?? preset.suggestedContextSize ?? fallback.maxContextSize,
+  )
   const reasoning = ov.reasoning ?? { mode: "auto" as const }
   const localCliIsolation = ov.localCliIsolation === true
   const functionCallingEnabled = ov.functionCallingEnabled !== false

+ 7 - 2
src/components/settings/sections/custom-provider-cards.tsx

@@ -10,6 +10,7 @@ import { fetchLlmModelList } from "@/lib/settings-model-list"
 import { useBatchModelTest } from "../hooks/use-batch-model-test"
 import { useTranslation } from "react-i18next"
 import { FunctionCallingControls, ReasoningControls } from "./llm-provider-section"
+import { normalizeUserLlmContextSize } from "@/lib/llm-context-size"
 
 interface CustomProviderCard {
   id: string
@@ -43,7 +44,7 @@ export function CustomProviderCards() {
         baseUrl: config.baseUrl || "",
         apiKey: config.apiKey || "",
         model: config.model || "",
-        maxContextSize: config.maxContextSize,
+        maxContextSize: normalizeUserLlmContextSize(config.maxContextSize),
         reasoning: config.reasoning,
         functionCallingEnabled: config.functionCallingEnabled,
         enabled: config.enabled ?? true,
@@ -61,6 +62,7 @@ export function CustomProviderCards() {
       baseUrl: "",
       apiKey: "",
       model: "",
+      maxContextSize: normalizeUserLlmContextSize(undefined),
       enabled: true,
       savedModels: [],
     }
@@ -75,6 +77,7 @@ export function CustomProviderCards() {
         baseUrl: newCard.baseUrl,
         apiKey: newCard.apiKey,
         model: newCard.model,
+        maxContextSize: newCard.maxContextSize,
         enabled: true,
         savedModels: newCard.savedModels,
       },
@@ -95,7 +98,9 @@ export function CustomProviderCards() {
       baseUrl: updates.baseUrl ?? prev.baseUrl,
       apiKey: updates.apiKey ?? prev.apiKey,
       model: updates.model ?? prev.model,
-      maxContextSize: updates.maxContextSize ?? prev.maxContextSize,
+      maxContextSize: normalizeUserLlmContextSize(
+        updates.maxContextSize ?? prev.maxContextSize,
+      ),
       reasoning: updates.reasoning ?? prev.reasoning,
       functionCallingEnabled: updates.functionCallingEnabled ?? prev.functionCallingEnabled,
       enabled: updates.enabled ?? prev.enabled ?? true,

+ 18 - 2
src/components/settings/sections/llm-provider-section.tsx

@@ -17,6 +17,7 @@ import { useBatchModelTest } from "../hooks/use-batch-model-test"
 import { ModelSelectInput } from "../model-select-input"
 import { SavedModelsManager } from "./saved-models-manager"
 import { CustomProviderCards } from "./custom-provider-cards"
+import { normalizeProviderOverride } from "@/lib/llm-context-size"
 
 export function LlmProviderSection() {
   const { t } = useTranslation()
@@ -51,7 +52,10 @@ export function LlmProviderSection() {
   }
 
   function updateOverride(id: string, patch: ProviderOverride) {
-    const merged: ProviderOverride = { ...(providerConfigs[id] ?? {}), ...patch }
+    const merged: ProviderOverride = normalizeProviderOverride({
+      ...(providerConfigs[id] ?? {}),
+      ...patch,
+    })
     const next = { ...providerConfigs, [id]: merged }
     setProviderConfigs(next)
     persist(next, activePresetId).catch(() => {})
@@ -88,10 +92,22 @@ export function LlmProviderSection() {
         </p>
       </div>
 
+      <div className="flex gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm text-amber-800 dark:text-amber-300">
+        <AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
+        <div>
+          <div className="font-medium">
+            {t("settings.sections.llm.longWritingContextTitle")}
+          </div>
+          <p className="mt-0.5 text-xs leading-relaxed">
+            {t("settings.sections.llm.longWritingContextHint")}
+          </p>
+        </div>
+      </div>
+
       {/* Custom Provider Cards - 放在顶部 */}
       <CustomProviderCards />
 
-      {/* Built-in Presets - 内置预设,过滤掉通用的"自定义模型"预设 */}
+      {/* Keep every provider; each provider defaults to at least the writing floor. */}
       <div className="space-y-2">
         {LLM_PRESETS.filter((p) => p.id !== "custom").map((preset) => {
           const ov = providerConfigs[preset.id]

+ 40 - 7
src/components/sources/outline-chat-panel.tsx

@@ -114,6 +114,10 @@ import {
   resolveUsableModelKey,
 } from "@/lib/novel/model-resolver";
 import { hasAvailableModels as hasConfiguredModels } from "@/lib/llm-model-keys";
+import {
+  planOutlineRequestBudget,
+  type OutlineBudgetStage,
+} from "@/lib/context-budget";
 import { ChatModelSelector } from "@/components/chat/chat-model-selector";
 import { highlightCode } from "@/lib/streaming-code-highlight";
 import { separateThinking } from "@/lib/separate-thinking";
@@ -1823,6 +1827,14 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       // 出错/中断时必须依靠这个变量判断有没有可保留的内容,
       // 避免整段结果被静默丢弃。
       let bestGeneratedText = "";
+      const outlineBudgetStage: OutlineBudgetStage = options.intentPhase === "generation"
+        ? "generation"
+        : "analysis";
+      const outlineRequestBudget = planOutlineRequestBudget({
+        maxContextSize: effectiveLlmConfig.maxContextSize,
+        contextTokenBudget: novelConfig.contextTokenBudget,
+        stage: outlineBudgetStage,
+      });
 
       try {
         const contextHub = getContextHub(normalizePath(project.path));
@@ -1835,7 +1847,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           references: tokens.map(describeReferenceForOutlineAgent),
           messages: historyBeforeSend,
           existingSummary: forceRefresh ? undefined : targetConversation?.contextSummary,
-          tokenBudget: novelConfig.contextTokenBudget,
+          tokenBudget: outlineRequestBudget.contextTokenBudget,
           maxContextSize: effectiveLlmConfig.maxContextSize,
           forceRefresh,
         });
@@ -1934,6 +1946,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         const buildConfigForSkillNames = (
           skillNames: string[] | undefined,
           disableWriteTools: boolean | undefined,
+          budgetStage: OutlineBudgetStage = outlineBudgetStage,
         ) => {
           const registry = new ToolRegistry();
           const effectiveOutlineWritingSkills = prioritizeOutlineSkills(
@@ -1980,15 +1993,19 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 : {}),
             },
           );
+          const requestBudget = budgetStage === outlineBudgetStage
+            ? outlineRequestBudget
+            : planOutlineRequestBudget({
+                maxContextSize: effectiveLlmConfig.maxContextSize,
+                contextTokenBudget: novelConfig.contextTokenBudget,
+                stage: budgetStage,
+              });
           return {
             agentConfig: {
               ...agentConfig,
               requestOverrides: {
                 ...agentConfig.requestOverrides,
-                max_tokens: Math.max(
-                  agentConfig.requestOverrides?.max_tokens ?? 0,
-                  32768,
-                ),
+                max_tokens: requestBudget.outputTokens,
                 userMemorySurface: "ai-outline" as const,
                 userMemoryProjectKey: normalizePath(project.path),
                 userMemorySessionKey: capturedConvId,
@@ -2005,11 +2022,13 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             disableWriteTools?: boolean;
             streamToUser?: boolean;
             statusText?: string;
+            budgetStage?: OutlineBudgetStage;
           } = {},
         ): Promise<{ text: string; record: AgentRunRecord; error?: Error; reasoning_content: string }> => {
           const { agentConfig, registry } = buildConfigForSkillNames(
             optionsForRun.skillNames,
             optionsForRun.disableWriteTools,
+            optionsForRun.budgetStage,
           );
           let runText = "";
           let runReasoningContent = "";
@@ -2167,6 +2186,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                   skillNames: options.preferredSkillNames,
                   disableWriteTools: true,
                   streamToUser: false,
+                  budgetStage: "analysis",
                 });
                 if (charRun.error) {
                   throw new Error(charRun.error.message);
@@ -2239,6 +2259,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               skillNames: [],
               disableWriteTools: true,
               statusText: "正在动态规划 Agent 任务…",
+              budgetStage: "analysis",
             });
             const dynamicPlan = parseDynamicOutlinePlan(
               plannerRun.text,
@@ -2852,6 +2873,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         toast.error("请先在设置中配置并选择一个可用的 AI 模型。");
         return;
       }
+      const resumeRequestBudget = planOutlineRequestBudget({
+        maxContextSize: effectiveLlmConfig.maxContextSize,
+        contextTokenBudget: novelConfig.contextTokenBudget,
+        stage: "generation",
+      });
 
       const capturedConvId = activeConversationId;
       const runId = crypto.randomUUID();
@@ -2880,7 +2906,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               content: message.content,
             })),
             existingSummary: conv.contextSummary,
-            tokenBudget: novelConfig.contextTokenBudget,
+            tokenBudget: resumeRequestBudget.contextTokenBudget,
             maxContextSize: effectiveLlmConfig.maxContextSize,
           });
           if (contextHubResult) {
@@ -2953,6 +2979,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               ...c,
               requestOverrides: {
                 ...c.requestOverrides,
+                max_tokens: resumeRequestBudget.outputTokens,
                 userMemorySurface: "ai-outline" as const,
                 userMemoryProjectKey: normalizePath(project.path),
                 userMemorySessionKey: capturedConvId,
@@ -3249,6 +3276,11 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         const historyMessages = regenerationInput.history satisfies AgentMessage[];
         let contextHubSnapshot: ContextHubSnapshotRef | undefined;
         let contextHubResult: ContextHubResult | null = null;
+        const regenerationRequestBudget = planOutlineRequestBudget({
+          maxContextSize: effectiveLlmConfig.maxContextSize,
+          contextTokenBudget: novelConfig.contextTokenBudget,
+          stage: "generation",
+        });
         try {
           const contextHub = getContextHub(normalizePath(project.path));
           contextHubResult = await contextHub.prepare({
@@ -3259,7 +3291,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             intent: "generate",
             messages: historyMessages,
             existingSummary: undefined,
-            tokenBudget: novelConfig.contextTokenBudget,
+            tokenBudget: regenerationRequestBudget.contextTokenBudget,
             maxContextSize: effectiveLlmConfig.maxContextSize,
           });
           if (contextHubResult && isCurrentRun()) {
@@ -3340,6 +3372,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         );
         agentConfig.requestOverrides = {
           ...agentConfig.requestOverrides,
+          max_tokens: regenerationRequestBudget.outputTokens,
           userMemorySurface: "ai-outline",
           userMemoryProjectKey: normalizePath(project.path),
           userMemorySessionKey: capturedConvId,

+ 3 - 1
src/i18n/en.json

@@ -760,6 +760,8 @@
       "llm": {
         "title": "Large Language / LLM Model",
         "description": "Each provider has an independent configuration. Turning one on makes it the active provider and turns the others off. Edits save immediately, and API keys remain separate per provider.",
+        "longWritingContextTitle": "Chapter and outline writing requires at least a 200K context window",
+        "longWritingContextHint": "The exact minimum is 204800 characters (about 51200 tokens). Every built-in provider now defaults to at least this value, and built-in model lists exclude models whose real window is too small. Verify custom or manually entered models against the server's actual limit.",
         "expand": "Expand configuration",
         "collapse": "Collapse",
         "toggleOff": "Disable this provider",
@@ -1335,7 +1337,7 @@
       "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.",
       "contextTokenBudget": "Context Token Budget",
       "contextTokenBudgetHint": "0 = auto from model window",
-      "contextTokenBudgetHelp": "Limits context tokens injected into one novel writing or extraction call. 0 means no extra limit: the budget is derived from a safe fraction of the model context window. Deep chapter writing first reserves output headroom as max(2× chapter target at CJK density, chapter maxOutputTokens), then allocates the context pack.",
+      "contextTokenBudgetHelp": "Limits context tokens injected into one novel writing or extraction call. 0 derives the budget from the model window. Chapter and outline workflows reserve system prompts, tools, and the actual output limit before allocating the remaining budget to the context pack.",
       "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.",

+ 3 - 1
src/i18n/zh.json

@@ -467,6 +467,8 @@
       "llm": {
         "title": "大语言/LLM模型",
         "description": "配置不同大语言模型服务商的 API Key、模型和连接方式。自定义模型保留在最上方,内置预设模型在下方可单独启用。",
+        "longWritingContextTitle": "正文和大纲写作要求至少 200K 上下文",
+        "longWritingContextHint": "最低精确值为 204800 字符(约 51200 tokens)。所有内置供应商默认值均不低于该值,内置模型列表已移除真实窗口不足的模型。自定义或手动输入的模型请确认服务端真实支持该窗口。",
         "expand": "展开配置",
         "collapse": "收起配置",
         "toggleOff": "停用此模型",
@@ -1204,7 +1206,7 @@
       "searchTopKHint": "控制从小说记忆库中检索并注入上下文的相关资料条数。数值越大,参考资料越多,但噪音和上下文占用也会增加。",
       "contextTokenBudget": "上下文 Token 预算",
       "contextTokenBudgetHint": "0 表示按模型窗口自动计算",
-      "contextTokenBudgetHelp": "限制一次小说写作或资料提取时可注入的上下文 Token 数量,避免提示词过长。设置为 0 时不额外限制,按模型上下文窗口的安全比例自动计算。深度写作会先按「单章目标字数×2(按中文密度折算)与章节 maxOutputTokens 取较大值」预留正文输出,再分配资料包。",
+      "contextTokenBudgetHelp": "限制一次小说写作或资料提取时可注入的上下文 Token 数量。设置为 0 时按模型窗口自动计算。正文和大纲工作流会统一预留系统提示、工具和实际输出空间,再把剩余预算分配给资料包。",
       "chatHistoryLength": "对话历史长度",
       "chatHistoryLengthHint": "每次请求发给 AI 的历史消息条数。多 = 上下文更完整但更费 token。",
       "chatHistoryLengthHelp": "控制 AI 会话中每次请求携带多少条历史消息。数量越多,AI 记得的上下文越完整,但消耗的 token 也越多。",

+ 6 - 3
src/lib/agent/runner.ts

@@ -16,7 +16,7 @@ import {
 import { getEffectiveMaxContextSize, type ChatMessage } from "../llm-providers"
 import { isReasoningDisabled, isReasoningOnlyResponseError, withReasoningDisabled } from "../reasoning-retry"
 import { addLlmUsage } from "../llm-usage"
-import { trimChatMessagesToBudget } from "../chat-request-budget"
+import { trimChatMessagesToTokenBudget } from "../chat-request-budget"
 import { logReasoningReplay } from "../reasoning-replay-debug"
 import { ToolEvidenceLedger } from "./tool-evidence-ledger"
 import {
@@ -169,8 +169,11 @@ export class AgentRunner {
       }
       const streamRound = async () => {
         const effectiveContext = getEffectiveMaxContextSize(config.llmConfig)
-        const internalBudget = Math.max(1, Math.floor(effectiveContext * 0.75))
-        const compacted = trimChatMessagesToBudget(workingMessages as ChatMessage[], internalBudget) as AgentMessage[]
+        const internalBudget = Math.max(1, Math.floor((effectiveContext / 4) * 0.75))
+        const compacted = trimChatMessagesToTokenBudget(
+          workingMessages as ChatMessage[],
+          internalBudget,
+        ) as AgentMessage[]
         workingMessages.splice(0, workingMessages.length, ...compacted)
         await streamChat(
           config.llmConfig,

+ 54 - 1
src/lib/chat-request-budget.test.ts

@@ -1,6 +1,12 @@
 import { describe, expect, it } from "vitest"
 import type { ChatMessage } from "./llm-client"
-import { trimChatMessagesToBudget } from "./chat-request-budget"
+import {
+  estimateChatMessagesTokens,
+  estimateRequestScaffoldTokens,
+  trimChatMessagesToBudget,
+  trimChatMessagesToTokenBudget,
+} from "./chat-request-budget"
+import { LlmContextBudgetError } from "./context-budget"
 
 function text(length: number, char = "x"): string {
   return char.repeat(length)
@@ -117,3 +123,50 @@ describe("trimChatMessagesToBudget", () => {
     expect(trimmed.at(-1)?.content).toBe("现在只回答新的问题")
   })
 })
+
+describe("trimChatMessagesToTokenBudget", () => {
+  it("uses CJK-aware estimation for Chinese, English, and mixed content", () => {
+    const chinese = [{ role: "user" as const, content: "中".repeat(100) }]
+    const english = [{ role: "user" as const, content: "a".repeat(100) }]
+    const mixed = [{ role: "user" as const, content: `${"中".repeat(50)}${"a".repeat(100)}` }]
+    expect(estimateChatMessagesTokens(chinese)).toBeGreaterThan(estimateChatMessagesTokens(english))
+    expect(estimateChatMessagesTokens(mixed)).toBeGreaterThan(estimateChatMessagesTokens(english))
+    expect(estimateChatMessagesTokens(mixed)).toBeLessThan(estimateChatMessagesTokens(chinese))
+  })
+
+  it("counts tool schema as request scaffold", () => {
+    const small = estimateRequestScaffoldTokens([{ type: "function", function: { name: "read" } }])
+    const large = estimateRequestScaffoldTokens([{
+      type: "function",
+      function: {
+        name: "read",
+        description: "读取资料".repeat(100),
+        parameters: { type: "object", properties: { path: { type: "string" } } },
+      },
+    }])
+    expect(small).toBeGreaterThan(0)
+    expect(large).toBeGreaterThan(small)
+  })
+
+  it("drops old tool protocol groups and preserves non-empty system/current user content", () => {
+    const messages: ChatMessage[] = [
+      { role: "system", content: `系统约束:${"规则".repeat(500)}` },
+      { role: "assistant", content: "", tool_calls: [{ id: "old", type: "function", function: { name: "read", arguments: "{}" } }] },
+      { role: "tool", content: "旧结果".repeat(1_000), tool_call_id: "old", name: "read" },
+      { role: "user", content: `任务目标:${"正文".repeat(1_000)}结尾限制:保持人物关系。` },
+    ]
+    const trimmed = trimChatMessagesToTokenBudget(messages, 500)
+    expect(estimateChatMessagesTokens(trimmed)).toBeLessThanOrEqual(500)
+    expect(trimmed.some((message) => message.tool_call_id === "old")).toBe(false)
+    expect(String(trimmed[0]?.content).trim()).not.toBe("")
+    expect(String(trimmed.at(-1)?.content)).toContain("任务目标")
+    expect(String(trimmed.at(-1)?.content)).toContain("保持人物关系")
+  })
+
+  it("fails explicitly instead of emptying protected messages", () => {
+    expect(() => trimChatMessagesToTokenBudget([
+      { role: "system", content: "必须遵守约束" },
+      { role: "user", content: "生成第一卷完整大纲" },
+    ], 5)).toThrow(LlmContextBudgetError)
+  })
+})

+ 259 - 0
src/lib/chat-request-budget.ts

@@ -1,4 +1,6 @@
 import type { ChatMessage, ContentBlock } from "./llm-providers"
+import { LlmContextBudgetError } from "./context-budget"
+import { estimateContextTokens } from "./context-hub/token-estimator"
 
 const HISTORY_TRUNCATED_MARKER = "[history truncated]\n"
 const CONTENT_TRUNCATED_MARKER = "\n[内容已压缩,保留首尾]\n"
@@ -121,6 +123,263 @@ function groupHistory(messages: ChatMessage[]): ChatMessage[][] {
   return groups
 }
 
+const MESSAGE_OVERHEAD_TOKENS = 4
+
+function contentTokenLength(content: ChatMessage["content"]): number {
+  if (typeof content === "string") return estimateContextTokens(content)
+  return content.reduce((sum, block) => {
+    if (block.type === "text") return sum + estimateContextTokens(block.text)
+    return sum + estimateContextTokens(block.dataBase64)
+  }, 0)
+}
+
+function toolMetadataTokens(message: ChatMessage): number {
+  const toolCallTokens = message.tool_calls?.reduce(
+    (sum, call) => sum + estimateContextTokens(
+      `${call.id}\n${call.function.name}\n${call.function.arguments}`,
+    ),
+    0,
+  ) ?? 0
+  return toolCallTokens + estimateContextTokens(
+    `${message.tool_call_id ?? ""}\n${message.name ?? ""}`,
+  )
+}
+
+function messageTokenLength(message: ChatMessage): number {
+  return MESSAGE_OVERHEAD_TOKENS + contentTokenLength(message.content) + toolMetadataTokens(message)
+}
+
+function minimumMessageTokenLength(message: ChatMessage, requireContent: boolean): number {
+  const fixedMetadataTokens = MESSAGE_OVERHEAD_TOKENS + estimateContextTokens(
+    `${message.tool_call_id ?? ""}\n${message.name ?? ""}`,
+  )
+  const minimumToolTokens = message.tool_calls?.reduce(
+    (sum, call) => sum + estimateContextTokens(`${call.id}\n${call.function.name}\n{}`),
+    0,
+  ) ?? 0
+  return fixedMetadataTokens + minimumToolTokens + (requireContent ? 1 : 0)
+}
+
+export function estimateChatMessagesTokens(messages: ChatMessage[]): number {
+  return messages.reduce((sum, message) => sum + messageTokenLength(message), 0)
+}
+
+export function estimateRequestScaffoldTokens(tools: unknown): number {
+  if (!tools) return 0
+  try {
+    return estimateContextTokens(JSON.stringify(tools))
+  } catch {
+    return 0
+  }
+}
+
+function clampTextToTokenBudget(
+  text: string,
+  maxTokens: number,
+  preserveHead: boolean,
+): string {
+  if (estimateContextTokens(text) <= maxTokens) return text
+  if (maxTokens <= 0) return ""
+  let low = 0
+  let high = text.length
+  let best = ""
+  while (low <= high) {
+    const mid = Math.floor((low + high) / 2)
+    const candidate = preserveHead
+      ? clampHeadTail(text, mid)
+      : clampTail(text, mid)
+    if (estimateContextTokens(candidate) <= maxTokens) {
+      best = candidate
+      low = mid + 1
+    } else {
+      high = mid - 1
+    }
+  }
+  return best
+}
+
+function trimContentToTokenBudget(
+  content: ChatMessage["content"],
+  maxTokens: number,
+  preserveHead = false,
+): ChatMessage["content"] {
+  if (typeof content === "string") {
+    return clampTextToTokenBudget(content, maxTokens, preserveHead)
+  }
+  let remaining = maxTokens
+  const reversed: ContentBlock[] = []
+  for (let index = content.length - 1; index >= 0; index -= 1) {
+    const block = content[index]
+    if (!block) continue
+    if (block.type !== "text") {
+      const tokens = estimateContextTokens(block.dataBase64)
+      if (tokens <= remaining) {
+        reversed.push(block)
+        remaining -= tokens
+      }
+      continue
+    }
+    const text = clampTextToTokenBudget(block.text, remaining, preserveHead)
+    if (text) {
+      reversed.push({ ...block, text })
+      remaining -= estimateContextTokens(text)
+    }
+    if (remaining <= 0) break
+  }
+  return reversed.reverse()
+}
+
+function trimMessageToTokenBudget(
+  message: ChatMessage,
+  maxTokens: number,
+  preserveHead = false,
+): ChatMessage {
+  const fixedMetadataTokens = MESSAGE_OVERHEAD_TOKENS + estimateContextTokens(
+    `${message.tool_call_id ?? ""}\n${message.name ?? ""}`,
+  )
+  const originalToolCalls = message.tool_calls
+  const minimumToolTokens = originalToolCalls?.reduce(
+    (sum, call) => sum + estimateContextTokens(`${call.id}\n${call.function.name}\n{}`),
+    0,
+  ) ?? 0
+  const contentBudget = Math.max(0, maxTokens - fixedMetadataTokens - minimumToolTokens)
+  const content = trimContentToTokenBudget(message.content, contentBudget, preserveHead)
+  let remaining = Math.max(
+    0,
+    maxTokens - fixedMetadataTokens - contentTokenLength(content),
+  )
+  const toolCalls = originalToolCalls?.map((call) => {
+    const fixed = estimateContextTokens(`${call.id}\n${call.function.name}\n`)
+    const fullArguments = estimateContextTokens(call.function.arguments)
+    const argumentsValue = fixed + fullArguments <= remaining
+      ? call.function.arguments
+      : "{}"
+    remaining = Math.max(
+      0,
+      remaining - fixed - estimateContextTokens(argumentsValue),
+    )
+    return {
+      ...call,
+      function: { ...call.function, arguments: argumentsValue },
+    }
+  })
+  return {
+    ...message,
+    content,
+    ...(toolCalls ? { tool_calls: toolCalls } : {}),
+  }
+}
+
+function hasNonEmptyContent(message: ChatMessage | undefined): boolean {
+  if (!message) return false
+  if (typeof message.content === "string") return message.content.trim().length > 0
+  return message.content.some((block) => block.type !== "text" || block.text.trim().length > 0)
+}
+
+/**
+ * Token-aware request trimmer. Tool-call/result groups stay paired; system
+ * constraints and the latest user request may be shortened but never emptied.
+ */
+export function trimChatMessagesToTokenBudget(
+  messages: ChatMessage[],
+  maxTokens: number,
+): ChatMessage[] {
+  if (messages.length === 0) return messages
+  if (!Number.isFinite(maxTokens) || maxTokens <= 0) throw new LlmContextBudgetError()
+  if (estimateChatMessagesTokens(messages) <= maxTokens) return messages
+
+  const leadingSystems: ChatMessage[] = []
+  let firstNonSystem = 0
+  while (firstNonSystem < messages.length && messages[firstNonSystem]?.role === "system") {
+    leadingSystems.push(messages[firstNonSystem]!)
+    firstNonSystem += 1
+  }
+  const bodyGroups = groupHistory(messages.slice(firstNonSystem))
+  let latestUserGroup = -1
+  for (let index = bodyGroups.length - 1; index >= 0; index -= 1) {
+    if (bodyGroups[index]!.some((message) => message.role === "user")) {
+      latestUserGroup = index
+      break
+    }
+  }
+  const retainedGroups = bodyGroups.map((group, index) => ({
+    group,
+    protected: index === latestUserGroup || index === bodyGroups.length - 1,
+  }))
+  let next = [...leadingSystems, ...retainedGroups.flatMap((entry) => entry.group)]
+
+  while (estimateChatMessagesTokens(next) > maxTokens) {
+    const removableIndex = retainedGroups.findIndex((entry) => !entry.protected)
+    if (removableIndex < 0) break
+    retainedGroups.splice(removableIndex, 1)
+    next = [...leadingSystems, ...retainedGroups.flatMap((entry) => entry.group)]
+  }
+  if (estimateChatMessagesTokens(next) <= maxTokens) return next
+
+  let latestUserIndex = -1
+  for (let index = next.length - 1; index >= 0; index -= 1) {
+    if (next[index]?.role === "user") {
+      latestUserIndex = index
+      break
+    }
+  }
+
+  // Old assistant/tool payloads are expendable before protected instructions.
+  for (let index = 0; index < next.length && estimateChatMessagesTokens(next) > maxTokens; index += 1) {
+    if (index === latestUserIndex || next[index]?.role === "system") continue
+    const current = next[index]!
+    const excess = estimateChatMessagesTokens(next) - maxTokens
+    next[index] = trimMessageToTokenBudget(
+      current,
+      Math.max(
+        minimumMessageTokenLength(current, false),
+        messageTokenLength(current) - excess,
+      ),
+    )
+  }
+
+  // System messages remain non-empty and retain both ends when compressed.
+  for (let index = 0; index < next.length && estimateChatMessagesTokens(next) > maxTokens; index += 1) {
+    if (next[index]?.role !== "system") continue
+    const current = next[index]!
+    const excess = estimateChatMessagesTokens(next) - maxTokens
+    next[index] = trimMessageToTokenBudget(
+      current,
+      Math.max(
+        minimumMessageTokenLength(current, true),
+        messageTokenLength(current) - excess,
+      ),
+      true,
+    )
+  }
+
+  if (latestUserIndex >= 0 && estimateChatMessagesTokens(next) > maxTokens) {
+    const current = next[latestUserIndex]!
+    const excess = estimateChatMessagesTokens(next) - maxTokens
+    next[latestUserIndex] = trimMessageToTokenBudget(
+      current,
+      Math.max(
+        minimumMessageTokenLength(current, true),
+        messageTokenLength(current) - excess,
+      ),
+      true,
+    )
+  }
+
+  const protectedSystemsValid = messages
+    .filter((message) => message.role === "system" && hasNonEmptyContent(message))
+    .every((_message, index) => hasNonEmptyContent(next.filter((entry) => entry.role === "system")[index]))
+  const latestUserValid = latestUserIndex < 0 || hasNonEmptyContent(next[latestUserIndex])
+  if (
+    estimateChatMessagesTokens(next) > maxTokens
+    || !protectedSystemsValid
+    || !latestUserValid
+  ) {
+    throw new LlmContextBudgetError()
+  }
+  return next
+}
+
 /**
  * Trims packed chat messages by character budget before sending them to an LLM.
  * The current user request is preserved because it carries the user's latest intent.

+ 16 - 17
src/lib/context-budget.contract.spec.ts

@@ -2,11 +2,10 @@ import { readFileSync } from "node:fs"
 import { resolve } from "node:path"
 import { describe, expect, it } from "vitest"
 import {
-  computeContextBudget,
   computeNovelContextTokenBudget,
   computeWritingContextPackTokenBudget,
+  planChapterRequestBudget,
   resolveContextPackTokenBudget,
-  WRITING_OUTPUT_RESERVE_MULTIPLIER,
 } from "./context-budget"
 
 describe("context pack budget contracts", () => {
@@ -28,37 +27,36 @@ describe("context pack budget contracts", () => {
       expect(general).toBeGreaterThan(0)
       expect(writing).toBeGreaterThan(0)
       expect(general).toBeLessThanOrEqual(computeNovelContextTokenBudget(maxContextSize, 0, 1))
-      expect(writing).toBeLessThanOrEqual(general)
+      const normalizedGeneral = resolveContextPackTokenBudget({
+        maxContextSize: Math.max(204_800, maxContextSize),
+        contextTokenBudget: 0,
+        langScale: 1,
+      })
+      expect(writing).toBeLessThanOrEqual(normalizedGeneral)
     }
   })
 
-  it("writing budget collapses to zero when the window cannot fit output reserve", () => {
+  it("writing adapter migrates a legacy small window before allocating", () => {
     const writing = computeWritingContextPackTokenBudget({
       maxContextSize: 32_000,
       chapterTargetChars: 3_000,
       langScale: 1,
     })
-    expect(writing).toBe(0)
+    expect(writing).toBe(33_280)
   })
 
   it("writing pack leaves room for output-token reserve plus scaffold", () => {
     for (const chapterTargetChars of [2_000, 3_000, 6_000]) {
       for (const maxContextSize of [64_000, 204_800]) {
-        const { maxCtx } = computeContextBudget(maxContextSize, 1)
-        const packTokens = computeWritingContextPackTokenBudget({
+        const plan = planChapterRequestBudget({
           maxContextSize,
           chapterTargetChars,
           langScale: 1,
+          stage: "generation",
         })
-        const maxOutputTokens = chapterTargetChars === 3_000
-          ? 8_000
-          : Math.max(8_000, Math.ceil((chapterTargetChars + 500) * 2))
-        const targetReserveTokens = Math.ceil(
-          (chapterTargetChars * WRITING_OUTPUT_RESERVE_MULTIPLIER) / 1.7,
-        )
-        const outputReserveChars = Math.max(targetReserveTokens, maxOutputTokens) * 4
-        const scaffold = Math.max(8_000, Math.floor(maxCtx * 0.08))
-        expect(packTokens * 4 + outputReserveChars + scaffold).toBeLessThanOrEqual(maxCtx)
+        expect(
+          plan.contextTokenBudget + plan.outputTokens + plan.scaffoldReserveTokens,
+        ).toBeLessThanOrEqual(plan.windowTokens)
       }
     }
   })
@@ -77,7 +75,8 @@ describe("context pack budget contracts", () => {
 
   it("deep chapter no longer hard-codes a 32000 context budget", () => {
     const source = readFileSync(resolve(__dirname, "./novel/deep-chapter-generation.ts"), "utf8")
-    expect(source).toContain("computeWritingContextPackTokenBudget({")
+    expect(source).toContain("planChapterRequestBudget({")
+    expect(source).toContain("max_tokens: chapterGenerationBudget.outputTokens")
     expect(source).not.toContain("DEEP_CHAPTER_CONTEXT_TOKEN_BUDGET")
   })
 })

+ 102 - 0
src/lib/context-budget.test.ts

@@ -5,6 +5,10 @@ import {
   computeWritingContextPackTokenBudget,
   contextScaleForLanguage,
   resolveContextPackTokenBudget,
+  planChapterRequestBudget,
+  planLlmRequestBudget,
+  planOutlineRequestBudget,
+  LlmContextBudgetError,
   WRITING_OUTPUT_RESERVE_MULTIPLIER,
 } from "./context-budget"
 
@@ -25,6 +29,104 @@ describe("computeContextBudget", () => {
   })
 })
 
+describe("shared LLM request budget", () => {
+  it("keeps the token conservation invariant", () => {
+    const plan = planLlmRequestBudget({
+      maxContextSize: 204_800,
+      desiredOutputTokens: 16_384,
+      requestedContextTokens: 40_000,
+      scaffoldReserveTokens: 8_192,
+      minimumContextTokens: 4_000,
+    })
+    expect(plan).toMatchObject({
+      windowTokens: 51_200,
+      outputTokens: 16_384,
+      contextTokenBudget: 26_624,
+      scaffoldReserveTokens: 8_192,
+      inputTokenBudget: 34_816,
+    })
+    expect(
+      plan.outputTokens + plan.contextTokenBudget + plan.scaffoldReserveTokens,
+    ).toBeLessThanOrEqual(plan.windowTokens)
+  })
+
+  it("reduces output but never below 512 before rejecting an impossible window", () => {
+    const reduced = planLlmRequestBudget({
+      maxContextSize: 4_096,
+      desiredOutputTokens: 8_192,
+      scaffoldReserveTokens: 256,
+      minimumContextTokens: 400,
+    })
+    expect(reduced.outputTokens).toBe(512)
+    expect(reduced.contextTokenBudget).toBe(256)
+    expect(() => planLlmRequestBudget({
+      maxContextSize: 2_000,
+      desiredOutputTokens: 8_192,
+      scaffoldReserveTokens: 64,
+    })).toThrow(LlmContextBudgetError)
+  })
+})
+
+describe("outline request budget", () => {
+  it.each([
+    [204_800, 8_192, 16_384],
+    [262_143, 8_192, 16_384],
+    [262_144, 8_192, 24_576],
+    [524_287, 8_192, 24_576],
+    [524_288, 8_192, 32_768],
+    [1_000_000, 8_192, 32_768],
+  ])("uses stage tiers at window %i", (maxContextSize, analysis, generation) => {
+    expect(planOutlineRequestBudget({
+      maxContextSize,
+      stage: "analysis",
+      langScale: 1,
+    }).outputTokens).toBe(analysis)
+    expect(planOutlineRequestBudget({
+      maxContextSize,
+      stage: "generation",
+      langScale: 1,
+    }).outputTokens).toBe(generation)
+  })
+
+  it("silently raises a legacy 128K window to 204800", () => {
+    const plan = planOutlineRequestBudget({
+      maxContextSize: 128_000,
+      stage: "generation",
+      langScale: 1,
+    })
+    expect(plan.windowTokens).toBe(51_200)
+    expect(plan.outputTokens).toBe(16_384)
+  })
+})
+
+describe("chapter request budget", () => {
+  it.each([
+    [2_000, 8_000],
+    [3_000, 8_000],
+    [6_000, 13_000],
+  ])("derives generation output for %i target chars", (chapterTargetChars, outputTokens) => {
+    const plan = planChapterRequestBudget({
+      maxContextSize: 204_800,
+      chapterTargetChars,
+      stage: "generation",
+      langScale: 1,
+    })
+    expect(plan.outputTokens).toBe(outputTokens)
+    expect(
+      plan.outputTokens + plan.contextTokenBudget + plan.scaffoldReserveTokens,
+    ).toBeLessThanOrEqual(plan.windowTokens)
+  })
+
+  it("uses 4096 tokens for task analysis", () => {
+    expect(planChapterRequestBudget({
+      maxContextSize: 204_800,
+      chapterTargetChars: 3_000,
+      stage: "analysis",
+      langScale: 1,
+    }).outputTokens).toBe(4_096)
+  })
+})
+
 describe("contextScaleForLanguage", () => {
   it("keeps scale 1 for English and other non-CJK languages", () => {
     expect(contextScaleForLanguage("en")).toBe(1)

+ 154 - 39
src/lib/context-budget.ts

@@ -32,6 +32,7 @@
  */
 
 import i18n from "@/i18n"
+import { normalizeUserLlmContextSize } from "@/lib/llm-context-size"
 
 /** Result of `computeContextBudget`. All values are character counts. */
 export interface ContextBudget {
@@ -196,11 +197,152 @@ export function resolveContextPackTokenBudget(
   )
 }
 
-/** Output reserve multiplier: chapter target chars × this factor. */
+export const MIN_LLM_OUTPUT_TOKENS = 512
+
+export class LlmContextBudgetError extends Error {
+  constructor(message = "模型上下文不足:无法同时容纳系统提示、当前用户请求和最小输出空间。") {
+    super(message)
+    this.name = "LlmContextBudgetError"
+  }
+}
+
+export interface LlmRequestBudgetInput {
+  maxContextSize?: number
+  desiredOutputTokens: number
+  requestedContextTokens?: number
+  scaffoldReserveTokens: number
+  minimumContextTokens?: number
+  minimumOutputTokens?: number
+}
+
+export interface LlmRequestBudgetPlan {
+  windowTokens: number
+  outputTokens: number
+  contextTokenBudget: number
+  scaffoldReserveTokens: number
+  inputTokenBudget: number
+}
+
+function finiteNonNegative(value: number | undefined, fallback = 0): number {
+  return Number.isFinite(value) && (value as number) > 0
+    ? Math.floor(value as number)
+    : fallback
+}
+
+/** Token-domain conservation kernel shared by chapter and outline workflows. */
+export function planLlmRequestBudget(input: LlmRequestBudgetInput): LlmRequestBudgetPlan {
+  const rawWindow = Number.isFinite(input.maxContextSize) && (input.maxContextSize as number) > 0
+    ? Math.floor(input.maxContextSize as number)
+    : normalizeUserLlmContextSize(undefined)
+  const windowTokens = Math.floor(rawWindow / CHARS_PER_TOKEN)
+  const scaffoldReserveTokens = finiteNonNegative(input.scaffoldReserveTokens)
+  const minimumOutputTokens = Math.max(
+    MIN_LLM_OUTPUT_TOKENS,
+    finiteNonNegative(input.minimumOutputTokens, MIN_LLM_OUTPUT_TOKENS),
+  )
+  const desiredOutputTokens = Math.max(
+    minimumOutputTokens,
+    finiteNonNegative(input.desiredOutputTokens, minimumOutputTokens),
+  )
+  const minimumContextTokens = finiteNonNegative(input.minimumContextTokens)
+  const available = windowTokens - scaffoldReserveTokens
+  if (available < minimumOutputTokens) throw new LlmContextBudgetError()
+
+  // Keep the requested minimum context where possible, then allocate output.
+  // If both cannot fit, context is the degradable side; output never drops below 512.
+  const outputTokens = Math.min(
+    desiredOutputTokens,
+    Math.max(minimumOutputTokens, available - minimumContextTokens),
+  )
+  const remainingForContext = Math.max(0, available - outputTokens)
+  const requestedContextTokens = finiteNonNegative(input.requestedContextTokens)
+  const contextTokenBudget = requestedContextTokens > 0
+    ? Math.min(requestedContextTokens, remainingForContext)
+    : remainingForContext
+  const inputTokenBudget = windowTokens - outputTokens
+
+  return {
+    windowTokens,
+    outputTokens,
+    contextTokenBudget,
+    scaffoldReserveTokens,
+    inputTokenBudget,
+  }
+}
+
+export type ChapterBudgetStage = "analysis" | "generation"
+
+export interface PlanChapterRequestBudgetInput {
+  maxContextSize?: number
+  contextTokenBudget?: number
+  chapterTargetChars?: number
+  stage: ChapterBudgetStage
+  langScale?: number
+}
+
+function chapterMaxOutputTokens(targetChars?: number): number {
+  const target = Number.isFinite(targetChars) && (targetChars as number) > 0
+    ? Math.max(2_000, Math.min(6_000, Math.round(targetChars as number)))
+    : 3_000
+  return target === 3_000 ? 8_000 : Math.max(8_000, Math.ceil((target + 500) * 2))
+}
+
+export function planChapterRequestBudget(
+  input: PlanChapterRequestBudgetInput,
+): LlmRequestBudgetPlan {
+  const normalizedWindow = normalizeUserLlmContextSize(input.maxContextSize)
+  const genericContextCap = computeNovelContextTokenBudget(
+    normalizedWindow,
+    input.contextTokenBudget,
+    input.langScale,
+  )
+  return planLlmRequestBudget({
+    maxContextSize: normalizedWindow,
+    desiredOutputTokens: input.stage === "analysis"
+      ? 4_096
+      : chapterMaxOutputTokens(input.chapterTargetChars),
+    requestedContextTokens: genericContextCap,
+    scaffoldReserveTokens: 8_000,
+    minimumContextTokens: 2_000,
+  })
+}
+
+export type OutlineBudgetStage = "analysis" | "generation"
+
+export interface PlanOutlineRequestBudgetInput {
+  maxContextSize?: number
+  contextTokenBudget?: number
+  stage: OutlineBudgetStage
+  langScale?: number
+}
+
+export function planOutlineRequestBudget(
+  input: PlanOutlineRequestBudgetInput,
+): LlmRequestBudgetPlan {
+  const normalizedWindow = normalizeUserLlmContextSize(input.maxContextSize)
+  const desiredOutputTokens = input.stage === "analysis"
+    ? 8_192
+    : normalizedWindow < 262_144
+      ? 16_384
+      : normalizedWindow < 524_288
+        ? 24_576
+        : 32_768
+  const genericContextCap = computeNovelContextTokenBudget(
+    normalizedWindow,
+    input.contextTokenBudget,
+    input.langScale,
+  )
+  return planLlmRequestBudget({
+    maxContextSize: normalizedWindow,
+    desiredOutputTokens,
+    requestedContextTokens: genericContextCap,
+    scaffoldReserveTokens: 8_192,
+    minimumContextTokens: 4_000,
+  })
+}
+
+/** Legacy compatibility constant retained for callers/tests that compare old reserves. */
 export const WRITING_OUTPUT_RESERVE_MULTIPLIER = 2
-/** Minimum scaffold reserve for writing prompts (instructions / outline shell). */
-const WRITING_SCAFFOLD_RESERVE_FLOOR = 8_000
-const WRITING_SCAFFOLD_RESERVE_FRAC = 0.08
 
 export interface ComputeWritingContextPackTokenBudgetInput {
   maxContextSize?: number
@@ -210,45 +352,18 @@ export interface ComputeWritingContextPackTokenBudgetInput {
 }
 
 /**
- * Deep-chapter ContextPack budget: window minus output reserve (target×2)
- * and scaffold, then clamped by the general window cap / user budget.
+ * Compatibility wrapper over the shared chapter-generation budget strategy.
  */
 export function computeWritingContextPackTokenBudget(
   input: ComputeWritingContextPackTokenBudgetInput,
 ): number {
-  const langScale = input.langScale
-  const { maxCtx } = computeContextBudget(input.maxContextSize, langScale)
-  // Inline clamp mirrors resolveChapterLengthSpec without importing deep-chapter-prompts
-  // (avoids circular deps). Keep in sync with DEEP_CHAPTER 2000–6000 bounds.
-  const rawTarget = input.chapterTargetChars
-  const target = Number.isFinite(rawTarget) && (rawTarget as number) > 0
-    ? Math.max(2_000, Math.min(6_000, Math.round(rawTarget as number)))
-    : 3_000
-  // Same formula as resolveChapterLengthSpec.maxOutputTokens.
-  const maxOutputTokens = target === 3_000
-    ? 8_000
-    : Math.max(8_000, Math.ceil((target + 500) * 2))
-  // User redundancy: 2× target chars at CJK density (~1.7 chars/token), then take the
-  // larger of that vs the chapter maxOutputTokens so generation headroom is real.
-  const targetReserveTokens = Math.ceil(
-    (target * WRITING_OUTPUT_RESERVE_MULTIPLIER) / CHARS_PER_TOKEN_CJK,
-  )
-  const outputReserveTokens = Math.max(targetReserveTokens, maxOutputTokens)
-  const outputReserveChars = outputReserveTokens * CHARS_PER_TOKEN
-  const scaffoldReserveChars = Math.max(
-    WRITING_SCAFFOLD_RESERVE_FLOOR,
-    Math.floor(maxCtx * WRITING_SCAFFOLD_RESERVE_FRAC),
-  )
-  const availableChars = Math.max(0, maxCtx - outputReserveChars - scaffoldReserveChars)
-  // Do not inflate with NOVEL_CONTEXT_TOKEN_FLOOR: that would steal the output reserve
-  // on small windows. Prefer leaving room for chapter generation.
-  const derivedTokens = Math.max(0, Math.floor(availableChars / CHARS_PER_TOKEN))
-  const windowCap = computeNovelContextTokenBudget(input.maxContextSize, 0, langScale)
-  const autoTokens = Math.min(derivedTokens, windowCap)
-  if (input.contextTokenBudget && input.contextTokenBudget > 0) {
-    return Math.min(input.contextTokenBudget, autoTokens)
-  }
-  return autoTokens
+  return planChapterRequestBudget({
+    maxContextSize: input.maxContextSize,
+    contextTokenBudget: input.contextTokenBudget,
+    chapterTargetChars: input.chapterTargetChars,
+    stage: "generation",
+    langScale: input.langScale,
+  }).contextTokenBudget
 }
 
 /** Legacy single-pass outline ingest floor; kept so small windows still behave predictably. */

+ 6 - 3
src/lib/context-hub/ai-outline-integration.spec.ts

@@ -34,9 +34,12 @@ describe("AI outline context hub integration", () => {
     expect(source).toContain("contextHub.saveSnapshot(`${messageId}:${runId}`, contextHubResult)")
   })
 
-  it("passes model window size so unlimited token budget scales safely", () => {
-    expect(source).toContain("tokenBudget: novelConfig.contextTokenBudget,")
+  it("passes the shared workflow budget and model window to Context Hub", () => {
+    expect(source).toContain("tokenBudget: outlineRequestBudget.contextTokenBudget,")
+    expect(source).toContain("tokenBudget: resumeRequestBudget.contextTokenBudget,")
+    expect(source).toContain("tokenBudget: regenerationRequestBudget.contextTokenBudget,")
     expect(source).toContain("maxContextSize: effectiveLlmConfig.maxContextSize,")
-    expect(source).not.toContain("contextTokenBudget > 0")
+    expect(source).toContain("max_tokens: requestBudget.outputTokens")
+    expect(source).not.toContain("max_tokens: Math.max(")
   })
 })

+ 2 - 1
src/lib/env-llm-defaults.ts

@@ -1,4 +1,5 @@
 import type { LlmConfig, ProviderConfigs } from "@/stores/wiki-store"
+import { normalizeUserLlmContextSize } from "@/lib/llm-context-size"
 
 const trimEnv = (value: unknown): string => {
   return typeof value === "string" ? value.trim() : ""
@@ -6,7 +7,7 @@ const trimEnv = (value: unknown): string => {
 
 const readContextSize = (): number => {
   const raw = Number(trimEnv(import.meta.env.VITE_QMAI_LLM_CONTEXT_SIZE))
-  return Number.isFinite(raw) && raw > 0 ? raw : 204800
+  return normalizeUserLlmContextSize(raw)
 }
 
 export function loadEnvLlmDefault(): {

+ 58 - 14
src/lib/llm-client.ts

@@ -16,7 +16,12 @@ import {
 } from "./reasoning-replay-debug"
 import { resolveRuntimeLocalCliConfig } from "./local-cli-config"
 import { ensureCursorProxyRunning, withCursorProxyEndpoint } from "./cursor-cli-proxy"
-import { trimChatMessagesToBudget } from "./chat-request-budget"
+import {
+  estimateChatMessagesTokens,
+  estimateRequestScaffoldTokens,
+  trimChatMessagesToTokenBudget,
+} from "./chat-request-budget"
+import { LlmContextBudgetError, planLlmRequestBudget } from "./context-budget"
 import { mergeLlmUsageSnapshot, type LlmUsage } from "./llm-usage"
 import { applyGlobalUserMemoryToMessages } from "./user-memory/request-integration"
 
@@ -130,7 +135,8 @@ function parseToolCallDeltaFromLine(line: string): { index: number; id?: string;
       name: toolCall.function?.name,
       arguments: toolCall.function?.arguments,
     }
-  } catch {
+  } catch (error) {
+    if (!(error instanceof LlmContextBudgetError)) throw error
     return null
   }
 }
@@ -168,14 +174,46 @@ export async function streamChat(
   const preparedMessages = applyGlobalUserMemoryToMessages(messages, requestOverrides)
   // Apply model-specific context size minimums (e.g. DeepSeek → 1M)
   const configuredWindow = getEffectiveMaxContextSize(runtimeConfig)
-  const outputReserveChars = requestOverrides?.max_tokens
-    ? Math.max(0, requestOverrides.max_tokens * 4)
-    : Math.floor(configuredWindow * 0.15)
-  const requestInputBudget = Math.max(1, Math.min(
-    Math.floor(configuredWindow * 0.85),
-    configuredWindow - outputReserveChars,
-  ))
-  const budgetedMessages = trimChatMessagesToBudget(preparedMessages, requestInputBudget)
+  const toolScaffoldTokens = estimateRequestScaffoldTokens(requestOverrides?.tools)
+  const runtimeBudget = planLlmRequestBudget({
+    maxContextSize: configuredWindow,
+    desiredOutputTokens: requestOverrides?.max_tokens
+      ?? Math.floor(configuredWindow * 0.15 / 4),
+    scaffoldReserveTokens: toolScaffoldTokens,
+    minimumContextTokens: 64,
+  })
+  let effectiveOutputTokens = runtimeBudget.outputTokens
+  let budgetedMessages: import("./llm-providers").ChatMessage[]
+  try {
+    budgetedMessages = trimChatMessagesToTokenBudget(
+      preparedMessages,
+      runtimeBudget.inputTokenBudget - toolScaffoldTokens,
+    )
+  } catch {
+    // Protected system/current-user content did not fit beside the desired output.
+    // Retry locally with the 512-token floor; no provider request is made on failure.
+    const minimumOutputTokens = 512
+    const maximumInputTokens = runtimeBudget.windowTokens
+      - toolScaffoldTokens
+      - minimumOutputTokens
+    budgetedMessages = trimChatMessagesToTokenBudget(
+      preparedMessages,
+      maximumInputTokens,
+    )
+    effectiveOutputTokens = Math.max(
+      minimumOutputTokens,
+      Math.min(
+        runtimeBudget.outputTokens,
+        runtimeBudget.windowTokens
+          - toolScaffoldTokens
+          - estimateChatMessagesTokens(budgetedMessages),
+      ),
+    )
+  }
+  const effectiveRequestOverrides: RequestOverrides = {
+    ...requestOverrides,
+    max_tokens: effectiveOutputTokens,
+  }
   const { onToken, onDone, onError } = callbacks
   const decoder = new TextDecoder()
 
@@ -183,11 +221,11 @@ export async function streamChat(
   // HTTP. Dispatch before getProviderConfig — that function throws for
   // this provider because it has no URL/headers.
   if (runtimeConfig.provider === "claude-code") {
-    return streamViaClaudeCodeCli(runtimeConfig, budgetedMessages, callbacks, signal, requestOverrides)
+    return streamViaClaudeCodeCli(runtimeConfig, budgetedMessages, callbacks, signal, effectiveRequestOverrides)
   }
 
   if (runtimeConfig.provider === "codex-cli") {
-    return streamViaCodexCli(runtimeConfig, budgetedMessages, callbacks, signal, requestOverrides)
+    return streamViaCodexCli(runtimeConfig, budgetedMessages, callbacks, signal, effectiveRequestOverrides)
   }
 
   if (runtimeConfig.provider === "cursor-cli") {
@@ -230,7 +268,7 @@ export async function streamChat(
     const buildRequestInit = (nextMessages: import("./llm-providers").ChatMessage[]): RequestInit => ({
       method: "POST",
       headers: providerConfig.headers,
-      body: JSON.stringify(providerConfig.buildBody(nextMessages, requestOverrides)),
+      body: JSON.stringify(providerConfig.buildBody(nextMessages, effectiveRequestOverrides)),
       signal: combinedSignal,
     })
 
@@ -327,8 +365,14 @@ export async function streamChat(
       let inputLimitRetrySucceeded = false
       const inputLimit = parseInputLengthLimit(errorDetail)
       if (inputLimit) {
+        const currentInputTokens = estimateChatMessagesTokens(budgetedMessages)
+        const shrinkRatio = Math.min(1, inputLimit.maxLength / Math.max(1, inputLimit.inputLength))
+        const retryInputTokenBudget = Math.max(
+          1,
+          Math.floor(currentInputTokens * shrinkRatio * 0.85),
+        )
         const retryRequestInit = buildRequestInit(
-          trimChatMessagesToBudget(budgetedMessages, Math.floor(inputLimit.maxLength * 0.85)),
+          trimChatMessagesToTokenBudget(budgetedMessages, retryInputTokenBudget),
         )
         if (retryRequestInit.body === requestInit.body) {
           onError(new Error(inputLengthLimitMessage(inputLimit)))

+ 25 - 5
src/lib/llm-client.usage.spec.ts

@@ -1,6 +1,9 @@
 import { beforeEach, describe, expect, it, vi } from "vitest"
 import type { LlmConfig } from "@/stores/wiki-store"
 import { streamChat } from "./llm-client"
+import { estimateChatMessagesTokens } from "./chat-request-budget"
+import type { ChatMessage } from "./llm-providers"
+import { LlmContextBudgetError } from "./context-budget"
 
 const mocks = vi.hoisted(() => ({
   fetch: vi.fn(),
@@ -101,14 +104,14 @@ describe("streamChat usage", () => {
     }))
   })
 
-  it("发送前把总输入限制在模型窗口的 85%", async () => {
+  it("发送前按 token 预算裁剪并保持系统与当前请求非空", async () => {
     mocks.fetch.mockResolvedValue(new Response([
       'data: {"choices":[{"delta":{"content":"完成"}}]}',
       "data: [DONE]",
       "",
     ].join("\n"), { status: 200 }))
 
-    await streamChat({ ...config, maxContextSize: 1_000 }, [
+    await streamChat({ ...config, maxContextSize: 4_096 }, [
       { role: "system", content: "系统".repeat(450) },
       { role: "user", content: `任务目标:续写。${"正文".repeat(450)}结尾限制:保持人物关系。` },
     ], {
@@ -118,10 +121,27 @@ describe("streamChat usage", () => {
     })
 
     const request = mocks.fetch.mock.calls[0][1] as RequestInit
-    const body = JSON.parse(String(request.body)) as { messages: Array<{ content: string }> }
-    const total = body.messages.reduce((sum, message) => sum + message.content.length, 0)
-    expect(total).toBeLessThanOrEqual(850)
+    const body = JSON.parse(String(request.body)) as {
+      messages: ChatMessage[]
+      max_tokens: number
+    }
+    expect(estimateChatMessagesTokens(body.messages)).toBeLessThanOrEqual(512)
+    expect(body.max_tokens).toBe(512)
+    expect(String(body.messages[0]?.content).trim()).not.toBe("")
     expect(body.messages.at(-1)?.content).toContain("任务目标")
     expect(body.messages.at(-1)?.content).toContain("保持人物关系")
   })
+
+  it("上下文无法容纳最小输出时明确失败且不调用供应商", async () => {
+    await expect(streamChat({ ...config, maxContextSize: 2_000 }, [
+      { role: "system", content: "系统约束" },
+      { role: "user", content: "生成第一卷完整大纲" },
+    ], {
+      onToken: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    })).rejects.toBeInstanceOf(LlmContextBudgetError)
+
+    expect(mocks.fetch).not.toHaveBeenCalled()
+  })
 })

+ 37 - 0
src/lib/llm-context-size.ts

@@ -0,0 +1,37 @@
+import type { LlmConfig, ProviderConfigs, ProviderOverride } from "@/stores/wiki-store"
+
+export const MIN_USER_LLM_CONTEXT_SIZE = 204_800
+
+export function normalizeUserLlmContextSize(value: number | undefined): number {
+  if (!Number.isFinite(value) || (value as number) <= 0) {
+    return MIN_USER_LLM_CONTEXT_SIZE
+  }
+  return Math.max(MIN_USER_LLM_CONTEXT_SIZE, Math.floor(value as number))
+}
+
+export function normalizeUserLlmConfig(config: LlmConfig): LlmConfig {
+  const maxContextSize = normalizeUserLlmContextSize(config.maxContextSize)
+  return maxContextSize === config.maxContextSize
+    ? config
+    : { ...config, maxContextSize }
+}
+
+export function normalizeProviderOverride(override: ProviderOverride): ProviderOverride {
+  if (override.maxContextSize === undefined) return override
+  const maxContextSize = normalizeUserLlmContextSize(override.maxContextSize)
+  return maxContextSize === override.maxContextSize
+    ? override
+    : { ...override, maxContextSize }
+}
+
+export function normalizeProviderConfigs(configs: ProviderConfigs): ProviderConfigs {
+  let changed = false
+  const normalized = Object.fromEntries(
+    Object.entries(configs).map(([id, override]) => {
+      const next = normalizeProviderOverride(override)
+      if (next !== override) changed = true
+      return [id, next]
+    }),
+  )
+  return changed ? normalized : configs
+}

+ 29 - 0
src/lib/llm-providers.spec.ts

@@ -23,6 +23,35 @@ function requestBody(config: LlmConfig): Record<string, unknown> {
 }
 
 describe("llm provider reasoning options", () => {
+  it("keeps Anthropic thinking inside the caller's max_tokens budget", () => {
+    const body = getProviderConfig(customConfig({
+      apiMode: "anthropic_messages",
+      reasoning: { mode: "high" },
+    })).buildBody(
+      [{ role: "user", content: "请回答。" }],
+      { max_tokens: 4_096 },
+    ) as {
+      max_tokens: number
+      thinking?: { type: string; budget_tokens: number }
+    }
+
+    expect(body.max_tokens).toBe(4_096)
+    expect(body.thinking).toEqual({ type: "enabled", budget_tokens: 3_584 })
+  })
+
+  it("does not inflate an Anthropic output budget too small for explicit thinking", () => {
+    const body = getProviderConfig(customConfig({
+      apiMode: "anthropic_messages",
+      reasoning: { mode: "high" },
+    })).buildBody(
+      [{ role: "user", content: "请回答。" }],
+      { max_tokens: 512 },
+    ) as Record<string, unknown>
+
+    expect(body.max_tokens).toBe(512)
+    expect(body).not.toHaveProperty("thinking")
+  })
+
   it("replays assistant reasoning_content including empty string", () => {
     const body = getProviderConfig(customConfig()).buildBody([
       { role: "user", content: "写第一章" },

+ 7 - 6
src/lib/llm-providers.ts

@@ -936,12 +936,13 @@ function buildAnthropicBodyWithReasoning(
         : reasoning.mode === "medium"
           ? 4096
         : 8192
-  const budgetTokens = Math.max(1024, budget)
-  const minAnswerTokens = 4096
-  const minTotalTokens = budgetTokens + minAnswerTokens
-  if ((body.max_tokens as number) < minTotalTokens) {
-    body.max_tokens = minTotalTokens
-  }
+  const maxOutputTokens = body.max_tokens as number
+  const minimumAnswerTokens = 512
+  const availableThinkingTokens = maxOutputTokens - minimumAnswerTokens
+  // Anthropic requires at least 1024 thinking tokens. Never inflate max_tokens
+  // beyond the workflow plan; disable explicit thinking when it cannot fit.
+  if (availableThinkingTokens < 1024) return body
+  const budgetTokens = Math.min(Math.max(1024, budget), availableThinkingTokens)
   body.thinking = { type: "enabled", budget_tokens: budgetTokens }
   delete body.temperature
   delete body.top_p

+ 8 - 3
src/lib/novel/deep-chapter-generation.spec.ts

@@ -1211,7 +1211,7 @@ describe("runDeepChapterGeneration", () => {
     expect(capturedPrompts[1]).toContain("冷峻克制")
   })
 
-  it("does not pass app-side max_tokens limits to deep chapter model calls", async () => {
+  it("binds analysis and generation budgets to deep chapter model calls", async () => {
     const deps = createDeps()
     const overrides: Array<RequestOverrides | undefined> = []
     vi.mocked(deps.streamChat).mockImplementation(async (
@@ -1241,7 +1241,9 @@ describe("runDeepChapterGeneration", () => {
     )
 
     expect(overrides.length).toBeGreaterThan(0)
-    expect(overrides.every((item) => item?.max_tokens === undefined)).toBe(true)
+    expect(overrides.every((item) => typeof item?.max_tokens === "number")).toBe(true)
+    expect(overrides.some((item) => item?.max_tokens === 4_096)).toBe(true)
+    expect(overrides.some((item) => item?.max_tokens === 8_000)).toBe(true)
   })
 
   it("preserves configured model reasoning for chapter generation calls", async () => {
@@ -1309,7 +1311,10 @@ describe("runDeepChapterGeneration", () => {
 
     expect(result.finalContent).toContain("最终兜底正文")
     expect(overrides[0]?.reasoning).toBeUndefined()
-    expect(overrides[1]).toEqual({ reasoning: { mode: "off" } })
+    expect(overrides[1]).toEqual({
+      max_tokens: 4_096,
+      reasoning: { mode: "off" },
+    })
   })
 
   it("uses fast, standard, and strict workflow routes", async () => {

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

@@ -13,7 +13,7 @@ import {
   isReasoningOnlyResponseError,
   withReasoningDisabled,
 } from "@/lib/reasoning-retry";
-import { computeWritingContextPackTokenBudget } from "@/lib/context-budget";
+import { planChapterRequestBudget } from "@/lib/context-budget";
 import { USER_ABORT_MESSAGE, rethrowIfUserAbort, throwIfAborted } from "@/lib/user-abort";
 import {
   buildContextPack,
@@ -667,11 +667,25 @@ export async function runDeepChapterGeneration(
     : input.llmConfig.maxContextSize;
 
   // 大纲与其余上下文共用同一窗口预算:按单章目标字数×2预留输出,再分配资料包。
-  const totalContextTokenBudget = computeWritingContextPackTokenBudget({
+  const chapterAnalysisBudget = planChapterRequestBudget({
     maxContextSize: sharedContextWindow,
     contextTokenBudget: novelConfig.contextTokenBudget,
     chapterTargetChars: novelConfig.chapterTargetChars,
+    stage: "analysis",
   });
+  const chapterGenerationBudget = planChapterRequestBudget({
+    maxContextSize: sharedContextWindow,
+    contextTokenBudget: novelConfig.contextTokenBudget,
+    chapterTargetChars: novelConfig.chapterTargetChars,
+    stage: "generation",
+  });
+  const totalContextTokenBudget = chapterGenerationBudget.contextTokenBudget;
+  const analysisRequestOverrides: RequestOverrides = {
+    max_tokens: chapterAnalysisBudget.outputTokens,
+  };
+  const generationRequestOverrides: RequestOverrides = {
+    max_tokens: chapterGenerationBudget.outputTokens,
+  };
   const totalContextCharBudget =
     totalContextTokenBudget * DEEP_CHAPTER_CHARS_PER_TOKEN;
   const outlineCharCap = Math.floor(
@@ -793,7 +807,7 @@ export async function runDeepChapterGeneration(
             callbacks.onThinking?.(
               formatStageThinking("阶段2:写作任务书", partial),
             ),
-          undefined,
+          analysisRequestOverrides,
           cachePrefix,
         ),
       (value) => `写作任务书完成,约 ${countChapterChars(value)} 字。`,
@@ -867,7 +881,7 @@ export async function runDeepChapterGeneration(
             callbacks.onThinking?.(
               formatStageThinking("阶段3:正文初稿", partial),
             ),
-          undefined,
+          generationRequestOverrides,
           cachePrefix,
         ),
       (value) => `正文初稿完成,约 ${countChapterChars(value)} 字。`,
@@ -907,7 +921,7 @@ export async function runDeepChapterGeneration(
               callbacks.onThinking?.(
                 formatStageThinking("阶段3:正文扩写补足", partial),
               ),
-            undefined,
+            generationRequestOverrides,
             cachePrefix,
           ),
         (value) => `正文扩写补足完成,约 ${countChapterChars(value)} 字。`,
@@ -1205,7 +1219,7 @@ export async function runDeepChapterGeneration(
             callbacks.onThinking?.(
               formatStageThinking("阶段5:自动返修", partial),
             ),
-          undefined,
+          generationRequestOverrides,
           cachePrefix,
         ),
       (value) =>
@@ -1358,6 +1372,7 @@ export async function runDeepChapterGeneration(
             signal,
             customDeAiSkill || undefined,
             cachePrefix,
+            generationRequestOverrides,
           ),
         (value) =>
           `简单审查与去AI味完成,最终正文约 ${countChapterChars(value)} 字。`,
@@ -1706,6 +1721,7 @@ async function finalPolishChapter(
   signal?: AbortSignal,
   customDeAiSkill?: string,
   cachePrefix?: string,
+  requestOverrides?: RequestOverrides,
 ): Promise<string> {
   assertNotAborted(signal);
   callbacks.onThinking?.(
@@ -1737,7 +1753,7 @@ async function finalPolishChapter(
       callbacks.onThinking?.(
         formatStageThinking("阶段6:简单审查与去AI味", partial),
       ),
-    undefined,
+    requestOverrides,
     cachePrefix,
   );
   assertNotAborted(signal);

+ 6 - 4
src/lib/novel/model-resolver.ts

@@ -1,9 +1,10 @@
 import { useWikiStore, type LlmConfig, type NovelConfig, type ProviderOverride } from "@/stores/wiki-store"
-import { LLM_PRESETS } from "@/components/settings/llm-presets"
+import { findLlmPresetById } from "@/components/settings/llm-presets"
 import { resolveConfig } from "@/components/settings/preset-resolver"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
 import { getEffectiveMaxContextSize } from "@/lib/llm-providers"
 import { getStableAvailableModelKey, getEffectiveSavedModels } from "@/lib/llm-model-keys"
+import { normalizeUserLlmConfig } from "@/lib/llm-context-size"
 
 export type NovelTaskType = "writing" | "review" | "summary" | "extract" | "lint" | "deAi"
 
@@ -18,7 +19,8 @@ function isConfigUsable(cfg: LlmConfig, providerConfigs: Record<string, Provider
 }
 
 function withEffectiveContextSize(config: LlmConfig): LlmConfig {
-  return { ...config, maxContextSize: getEffectiveMaxContextSize(config) }
+  const normalized = normalizeUserLlmConfig(config)
+  return { ...normalized, maxContextSize: getEffectiveMaxContextSize(normalized) }
 }
 
 function toUnusableConfig(baseConfig: LlmConfig): LlmConfig {
@@ -72,7 +74,7 @@ export function resolveModelConfig(
     const modelId = targetModel.slice(slashIdx + 1)
     const override = providerConfigs[providerId]
     if (override && getEffectiveSavedModels(override).some((m) => m.model === modelId)) {
-      const template = LLM_PRESETS.find((p) => p.id === providerId) ?? LLM_PRESETS.find((p) => p.id === "custom")
+      const template = findLlmPresetById(providerId) ?? findLlmPresetById("custom")
       if (template) {
         return withEffectiveContextSize({ ...resolveConfig(template, override, baseConfig), model: modelId })
       }
@@ -82,7 +84,7 @@ export function resolveModelConfig(
   // 回退:按纯模型名匹配(兼容旧数据)
   for (const [providerId, override] of Object.entries(providerConfigs)) {
     if (getEffectiveSavedModels(override).some((m) => m.model === targetModel)) {
-      const template = LLM_PRESETS.find((p) => p.id === providerId) ?? LLM_PRESETS.find((p) => p.id === "custom")
+      const template = findLlmPresetById(providerId) ?? findLlmPresetById("custom")
       if (template) {
         return withEffectiveContextSize({ ...resolveConfig(template, override, baseConfig), model: targetModel })
       }

+ 29 - 1
src/lib/project-store.integration.test.ts

@@ -7,7 +7,7 @@
  */
 import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"
 import { realFs, createTempProject, writeFileRaw, fileExists, readFileRaw } from "@/test-helpers/fs-temp"
-import type { NovelConfig, RevisionFeedbackWindowConfig, SourceWatchConfig, RerankConfig } from "@/stores/wiki-store"
+import type { LlmConfig, NovelConfig, ProviderConfigs, RevisionFeedbackWindowConfig, SourceWatchConfig, RerankConfig } from "@/stores/wiki-store"
 import type { McpConfig } from "@/lib/mcp/config"
 
 vi.mock("@/commands/fs", () => realFs)
@@ -38,6 +38,8 @@ import {
   loadSourceWatchConfig,
   saveMcpConfig,
   loadMcpConfig,
+  loadLlmConfig,
+  loadProviderConfigs,
 } from "./project-store"
 
 let tmp: { path: string; cleanup: () => Promise<void> }
@@ -51,6 +53,32 @@ afterEach(async () => {
   await tmp.cleanup()
 })
 
+it("silently migrates legacy main and provider context sizes on load", async () => {
+  const llmConfig: LlmConfig = {
+    provider: "custom",
+    apiKey: "key",
+    model: "model-a",
+    customEndpoint: "https://example.com/v1",
+    ollamaUrl: "",
+    maxContextSize: 128_000,
+  }
+  const providerConfigs: ProviderConfigs = {
+    custom: {
+      apiKey: "key",
+      model: "model-a",
+      maxContextSize: 32_768,
+      enabled: false,
+    },
+  }
+  inMemoryStore.set("llmConfig", llmConfig)
+  inMemoryStore.set("providerConfigs", providerConfigs)
+
+  expect((await loadLlmConfig())?.maxContextSize).toBe(204_800)
+  expect((await loadProviderConfigs())?.custom?.maxContextSize).toBe(204_800)
+  expect((inMemoryStore.get("llmConfig") as LlmConfig).maxContextSize).toBe(204_800)
+  expect((inMemoryStore.get("providerConfigs") as ProviderConfigs).custom?.enabled).toBe(false)
+})
+
 function makeNovelConfig(overrides: Partial<NovelConfig> = {}): NovelConfig {
   return {
     contextTokenBudget: 200000,

+ 16 - 4
src/lib/project-store.ts

@@ -14,6 +14,10 @@ import {
 } from "@/lib/visual-style-settings"
 import { normalizePath } from "@/lib/path-utils"
 import { readFile, writeFile, fileExists } from "@/commands/fs"
+import {
+  normalizeProviderConfigs,
+  normalizeUserLlmConfig,
+} from "@/lib/llm-context-size"
 
 const RECENT_PROJECTS_KEY = "recentProjects"
 const LAST_PROJECT_KEY = "lastProject"
@@ -57,12 +61,16 @@ const ACTIVE_PRESET_KEY = "activePresetId"
 
 export async function saveLlmConfig(config: LlmConfig): Promise<void> {
   const store = await getStore()
-  await store.set(LLM_CONFIG_KEY, config)
+  await store.set(LLM_CONFIG_KEY, normalizeUserLlmConfig(config))
 }
 
 export async function loadLlmConfig(): Promise<LlmConfig | null> {
   const store = await getStore()
-  return (await store.get<LlmConfig>(LLM_CONFIG_KEY)) ?? null
+  const saved = (await store.get<LlmConfig>(LLM_CONFIG_KEY)) ?? null
+  if (!saved) return null
+  const normalized = normalizeUserLlmConfig(saved)
+  if (normalized !== saved) await store.set(LLM_CONFIG_KEY, normalized)
+  return normalized
 }
 
 export async function saveAiChatModel(model: string): Promise<void> {
@@ -105,12 +113,16 @@ export async function loadDefaultLlmModel(): Promise<string | null> {
 
 export async function saveProviderConfigs(configs: ProviderConfigs): Promise<void> {
   const store = await getStore()
-  await store.set(PROVIDER_CONFIGS_KEY, configs)
+  await store.set(PROVIDER_CONFIGS_KEY, normalizeProviderConfigs(configs))
 }
 
 export async function loadProviderConfigs(): Promise<ProviderConfigs | null> {
   const store = await getStore()
-  return (await store.get<ProviderConfigs>(PROVIDER_CONFIGS_KEY)) ?? null
+  const saved = (await store.get<ProviderConfigs>(PROVIDER_CONFIGS_KEY)) ?? null
+  if (!saved) return null
+  const normalized = normalizeProviderConfigs(saved)
+  if (normalized !== saved) await store.set(PROVIDER_CONFIGS_KEY, normalized)
+  return normalized
 }
 
 export async function saveActivePresetId(id: string | null): Promise<void> {

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

@@ -31,6 +31,10 @@ import {
 } from "@/lib/visual-style-settings"
 import { DEFAULT_AI_WORKFLOW_MODE, resolveAiWorkflowMode, type AiWorkflowMode, type LegacyAiWorkflowMode } from "@/lib/agent/workflow-mode"
 import { DEFAULT_MCP_CONFIG, type McpConfig } from "@/lib/mcp/config"
+import {
+  normalizeProviderConfigs,
+  normalizeUserLlmConfig,
+} from "@/lib/llm-context-size"
 
 const GRAPH_LABEL_MODE_KEY = "lk-graph-label-display-mode"
 const GRAPH_EDGE_COLOR_KEY = "lk-graph-edge-color"
@@ -902,14 +906,16 @@ export const useWikiStore = create<WikiState>((set) => ({
   visualStyle: readStoredVisualStyle(),
   sidebarNavConfig: readStoredSidebarNavConfig(),
 
-  setLlmConfig: (llmConfig) => set({ llmConfig }),
+  setLlmConfig: (llmConfig) => set({ llmConfig: normalizeUserLlmConfig(llmConfig) }),
   setAiChatModel: (aiChatModel) => set({ aiChatModel }),
   setAiOutlineModel: (aiOutlineModel) => set((state) => ({
     aiOutlineModel,
     aiOutlineModelRevision: state.aiOutlineModelRevision + 1,
   })),
   setDefaultLlmModel: (defaultLlmModel) => set({ defaultLlmModel }),
-  setProviderConfigs: (providerConfigs) => set({ providerConfigs }),
+  setProviderConfigs: (providerConfigs) => set({
+    providerConfigs: normalizeProviderConfigs(providerConfigs),
+  }),
   setActivePresetId: (activePresetId) => set({ activePresetId }),
   setSearchApiConfig: (searchApiConfig) => set({ searchApiConfig }),
   setMcpConfig: (mcpConfig) => set({ mcpConfig }),