Jelajahi Sumber

fix: DeepSeek 上下文默认提升至 1M token,修复长内容截断问题 (v3.1.2)

Mochocyang 1 bulan lalu
induk
melakukan
fa648d7f63

+ 1 - 1
package.json

@@ -1,7 +1,7 @@
 {
   "name": "qmai",
   "private": true,
-  "version": "3.1.1",
+  "version": "3.1.2",
   "license": "GPL-3.0-or-later",
   "type": "module",
   "scripts": {

+ 1 - 1
src-tauri/Cargo.lock

@@ -5869,7 +5869,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
 
 [[package]]
 name = "qmai"
-version = "3.1.0"
+version = "3.1.1"
 dependencies = [
  "arrow-array",
  "arrow-schema",

+ 1 - 1
src-tauri/Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = "qmai"
-version = "3.1.1"
+version = "3.1.2"
 description = "QMAI - AI writing system for long-form novels"
 authors = ["Mochocyang"]
 edition = "2021"

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

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

+ 1 - 1
src/components/settings/llm-presets.ts

@@ -211,7 +211,7 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
       "deepseek-chat",
       "deepseek-reasoner",
     ],
-    suggestedContextSize: 64000,
+    suggestedContextSize: 1000000,
   },
   {
     id: "atlascloud",

+ 37 - 37
src/components/settings/preset-resolver.ts

@@ -1,6 +1,7 @@
 import type { LlmConfig } from "@/stores/wiki-store"
 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"
 
 /**
@@ -16,7 +17,7 @@ export function resolveConfig(
   const ov = override ?? {}
   const apiKey = ov.apiKey ?? ""
   const model = ov.model?.trim() || preset.defaultModel || ""
-  const maxContextSize =
+  const rawMaxContextSize =
     ov.maxContextSize ?? preset.suggestedContextSize ?? fallback.maxContextSize
   const reasoning = ov.reasoning ?? { mode: "auto" as const }
   const localCliIsolation = ov.localCliIsolation === true
@@ -26,37 +27,35 @@ export function resolveConfig(
       ? Math.max(1, Math.min(240, Math.floor(ov.codexCliTimeoutMinutes)))
       : undefined
 
+  let config: LlmConfig
+
   if (preset.provider === "custom") {
-    return {
+    config = {
       provider: "custom",
       apiKey,
       model,
       ollamaUrl: fallback.ollamaUrl,
       customEndpoint: ov.baseUrl ?? preset.baseUrl ?? "",
-      maxContextSize,
+      maxContextSize: rawMaxContextSize,
       apiMode: ov.apiMode ?? preset.apiMode ?? "chat_completions",
       reasoning,
       localCliIsolation: false,
       functionCallingEnabled,
     }
-  }
-
-  if (preset.provider === "ollama") {
-    return {
+  } else if (preset.provider === "ollama") {
+    config = {
       provider: "ollama",
       apiKey: "",
       model,
       ollamaUrl: ov.baseUrl ?? preset.baseUrl ?? "http://localhost:11434",
       customEndpoint: fallback.customEndpoint,
-      maxContextSize,
+      maxContextSize: rawMaxContextSize,
       reasoning,
       localCliIsolation: false,
       functionCallingEnabled,
     }
-  }
-
-  if (preset.provider === "azure") {
-    return {
+  } else if (preset.provider === "azure") {
+    config = {
       provider: "azure",
       apiKey,
       model,
@@ -64,61 +63,62 @@ export function resolveConfig(
       customEndpoint: ov.baseUrl ?? preset.baseUrl ?? "",
       azureApiVersion: ov.azureApiVersion ?? preset.azureApiVersion ?? AZURE_OPENAI_API_VERSION,
       azureModelFamily: ov.azureModelFamily ?? preset.azureModelFamily ?? "auto",
-      maxContextSize,
+      maxContextSize: rawMaxContextSize,
       reasoning,
       localCliIsolation: false,
       functionCallingEnabled,
     }
-  }
-
-  if (preset.provider === "claude-code" || preset.provider === "codex-cli") {
+  } else if (preset.provider === "claude-code" || preset.provider === "codex-cli") {
     // Subprocess transport — no apiKey, no endpoint URL. Model id is
     // passed straight to the local CLI's model flag when the user
     // explicitly sets one. Leaving it empty lets the local CLI use the
     // machine's own configured default model.
-    return {
+    config = {
       provider: preset.provider,
       apiKey: "",
       model: ov.model?.trim() || "",
       ollamaUrl: fallback.ollamaUrl,
       customEndpoint: fallback.customEndpoint,
-      maxContextSize,
+      maxContextSize: rawMaxContextSize,
       reasoning,
       localCliIsolation,
       codexCliTimeoutMinutes: preset.provider === "codex-cli" ? codexCliTimeoutMinutes : undefined,
       functionCallingEnabled,
     }
-  }
-
-  if (preset.provider === "cursor-cli") {
+  } else if (preset.provider === "cursor-cli") {
     // HTTP bridge via cursor-api-proxy. Optional apiKey only if the
     // proxy was started with CURSOR_BRIDGE_API_KEY.
-    return {
+    config = {
       provider: "cursor-cli",
       apiKey,
       model: ov.model?.trim() || preset.defaultModel || "",
       ollamaUrl: fallback.ollamaUrl,
       customEndpoint: ov.baseUrl ?? preset.baseUrl ?? "http://127.0.0.1:8765/v1",
-      maxContextSize,
+      maxContextSize: rawMaxContextSize,
       apiMode: "chat_completions",
       reasoning,
       localCliIsolation: false,
       functionCallingEnabled,
     }
+  } else {
+    // openai / anthropic / google / minimax — use fixed endpoint baked into the
+    // provider dispatch. We still let users override baseUrl via apiKey env if
+    // needed by editing manually, but presets for these don't expose it.
+    config = {
+      provider: preset.provider,
+      apiKey,
+      model,
+      ollamaUrl: fallback.ollamaUrl,
+      customEndpoint: fallback.customEndpoint,
+      maxContextSize: rawMaxContextSize,
+      reasoning,
+      localCliIsolation: false,
+      functionCallingEnabled,
+    }
   }
 
-  // openai / anthropic / google / minimax — use fixed endpoint baked into the
-  // provider dispatch. We still let users override baseUrl via apiKey env if
-  // needed by editing manually, but presets for these don't expose it.
-  return {
-    provider: preset.provider,
-    apiKey,
-    model,
-    ollamaUrl: fallback.ollamaUrl,
-    customEndpoint: fallback.customEndpoint,
-    maxContextSize,
-    reasoning,
-    localCliIsolation: false,
-    functionCallingEnabled,
-  }
+  // Apply model-specific context size minimums (e.g. DeepSeek → 1M)
+  config.maxContextSize = getEffectiveMaxContextSize(config)
+
+  return config
 }

+ 3 - 1
src/lib/agent/plugins/trim-context-plugin.ts

@@ -1,6 +1,7 @@
 import type { PrePlugin, PrePluginInput, PrePluginOutput } from "../pipeline"
 import type { ContextPack, TrimResult } from "@/lib/novel/context-engine"
 import { resolveContextPackTokenBudget } from "@/lib/context-budget"
+import { getEffectiveMaxContextSize } from "@/lib/llm-providers"
 import { useWikiStore } from "@/stores/wiki-store"
 
 export interface TrimContextPluginDeps {
@@ -92,7 +93,8 @@ export function createTrimContextPlugin(deps: TrimContextPluginDeps = {}): PrePl
 }
 
 function resolveTokenBudget(input: PrePluginInput): number {
-  const maxContextSize = input.agentConfig?.llmConfig?.maxContextSize
+  const llmConfig = input.agentConfig?.llmConfig
+  const maxContextSize = llmConfig ? getEffectiveMaxContextSize(llmConfig) : undefined
   const contextTokenBudget = useWikiStore.getState().novelConfig?.contextTokenBudget
   return resolveContextPackTokenBudget({
     maxContextSize,

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

@@ -13,7 +13,7 @@ import {
   saveTaskBreakpoint,
   updateBreakpointStage,
 } from "./task-breakpoint"
-import type { ChatMessage } from "../llm-providers"
+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"
@@ -163,7 +163,8 @@ export class AgentRunner {
         return record
       }
       const streamRound = async () => {
-        const internalBudget = Math.max(1, Math.floor((config.llmConfig.maxContextSize || 204_800) * 0.75))
+        const effectiveContext = getEffectiveMaxContextSize(config.llmConfig)
+        const internalBudget = Math.max(1, Math.floor(effectiveContext * 0.75))
         const compacted = trimChatMessagesToBudget(workingMessages as ChatMessage[], internalBudget) as AgentMessage[]
         workingMessages.splice(0, workingMessages.length, ...compacted)
         await streamChat(

+ 16 - 0
src/lib/changelog.ts

@@ -7,6 +7,19 @@ export interface ChangelogEntry {
   };
 }
 
+const THREE_POINT_ONE_TWO_CHANGELOG: ChangelogEntry = {
+  version: "3.1.2",
+  date: "2026-08-08",
+  highlights: {
+    en: [
+      "[DeepSeek Context Fix] Fixed response truncation when using DeepSeek models. DeepSeek default context window increased to 1M (1,000,000 tokens); runtime auto-correct applies even when DeepSeek models are selected under Custom preset.",
+    ],
+    zh: [
+      "【DeepSeek 上下文截断修复】修复使用 DeepSeek 模型时回答总是被截断的问题。DeepSeek 默认上下文窗口提升至 100 万 token;即使用户在自定义预设下选择 DeepSeek 模型,运行时也会自动修正为 1M 上下文,不再截断长内容",
+    ],
+  },
+};
+
 const THREE_POINT_ZERO_NINE_CHANGELOG: ChangelogEntry = {
   version: "3.0.9",
   date: "2026-08-04",
@@ -1016,6 +1029,7 @@ function isMergedOnePointRelease(version: string): boolean {
 }
 
 export const CHANGELOG: ChangelogEntry[] = [
+  THREE_POINT_ONE_TWO_CHANGELOG,
   {
     version: "1.0.7",
     date: "2026-06-02",
@@ -1075,6 +1089,8 @@ export const CHANGELOG: ChangelogEntry[] = [
 ];
 
 export function currentVersionChangelog(version: string): ChangelogEntry[] {
+  if (version === THREE_POINT_ONE_TWO_CHANGELOG.version)
+    return [THREE_POINT_ONE_TWO_CHANGELOG];
   if (version === THREE_POINT_ZERO_NINE_CHANGELOG.version)
     return [THREE_POINT_ZERO_NINE_CHANGELOG];
   if (version === THREE_POINT_ZERO_EIGHT_CHANGELOG.version)

+ 3 - 4
src/lib/llm-client.ts

@@ -1,6 +1,6 @@
 import type { LlmConfig } from "@/stores/wiki-store"
 import { isAzureOpenAiEndpoint } from "@/lib/azure-openai"
-import { getProviderConfig, type RequestOverrides } from "./llm-providers"
+import { getEffectiveMaxContextSize, getProviderConfig, type RequestOverrides } from "./llm-providers"
 import { getHttpFetch, isFetchNetworkError } from "./tauri-fetch"
 import { countReasoningCharsInLine, extractReasoningTextFromLine } from "./reasoning-detector"
 import {
@@ -143,9 +143,8 @@ export async function streamChat(
 ): Promise<void> {
   let runtimeConfig = await resolveRuntimeLocalCliConfig(config)
   const preparedMessages = applyGlobalUserMemoryToMessages(messages, requestOverrides)
-  const configuredWindow = Number.isFinite(runtimeConfig.maxContextSize) && runtimeConfig.maxContextSize > 0
-    ? runtimeConfig.maxContextSize
-    : 204_800
+  // 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)

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

@@ -527,6 +527,25 @@ function isDeepSeekEndpoint(config: LlmConfig): boolean {
   return /deepseek/i.test(config.model) || /deepseek/i.test(config.customEndpoint)
 }
 
+/**
+ * Minimum context window for DeepSeek models. DeepSeek V3/V4 support
+ * up to 1M tokens; the previous default of 64K/200K caused response
+ * truncation on long inputs (bug report).
+ */
+const DEEPSEEK_MIN_CONTEXT_SIZE = 1_000_000
+
+/**
+ * Returns the effective maxContextSize for a given config, applying
+ * model-specific minimums. DeepSeek endpoints get bumped to at least
+ * 1M chars so long prompts aren't silently truncated.
+ */
+export function getEffectiveMaxContextSize(config: LlmConfig): number {
+  if (isDeepSeekEndpoint(config)) {
+    return Math.max(config.maxContextSize || 0, DEEPSEEK_MIN_CONTEXT_SIZE)
+  }
+  return config.maxContextSize || 204_800
+}
+
 function isQwenThinkingModel(model: string): boolean {
   return /qwen[-_]?3/i.test(model)
 }

+ 10 - 5
src/lib/novel/model-resolver.ts

@@ -2,6 +2,7 @@ import { useWikiStore, type LlmConfig, type NovelConfig, type ProviderOverride }
 import { LLM_PRESETS } 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"
 
 export type NovelTaskType = "writing" | "review" | "summary" | "extract" | "lint" | "deAi"
@@ -16,8 +17,12 @@ function isConfigUsable(cfg: LlmConfig, providerConfigs: Record<string, Provider
   return hasUsableLlm(cfg, providerConfigs)
 }
 
+function withEffectiveContextSize(config: LlmConfig): LlmConfig {
+  return { ...config, maxContextSize: getEffectiveMaxContextSize(config) }
+}
+
 function toUnusableConfig(baseConfig: LlmConfig): LlmConfig {
-  return { ...baseConfig, ...UNUSABLE_LLM_CONFIG }
+  return withEffectiveContextSize({ ...baseConfig, ...UNUSABLE_LLM_CONFIG })
 }
 
 export function isModelKeyRegistered(
@@ -69,21 +74,21 @@ export function resolveModelConfig(
     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")
       if (template) {
-        return { ...resolveConfig(template, override, baseConfig), model: modelId }
+        return withEffectiveContextSize({ ...resolveConfig(template, override, baseConfig), model: modelId })
       }
     }
-    return { ...baseConfig, model: modelId }
+    return withEffectiveContextSize({ ...baseConfig, model: modelId })
   }
   // 回退:按纯模型名匹配(兼容旧数据)
   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")
       if (template) {
-        return { ...resolveConfig(template, override, baseConfig), model: targetModel }
+        return withEffectiveContextSize({ ...resolveConfig(template, override, baseConfig), model: targetModel })
       }
     }
   }
-  return { ...baseConfig, model: targetModel }
+  return withEffectiveContextSize({ ...baseConfig, model: targetModel })
 }
 
 export function resolveUsableModelKey(