Bläddra i källkod

fix: 写作流程滚动卡顿 + 大纲 AI 对话跨书泄漏 (#31)

* perf(chat): 优化写作流程窗口滚动卡顿

流式长思考只渲染尾部,去掉高频动画与 smooth 自动滚底抢滚轮。

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(build): 同步 Cargo.lock 中 qmai 版本到 3.0.1

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(outline-chat): 修复切书后对话历史跨项目泄漏

切项目时清空 outline chat store,打开项目时按路径重载,避免 A 书历史出现在 B 书。

Closes #32

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(llm): 支持按供应商关闭 Function Calling

修复 OpenAI 兼容请求泄漏 camelCase toolChoice;为 ProviderOverride
增加 functionCallingEnabled,关闭后不发送 tools/tool_choice。
接口拒绝工具调用时无 tools 重试,失败仍抛 ModelDoesNotSupportToolsError。

Closes #33

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 1 månad sedan
förälder
incheckning
7e84b4a79f

+ 1 - 1
src-tauri/Cargo.lock

@@ -5825,7 +5825,7 @@ checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
 
 [[package]]
 name = "qmai"
-version = "2.2.37"
+version = "3.0.2"
 dependencies = [
  "arrow-array",
  "arrow-schema",

+ 17 - 8
src/App.tsx

@@ -4,6 +4,7 @@ import { useWikiStore } from "@/stores/wiki-store"
 import { useReviewStore } from "@/stores/review-store"
 import { isTauri, pickDirectory } from "@/lib/platform"
 import { useChatStore } from "@/stores/chat-store"
+import { useOutlineChatStore } from "@/stores/outline-chat-store"
 import { openProject, fileExists, listDirectory, readFile } from "@/commands/fs"
 import { getLastProject, saveLastProject, loadLlmConfig, loadAiChatModel, loadDefaultLlmModel, loadLanguage, loadEmbeddingConfig, loadProviderConfigs, loadActivePresetId, loadProxyConfig, loadScheduledImportConfig, saveScheduledImportConfig, loadSourceWatchConfig, loadNovelMode, loadNovelConfig, loadRevisionFeedbackWindowConfig, loadTheme, loadMaxHistoryMessages, loadUiFontFamily, loadVisualStyle, saveLlmConfig, loadLastReadChapter, loadMcpConfig } from "@/lib/project-store"
 import { loadReviewItems, loadChatHistory, saveChatHistory, saveReviewItems } from "@/lib/persist"
@@ -66,19 +67,27 @@ function App() {
 
     try {
       const savedChat = await loadChatHistory(proj.path)
-      if (!isCurrentProject(proj)) return
-      useChatStore.getState().setLoadedRunStates(savedChat.runStates)
-      if (savedChat.conversations.length > 0) {
-        useChatStore.getState().setConversations(savedChat.conversations)
-        useChatStore.getState().setMessages(savedChat.messages)
-        const sorted = [...savedChat.conversations].sort((a, b) => b.updatedAt - a.updatedAt)
-        if (sorted[0]) {
-          useChatStore.getState().setActiveConversation(sorted[0].id)
+      if (isCurrentProject(proj)) {
+        useChatStore.getState().setLoadedRunStates(savedChat.runStates)
+        if (savedChat.conversations.length > 0) {
+          useChatStore.getState().setConversations(savedChat.conversations)
+          useChatStore.getState().setMessages(savedChat.messages)
+          const sorted = [...savedChat.conversations].sort((a, b) => b.updatedAt - a.updatedAt)
+          if (sorted[0]) {
+            useChatStore.getState().setActiveConversation(sorted[0].id)
+          }
         }
       }
     } catch (err) {
       console.warn("[startup] 加载聊天历史失败:", err)
     }
+
+    try {
+      if (!isCurrentProject(proj)) return
+      await useOutlineChatStore.getState().loadFromDisk()
+    } catch (err) {
+      console.warn("[startup] 加载大纲 AI 对话历史失败:", err)
+    }
   }
 
   async function hydrateScheduledImportAfterOpen(proj: WikiProject): Promise<void> {

+ 1 - 1
src/components/chat/agent-stage-stream.tsx

@@ -117,7 +117,7 @@ function AgentStageRow({
         </span>
       </button>
       {open && (
-        <div className="mt-1.5 max-h-80 space-y-1.5 overflow-y-auto pr-1 pl-5 text-[12px] leading-5">
+        <div className="mt-1.5 max-h-80 space-y-1.5 overflow-y-auto pr-1 pl-5 text-[12px] leading-5 [contain:content]">
           {stage.events.map((event) => (
             <AgentActivityRow key={event.id} event={event} />
           ))}

+ 27 - 9
src/components/chat/chat-message.tsx

@@ -38,6 +38,7 @@ import { ReferenceChip } from "@/components/reference/ReferenceChip";
 import type { DisplayMessage } from "@/stores/chat-store";
 import { ContextTracePanel } from "@/components/chat/context-trace-panel";
 import { ContextHubDetails } from "@/components/common/context-hub-details";
+import { getStreamingTailDisplay } from "@/components/common/streaming-display-text";
 
 import { convertLatexToUnicode } from "@/lib/latex-to-unicode";
 import { resolveMarkdownImageSrc } from "@/lib/markdown-image-resolver";
@@ -987,18 +988,32 @@ function formatThinkingForDisplay(content: string): string {
 function StreamingWorkflowBlock({ content }: { content: string }) {
   const displayContent = useMemo(() => formatThinkingForDisplay(content), [content])
   const { title } = useMemo(() => getThinkingBlockMeta(content, true), [content])
+  const display = useMemo(
+    () => getStreamingTailDisplay(displayContent, true),
+    [displayContent],
+  )
   const scrollRef = useRef<HTMLDivElement>(null)
   const userScrolledUpRef = useRef(false)
   const lastScrollTopRef = useRef(0)
+  const scrollFrameRef = useRef<number | null>(null)
 
   useEffect(() => {
     const container = scrollRef.current
-    if (!container) return
-    if (!userScrolledUpRef.current) {
-      container.scrollTop = container.scrollHeight
-      lastScrollTopRef.current = container.scrollTop
+    if (!container || userScrolledUpRef.current) return
+    if (scrollFrameRef.current != null) cancelAnimationFrame(scrollFrameRef.current)
+    scrollFrameRef.current = requestAnimationFrame(() => {
+      scrollFrameRef.current = null
+      if (!scrollRef.current || userScrolledUpRef.current) return
+      scrollRef.current.scrollTop = scrollRef.current.scrollHeight
+      lastScrollTopRef.current = scrollRef.current.scrollTop
+    })
+    return () => {
+      if (scrollFrameRef.current != null) {
+        cancelAnimationFrame(scrollFrameRef.current)
+        scrollFrameRef.current = null
+      }
     }
-  }, [displayContent])
+  }, [display.text])
 
   useEffect(() => {
     const container = scrollRef.current
@@ -1015,7 +1030,7 @@ function StreamingWorkflowBlock({ content }: { content: string }) {
       }
       lastScrollTopRef.current = currentScrollTop
     }
-    container.addEventListener("scroll", handleScroll)
+    container.addEventListener("scroll", handleScroll, { passive: true })
     return () => container.removeEventListener("scroll", handleScroll)
   }, [])
 
@@ -1027,9 +1042,12 @@ function StreamingWorkflowBlock({ content }: { content: string }) {
       </div>
       <div
         ref={scrollRef}
-        className="w-full min-w-0 max-h-72 overflow-y-auto overflow-x-hidden pr-1 text-xs text-blue-800/70 dark:text-blue-300/60 leading-relaxed whitespace-pre-wrap [overflow-wrap:anywhere]"
+        className="w-full min-w-0 max-h-72 overflow-y-auto overflow-x-hidden pr-1 text-xs text-blue-800/70 dark:text-blue-300/60 leading-relaxed whitespace-pre-wrap [overflow-wrap:anywhere] [contain:content]"
       >
-        {displayContent}
+        {display.truncated ? (
+          <div className="mb-1 text-[11px] text-blue-700/60 dark:text-blue-400/60">…上文已省略</div>
+        ) : null}
+        {display.text}
         <span className="text-blue-500"><StreamingSpinner /></span>
       </div>
     </div>
@@ -1050,7 +1068,7 @@ function WorkflowBlock({ content }: { content: string }) {
           <span className="text-[10px] text-blue-600/60 dark:text-blue-500/60">{stageCount} 个阶段</span>
         )}
       </div>
-      <div className="w-full min-w-0 max-h-72 overflow-y-auto overflow-x-hidden border-t border-blue-500/20 px-2.5 py-2 pr-1 text-xs text-blue-800/80 dark:text-blue-300/70 whitespace-pre-wrap leading-relaxed [overflow-wrap:anywhere]">
+      <div className="w-full min-w-0 max-h-72 overflow-y-auto overflow-x-hidden border-t border-blue-500/20 px-2.5 py-2 pr-1 text-xs text-blue-800/80 dark:text-blue-300/70 whitespace-pre-wrap leading-relaxed [overflow-wrap:anywhere] [contain:content]">
         {displayContent}
       </div>
     </div>

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

@@ -1184,15 +1184,13 @@ export function ChatPanel() {
   }, [activeConversationId])
 
   // Auto-scroll to bottom when messages change or streaming content updates
-  // But stop if user manually scrolled up
+  // But stop if user manually scrolled up. Use instant scroll — smooth fights the
+  // user wheel and stacks animations while tool/thinking updates fire rapidly.
   useEffect(() => {
     const container = scrollContainerRef.current
     if (!container) return
     if (!userScrolledUpRef.current) {
-      container.scrollTo({
-        top: container.scrollHeight,
-        behavior: "smooth",
-      })
+      container.scrollTop = container.scrollHeight
       lastScrollTopRef.current = container.scrollTop
     }
   }, [activeMessages, batchedStreamingContent])
@@ -1294,12 +1292,12 @@ export function ChatPanel() {
         setDeAiSkillWarningMessage("请先打开一个项目")
         return
       }
-      if (!agentSupportsTools) {
-        setDeAiSkillWarningMessage("Agent 调度模型不支持工具调用,请更换小说设置中的默认模型")
-        return
-      }
       if (!agentSkillConfigLoaded || !agentConfig) {
-        setDeAiSkillWarningMessage("Agent配置仍在加载,请稍后重试")
+        setDeAiSkillWarningMessage(
+          !agentSupportsTools
+            ? "Agent 调度模型不支持工具调用,请更换小说设置中的默认模型"
+            : "Agent配置仍在加载,请稍后重试",
+        )
         return
       }
 

+ 18 - 22
src/components/common/event-stream.tsx

@@ -16,6 +16,7 @@ interface EventStreamProps {
 function EventStreamImpl({ events, isStreaming, totalDurationMs, totalTokens }: EventStreamProps) {
   const containerRef = useRef<HTMLDivElement>(null)
   const userScrolledRef = useRef(false)
+  const scrollFrameRef = useRef<number | null>(null)
   const groupedEvents = useMemo(() => groupTimelineEvents(events), [events])
 
   const thinkingCount = events.filter((e) => e.kind === "thinking").length
@@ -33,11 +34,7 @@ function EventStreamImpl({ events, isStreaming, totalDurationMs, totalTokens }:
     const handleScroll = () => {
       const { scrollTop, scrollHeight, clientHeight } = container
       const atBottom = scrollHeight - scrollTop - clientHeight < 30
-      if (!atBottom) {
-        userScrolledRef.current = true
-      } else {
-        userScrolledRef.current = false
-      }
+      userScrolledRef.current = !atBottom
     }
 
     container.addEventListener("scroll", handleScroll, { passive: true })
@@ -53,8 +50,18 @@ function EventStreamImpl({ events, isStreaming, totalDurationMs, totalTokens }:
 
   useEffect(() => {
     if (!isStreaming || userScrolledRef.current) return
-    if (containerRef.current) {
-      containerRef.current.scrollTop = containerRef.current.scrollHeight
+    if (scrollFrameRef.current != null) cancelAnimationFrame(scrollFrameRef.current)
+    scrollFrameRef.current = requestAnimationFrame(() => {
+      scrollFrameRef.current = null
+      if (userScrolledRef.current) return
+      const container = containerRef.current
+      if (container) container.scrollTop = container.scrollHeight
+    })
+    return () => {
+      if (scrollFrameRef.current != null) {
+        cancelAnimationFrame(scrollFrameRef.current)
+        scrollFrameRef.current = null
+      }
     }
   }, [groupedEvents, isStreaming])
 
@@ -74,17 +81,12 @@ function EventStreamImpl({ events, isStreaming, totalDurationMs, totalTokens }:
     <div className="relative w-full min-w-0 max-w-full overflow-x-hidden">
       <div
         ref={containerRef}
-        className="event-stream-scroll max-h-[50vh] w-full min-w-0 max-w-full space-y-0 overflow-x-hidden overflow-y-auto py-1"
+        className="event-stream-scroll max-h-[50vh] w-full min-w-0 max-w-full space-y-0 overflow-x-hidden overflow-y-auto py-1 [contain:content]"
       >
-        {groupedEvents.map((event, idx) => {
-          const delay = Math.min(idx * 50, 300)
-          const animationStyle = {
-            animationDelay: `${delay}ms`,
-            animationFillMode: "backwards" as const,
-          }
+        {groupedEvents.map((event) => {
           if (event.kind === "thinking") {
             return (
-              <div key={`thinking-${idx}-${event.data.id}`} style={animationStyle}>
+              <div key={`thinking-${event.data.id}`}>
                 <ThinkingEvent event={event.data} />
               </div>
             )
@@ -94,12 +96,11 @@ function EventStreamImpl({ events, isStreaming, totalDurationMs, totalTokens }:
               <ToolCallGroup
                 key={getToolCallGroupRenderKey(event.data)}
                 group={event.data}
-                style={animationStyle}
               />
             )
           }
           return (
-            <div key={`tool-${event.data.id}`} style={animationStyle}>
+            <div key={`tool-${event.data.id}`}>
               <ToolCallEvent event={event.data} />
             </div>
           )
@@ -108,11 +109,6 @@ function EventStreamImpl({ events, isStreaming, totalDurationMs, totalTokens }:
         {!isStreaming && (totalDurationMs !== undefined || totalTokens !== undefined) && (
           <div
             className="mt-2 flex items-center gap-3 px-2 pt-2 border-t border-border/50 text-[11px] text-muted-foreground/70"
-            style={{
-              animationDelay: `${Math.min(groupedEvents.length * 50 + 100, 400)}ms`,
-              animationFillMode: "backwards",
-              animation: "slideInUp 300ms ease-out",
-            }}
           >
             {totalTokens !== undefined && (
               <span className="flex items-center gap-1">

+ 34 - 0
src/components/common/streaming-display-text.spec.ts

@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest"
+import {
+  STREAMING_DISPLAY_MAX_CHARS,
+  getStreamingTailDisplay,
+} from "./streaming-display-text"
+
+describe("getStreamingTailDisplay", () => {
+  it("keeps short streaming text intact", () => {
+    expect(getStreamingTailDisplay("hello", true)).toEqual({
+      text: "hello",
+      truncated: false,
+    })
+  })
+
+  it("keeps completed long text intact", () => {
+    const content = "a".repeat(STREAMING_DISPLAY_MAX_CHARS + 100)
+    expect(getStreamingTailDisplay(content, false)).toEqual({
+      text: content,
+      truncated: false,
+    })
+  })
+
+  it("truncates long streaming text to the tail", () => {
+    const head = "HEAD\n"
+    const mid = "m".repeat(STREAMING_DISPLAY_MAX_CHARS)
+    const tail = "\nTAIL"
+    const content = `${head}${mid}${tail}`
+    const result = getStreamingTailDisplay(content, true)
+    expect(result.truncated).toBe(true)
+    expect(result.text.endsWith("TAIL")).toBe(true)
+    expect(result.text.includes("HEAD")).toBe(false)
+    expect(result.text.length).toBeLessThanOrEqual(STREAMING_DISPLAY_MAX_CHARS)
+  })
+})

+ 18 - 0
src/components/common/streaming-display-text.ts

@@ -0,0 +1,18 @@
+/** 流式长文本只保留尾部进 DOM,避免流程窗口滚动时布局成本爆炸。 */
+export const STREAMING_DISPLAY_MAX_CHARS = 8_000
+
+export function getStreamingTailDisplay(
+  content: string,
+  streaming: boolean,
+  maxChars = STREAMING_DISPLAY_MAX_CHARS,
+): { text: string; truncated: boolean } {
+  if (!streaming || content.length <= maxChars) {
+    return { text: content, truncated: false }
+  }
+
+  const sliceStart = content.length - maxChars
+  const newline = content.indexOf("\n", sliceStart)
+  const start =
+    newline >= 0 && newline < sliceStart + 240 ? newline + 1 : sliceStart
+  return { text: content.slice(start), truncated: true }
+}

+ 13 - 4
src/components/common/timeline-thinking-event.tsx

@@ -1,7 +1,8 @@
-import { useState, useEffect, useRef, memo } from "react"
+import { useState, useEffect, useRef, memo, useMemo } from "react"
 import { Brain } from "lucide-react"
 import type { ThinkingEventItem } from "./timeline-types"
 import { StreamingSpinner } from "./streaming-spinner"
+import { getStreamingTailDisplay } from "./streaming-display-text"
 
 interface ThinkingEventProps {
   event: ThinkingEventItem
@@ -27,6 +28,11 @@ function ThinkingEventImpl({ event }: ThinkingEventProps) {
     wasStreamingRef.current = event.streaming
   }, [event.streaming, event.content])
 
+  const display = useMemo(
+    () => getStreamingTailDisplay(event.content, event.streaming),
+    [event.content, event.streaming],
+  )
+
   if (!event.content) return null
 
   const charCount = event.content.length
@@ -50,7 +56,7 @@ function ThinkingEventImpl({ event }: ThinkingEventProps) {
   }
 
   return (
-    <div className="px-2 py-1 animate-[slideInUp_300ms_ease-out] group">
+    <div className="px-2 py-1 group">
       <div className="flex items-start gap-2">
         <Brain aria-hidden="true" className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
         <div className="min-w-0 flex-1">
@@ -71,8 +77,11 @@ function ThinkingEventImpl({ event }: ThinkingEventProps) {
               </button>
             )}
           </div>
-          <div className="border-l-2 border-amber-400/30 pl-2.5 text-[12px] leading-5 text-foreground/75 whitespace-pre-wrap">
-            {event.content}
+          <div className="border-l-2 border-amber-400/30 pl-2.5 text-[12px] leading-5 text-foreground/75 whitespace-pre-wrap [contain:content]">
+            {display.truncated ? (
+              <div className="mb-1 text-[11px] text-muted-foreground/80">…上文已省略</div>
+            ) : null}
+            {display.text}
             {event.streaming && (
               <span className="inline-block ml-0.5 text-amber-500 dark:text-amber-400 align-text-bottom">
                 <StreamingSpinner />

+ 38 - 0
src/components/settings/preset-resolver.spec.ts

@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest"
+import { resolveConfig } from "./preset-resolver"
+import type { LlmPreset } from "./llm-presets"
+import type { LlmConfig } from "@/stores/wiki-store"
+
+const fallback: LlmConfig = {
+  provider: "openai",
+  apiKey: "",
+  model: "",
+  ollamaUrl: "http://localhost:11434",
+  customEndpoint: "",
+  maxContextSize: 131072,
+}
+
+const customPreset: LlmPreset = {
+  id: "custom",
+  label: "Custom",
+  provider: "custom",
+  baseUrl: "https://example.test/v1",
+  apiMode: "chat_completions",
+  defaultModel: "demo-model",
+}
+
+describe("resolveConfig functionCallingEnabled", () => {
+  it("defaults to enabled when override omits the flag", () => {
+    const config = resolveConfig(customPreset, { apiKey: "sk", model: "m" }, fallback)
+    expect(config.functionCallingEnabled).toBe(true)
+  })
+
+  it("passes false through to LlmConfig", () => {
+    const config = resolveConfig(
+      customPreset,
+      { apiKey: "sk", model: "m", functionCallingEnabled: false },
+      fallback,
+    )
+    expect(config.functionCallingEnabled).toBe(false)
+  })
+})

+ 7 - 0
src/components/settings/preset-resolver.ts

@@ -20,6 +20,7 @@ export function resolveConfig(
     ov.maxContextSize ?? preset.suggestedContextSize ?? fallback.maxContextSize
   const reasoning = ov.reasoning ?? { mode: "auto" as const }
   const localCliIsolation = ov.localCliIsolation === true
+  const functionCallingEnabled = ov.functionCallingEnabled !== false
   const codexCliTimeoutMinutes =
     typeof ov.codexCliTimeoutMinutes === "number" && Number.isFinite(ov.codexCliTimeoutMinutes)
       ? Math.max(1, Math.min(240, Math.floor(ov.codexCliTimeoutMinutes)))
@@ -36,6 +37,7 @@ export function resolveConfig(
       apiMode: ov.apiMode ?? preset.apiMode ?? "chat_completions",
       reasoning,
       localCliIsolation: false,
+      functionCallingEnabled,
     }
   }
 
@@ -49,6 +51,7 @@ export function resolveConfig(
       maxContextSize,
       reasoning,
       localCliIsolation: false,
+      functionCallingEnabled,
     }
   }
 
@@ -64,6 +67,7 @@ export function resolveConfig(
       maxContextSize,
       reasoning,
       localCliIsolation: false,
+      functionCallingEnabled,
     }
   }
 
@@ -82,6 +86,7 @@ export function resolveConfig(
       reasoning,
       localCliIsolation,
       codexCliTimeoutMinutes: preset.provider === "codex-cli" ? codexCliTimeoutMinutes : undefined,
+      functionCallingEnabled,
     }
   }
 
@@ -98,6 +103,7 @@ export function resolveConfig(
       apiMode: "chat_completions",
       reasoning,
       localCliIsolation: false,
+      functionCallingEnabled,
     }
   }
 
@@ -113,5 +119,6 @@ export function resolveConfig(
     maxContextSize,
     reasoning,
     localCliIsolation: false,
+    functionCallingEnabled,
   }
 }

+ 13 - 0
src/components/settings/sections/custom-provider-cards.function-calling.spec.ts

@@ -0,0 +1,13 @@
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { describe, expect, it } from "vitest"
+
+const source = readFileSync(resolve(__dirname, "custom-provider-cards.tsx"), "utf8")
+
+describe("custom provider Function Calling toggle", () => {
+  it("wires FunctionCallingControls into custom provider cards", () => {
+    expect(source).toContain("FunctionCallingControls")
+    expect(source).toContain("functionCallingEnabled")
+    expect(source).toContain("onUpdate({ functionCallingEnabled })")
+  })
+})

+ 10 - 1
src/components/settings/sections/custom-provider-cards.tsx

@@ -9,7 +9,7 @@ import { resolveConfig } from "../preset-resolver"
 import { fetchLlmModelList } from "@/lib/settings-model-list"
 import { useBatchModelTest } from "../hooks/use-batch-model-test"
 import { useTranslation } from "react-i18next"
-import { ReasoningControls } from "./llm-provider-section"
+import { FunctionCallingControls, ReasoningControls } from "./llm-provider-section"
 
 interface CustomProviderCard {
   id: string
@@ -20,6 +20,7 @@ interface CustomProviderCard {
   model: string
   maxContextSize?: number
   reasoning?: ReasoningConfig
+  functionCallingEnabled?: boolean
   enabled: boolean
   savedModels: SavedModel[]
 }
@@ -44,6 +45,7 @@ export function CustomProviderCards() {
         model: config.model || "",
         maxContextSize: config.maxContextSize,
         reasoning: config.reasoning,
+        functionCallingEnabled: config.functionCallingEnabled,
         enabled: config.enabled ?? true,
         savedModels: config.savedModels || [],
       }
@@ -95,6 +97,7 @@ export function CustomProviderCards() {
       model: updates.model ?? prev.model,
       maxContextSize: updates.maxContextSize ?? prev.maxContextSize,
       reasoning: updates.reasoning ?? prev.reasoning,
+      functionCallingEnabled: updates.functionCallingEnabled ?? prev.functionCallingEnabled,
       enabled: updates.enabled ?? prev.enabled ?? true,
       savedModels: updates.savedModels ?? prev.savedModels,
     }
@@ -231,6 +234,7 @@ function CustomProviderCardItem({
       baseUrl: card.baseUrl,
       apiMode: card.apiMode,
       maxContextSize: card.maxContextSize,
+      functionCallingEnabled: card.functionCallingEnabled,
     }
     return resolveConfig(preset, override, llmConfig)
   }, [card, llmConfig])
@@ -708,6 +712,11 @@ function CustomProviderCardItem({
             onChange={(reasoning) => onUpdate({ reasoning })}
           />
 
+          <FunctionCallingControls
+            enabled={card.functionCallingEnabled !== false}
+            onChange={(functionCallingEnabled) => onUpdate({ functionCallingEnabled })}
+          />
+
           {/* Delete */}
           <div className="flex justify-end border-t pt-3">
             <Button

+ 7 - 0
src/components/settings/sections/llm-provider-section.spec.ts

@@ -24,4 +24,11 @@ describe("LLM provider model controls", () => {
     expect(source).toContain("retryFailed((modelId)")
     expect(source).toContain("重试失败模型")
   })
+
+  it("exposes a per-provider Function Calling toggle", () => {
+    expect(source).toContain("export function FunctionCallingControls")
+    expect(source).toContain("<FunctionCallingControls")
+    expect(source).toContain("functionCallingEnabled")
+    expect(source).toContain('settings.sections.llm.functionCalling.label')
+  })
 })

+ 65 - 0
src/components/settings/sections/llm-provider-section.tsx

@@ -685,6 +685,11 @@ function PresetRow({
             onChange={(reasoning) => onChange({ reasoning })}
           />
 
+          <FunctionCallingControls
+            enabled={ov.functionCallingEnabled !== false}
+            onChange={(functionCallingEnabled) => onChange({ functionCallingEnabled })}
+          />
+
           <div className="space-y-2 rounded-md border p-3">
             <div>
               <div className="text-sm font-medium">
@@ -733,6 +738,66 @@ function PresetRow({
   )
 }
 
+export function FunctionCallingControls({
+  enabled,
+  onChange,
+}: {
+  enabled: boolean
+  onChange: (enabled: boolean) => void
+}) {
+  const { t } = useTranslation()
+  return (
+    <div
+      className={`flex items-center justify-between rounded-md border-2 p-3 transition-colors ${
+        enabled
+          ? "border-primary/40 bg-primary/5"
+          : "border-border bg-background"
+      }`}
+    >
+      <div className="min-w-0 flex-1">
+        <div className="text-sm font-medium">
+          {t("settings.sections.llm.functionCalling.label", "启用 Function Calling")}
+        </div>
+        <div className="text-xs text-muted-foreground">
+          {t(
+            "settings.sections.llm.functionCalling.hint",
+            "关闭后,使用该供应商时请求不携带 tools/tool_choice(含内置工具与 MCP),用于兼容不支持工具调用的中转或本地模型。",
+          )}
+        </div>
+      </div>
+      <button
+        type="button"
+        onClick={() => onChange(!enabled)}
+        role="switch"
+        aria-checked={enabled}
+        aria-label={t("settings.sections.llm.functionCalling.label", "启用 Function Calling")}
+        className="ml-3 flex shrink-0 items-center gap-2"
+      >
+        <span
+          className={`text-xs font-semibold ${
+            enabled ? "text-primary" : "text-muted-foreground"
+          }`}
+        >
+          {enabled
+            ? t("settings.sections.llm.functionCalling.stateOn", "ON")
+            : t("settings.sections.llm.functionCalling.stateOff", "OFF")}
+        </span>
+        <span
+          className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
+            enabled ? "bg-primary" : "bg-muted"
+          }`}
+        >
+          <span
+            className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
+              enabled ? "translate-x-4.5" : "translate-x-0.5"
+            }`}
+          />
+        </span>
+      </button>
+    </div>
+  )
+}
+
 export function ReasoningControls({
   value,
   onChange,

+ 1 - 4
src/components/sources/outline-chat-panel.tsx

@@ -1501,10 +1501,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
   useEffect(() => {
     const container = scrollRef.current;
     if (!container || userScrolledUpRef.current) return;
-    container.scrollTo({
-      top: container.scrollHeight,
-      behavior: "smooth",
-    });
+    container.scrollTop = container.scrollHeight;
     lastScrollTopRef.current = container.scrollTop;
   }, [activeMessages, streamingContent]);
 

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

@@ -270,6 +270,38 @@ describe("useAgentConfig", () => {
     await cleanup()
   }, 15000)
 
+  it("provider 关闭 Function Calling 时仍返回 config,但 tools 为空", async () => {
+    const { result, cleanup } = await renderHook("test prompt", {
+      wiki: {
+        aiChatModel: "openai/gpt-4o",
+        project: { path: "/tmp/project" } as WikiProject,
+        providerConfigs: {
+          openai: {
+            enabled: true,
+            apiKey: "test-key",
+            functionCallingEnabled: false,
+            savedModels: [{ id: "gpt-4o", name: "GPT-4o", model: "gpt-4o", createdAt: 1 }],
+          },
+        },
+      },
+      skillConfig: {
+        version: 1,
+        defaultSkillId: "built-in:comprehensive",
+        disabledSkillIds: [],
+        projectSkills: [],
+        builtInSkillOverrides: [],
+        lastChapterDeAiSkillId: null,
+      },
+    })
+
+    expect(result.config).not.toBeNull()
+    expect(result.supportsTools).toBe(false)
+    expect(result.config?.tools).toEqual([])
+    expect(result.config?.llmConfig.functionCallingEnabled).toBe(false)
+
+    await cleanup()
+  }, 15000)
+
   it("uses the default model for Agent orchestration while preserving the chat model for chapter writing", async () => {
     const providerConfigs: ProviderConfigs = {
       custom: {

+ 11 - 6
src/hooks/use-agent-config.ts

@@ -9,7 +9,7 @@ import { resolveDefaultModel, resolveModelConfig } from "@/lib/novel/model-resol
 import { runDeepChapterGeneration } from "@/lib/novel/deep-chapter-generation"
 import { normalizePath } from "@/lib/path-utils"
 import { ToolRegistry } from "@/lib/agent/registry"
-import { buildAgentConfig, modelSupportsTools } from "@/lib/agent/config"
+import { buildAgentConfig, isFunctionCallingEnabled, modelSupportsTools } from "@/lib/agent/config"
 import type { AgentConfig } from "@/lib/agent/types"
 import type { AiCapability } from "@/lib/agent/capabilities/types"
 import { buildMcpRuntime } from "@/lib/mcp/runtime"
@@ -111,9 +111,11 @@ export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => st
 
   return useMemo(() => {
     const agentLlmConfig = resolveDefaultModel(baseLlmConfig)
-    const supportsTools = modelSupportsTools(agentLlmConfig.model, agentLlmConfig.provider)
+    const modelOk = modelSupportsTools(agentLlmConfig.model, agentLlmConfig.provider)
+    const fcEnabled = isFunctionCallingEnabled(agentLlmConfig)
+    const supportsTools = modelOk && fcEnabled
 
-    if (!supportsTools || !projectPath || !skillConfigLoaded) {
+    if (!modelOk || !projectPath || !skillConfigLoaded) {
       return {
         config: null,
         registry: new ToolRegistry(),
@@ -130,10 +132,13 @@ export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => st
     const registry = new ToolRegistry()
     const wikiPath = `${normalizePath(projectPath)}/wiki`
     const novelMode = useWikiStore.getState().novelMode
-    const realMcpConnector = (mcpConfig?.servers ?? []).some((server) => server.enabled && server.command)
+    const realMcpConnector = fcEnabled
+      && (mcpConfig?.servers ?? []).some((server) => server.enabled && server.command)
       ? new RealMcpConnector(mcpConfig)
       : undefined
-    const mcpRuntime = buildMcpRuntime(mcpConfig, undefined, realMcpConnector)
+    const mcpRuntime = fcEnabled
+      ? buildMcpRuntime(mcpConfig, undefined, realMcpConnector)
+      : { mcpTools: [], mcpCapabilities: [], warnings: [] as string[] }
     const config = buildAgentConfig(agentLlmConfig.model, systemPrompt, registry, {
       wikiPath,
       getSkillConfig,
@@ -156,7 +161,7 @@ export function useAgentConfig(systemPrompt: string, getPlanBlueprint?: () => st
     return {
       config,
       registry,
-      supportsTools: true,
+      supportsTools,
       skillConfigLoaded: true,
       skillConfig,
       writingSkills,

+ 51 - 0
src/lib/agent/config.spec.ts

@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest"
+import {
+  buildAgentConfig,
+  effectiveToolsEnabled,
+  isFunctionCallingEnabled,
+  modelSupportsTools,
+} from "./config"
+import { ToolRegistry } from "./registry"
+import type { LlmConfig } from "@/stores/wiki-store"
+
+const baseLlm: LlmConfig = {
+  provider: "openai",
+  apiKey: "sk",
+  model: "gpt-4o",
+  ollamaUrl: "",
+  customEndpoint: "",
+  maxContextSize: 8192,
+}
+
+describe("function calling helpers", () => {
+  it("treats undefined functionCallingEnabled as enabled", () => {
+    expect(isFunctionCallingEnabled(baseLlm)).toBe(true)
+  })
+
+  it("respects explicit false", () => {
+    expect(isFunctionCallingEnabled({ ...baseLlm, functionCallingEnabled: false })).toBe(false)
+  })
+
+  it("combines model blacklist with provider switch", () => {
+    expect(effectiveToolsEnabled("gpt-4o", baseLlm)).toBe(true)
+    expect(effectiveToolsEnabled("o3-mini", baseLlm)).toBe(false)
+    expect(effectiveToolsEnabled("gpt-4o", { ...baseLlm, functionCallingEnabled: false })).toBe(false)
+    expect(modelSupportsTools("gpt-4o")).toBe(true)
+  })
+})
+
+describe("buildAgentConfig functionCallingEnabled", () => {
+  it("registers no tools when function calling is disabled", () => {
+    const registry = new ToolRegistry()
+    const config = buildAgentConfig("gpt-4o", "system", registry, {
+      wikiPath: "/tmp/wiki",
+      getSkillConfig: () => null,
+      getChatConversations: () => [],
+      getOutlineConversations: () => [],
+      llmConfig: { ...baseLlm, functionCallingEnabled: false },
+    })
+
+    expect(config.tools).toEqual([])
+    expect(registry.list()).toEqual([])
+  })
+})

+ 21 - 2
src/lib/agent/config.ts

@@ -45,6 +45,18 @@ export function modelSupportsTools(
   })
 }
 
+/** Provider/user switch: undefined/true keeps tools; false strips tools/tool_choice. */
+export function isFunctionCallingEnabled(llmConfig: LlmConfig): boolean {
+  return llmConfig.functionCallingEnabled !== false
+}
+
+export function effectiveToolsEnabled(
+  modelId: string,
+  llmConfig: LlmConfig,
+): boolean {
+  return modelSupportsTools(modelId, llmConfig.provider) && isFunctionCallingEnabled(llmConfig)
+}
+
 export function buildAgentConfig(
   modelId: string,
   systemPrompt: string,
@@ -52,9 +64,16 @@ export function buildAgentConfig(
   options: BuildAgentConfigOptions,
 ): AgentConfig {
   registry.clear()
-  registerAllBuiltInTools(registry, options)
+  const fcEnabled = isFunctionCallingEnabled(options.llmConfig)
+  registerAllBuiltInTools(registry, fcEnabled
+    ? options
+    : {
+        ...options,
+        enabledToolNames: [],
+        mcpTools: [],
+      })
 
-  const prompt = providerUsesTextToolCalls(options.llmConfig.provider)
+  const prompt = providerUsesTextToolCalls(options.llmConfig.provider) && fcEnabled
     ? `${systemPrompt}\n\n当需要调用工具时,请只输出一个 JSON 对象,格式为 {"name":"工具名","arguments":{...}},不要附加其他说明文字。收到工具结果后继续推理;若无需工具则直接回答。`
     : systemPrompt
 

+ 175 - 1
src/lib/agent/runner.spec.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it, vi, beforeEach } from "vitest"
-import { AgentRunner } from "./runner"
+import { AgentRunner, ModelDoesNotSupportToolsError } from "./runner"
 import { ToolRegistry } from "./registry"
 import type { AgentConfig, AgentMessage } from "./types"
 import type { Tool } from "./types"
@@ -755,6 +755,180 @@ describe("AgentRunner", () => {
     )
   })
 
+  it("omits tools when functionCallingEnabled is false on llmConfig", async () => {
+    const tool: Tool = {
+      name: "read_chapter",
+      description: "read",
+      category: "read",
+      parameters: {},
+      execute: vi.fn().mockResolvedValue("Chapter content"),
+    }
+    registry.register(tool)
+
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      cb.onToken("普通回复")
+      cb.onDone()
+    })
+
+    await runner.run(
+      {
+        maxRounds: 3,
+        tools: [tool],
+        systemPrompt: "",
+        llmConfig: { ...mockLlmConfig, functionCallingEnabled: false },
+      },
+      registry,
+      [systemMsg, userMsg],
+      { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
+      undefined,
+    )
+
+    expect(mockStreamChat).toHaveBeenCalledWith(
+      expect.objectContaining({ functionCallingEnabled: false }),
+      expect.any(Array),
+      expect.any(Object),
+      undefined,
+      undefined,
+    )
+  })
+
+  it("retries once without tools when the API rejects function calling", async () => {
+    const tool: Tool = {
+      name: "read_chapter",
+      description: "read",
+      category: "read",
+      parameters: {},
+      execute: vi.fn().mockResolvedValue("Chapter content"),
+    }
+    registry.register(tool)
+
+    mockStreamChat
+      .mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+        cb.onError(new Error("This model does not support function calling / tools"))
+      })
+      .mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+        cb.onToken("降级正文")
+        cb.onDone()
+      })
+
+    const callbacks = {
+      onText: vi.fn(),
+      onToolCall: vi.fn(),
+      onToolResult: vi.fn(),
+      onToolError: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+
+    const result = await runner.run(
+      {
+        maxRounds: 3,
+        tools: [tool],
+        systemPrompt: "",
+        llmConfig: mockLlmConfig,
+      },
+      registry,
+      [systemMsg, userMsg],
+      callbacks,
+      undefined,
+    )
+
+    expect(mockStreamChat).toHaveBeenCalledTimes(2)
+    expect(mockStreamChat.mock.calls[0][4]).toEqual(expect.objectContaining({
+      tools: expect.any(Array),
+      toolChoice: "auto",
+    }))
+    expect(mockStreamChat.mock.calls[1][4]).toBeUndefined()
+    expect(result.finalText).toBe("降级正文")
+    expect(callbacks.onError).not.toHaveBeenCalled()
+  })
+
+  it("emits ModelDoesNotSupportToolsError when tool-less retry still fails", async () => {
+    const tool: Tool = {
+      name: "read_chapter",
+      description: "read",
+      category: "read",
+      parameters: {},
+      execute: vi.fn().mockResolvedValue("Chapter content"),
+    }
+    registry.register(tool)
+
+    mockStreamChat
+      .mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+        cb.onError(new Error("does not support function calling"))
+      })
+      .mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+        cb.onError(new Error("upstream 500"))
+      })
+
+    const callbacks = {
+      onText: vi.fn(),
+      onToolCall: vi.fn(),
+      onToolResult: vi.fn(),
+      onToolError: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+
+    await runner.run(
+      {
+        maxRounds: 3,
+        tools: [tool],
+        systemPrompt: "",
+        llmConfig: mockLlmConfig,
+      },
+      registry,
+      [systemMsg, userMsg],
+      callbacks,
+      undefined,
+    )
+
+    expect(mockStreamChat).toHaveBeenCalledTimes(2)
+    expect(callbacks.onError).toHaveBeenCalledWith(expect.any(ModelDoesNotSupportToolsError))
+  })
+
+  it("does not treat unrelated unsupported errors as missing function calling", async () => {
+    const tool: Tool = {
+      name: "read_chapter",
+      description: "read",
+      category: "read",
+      parameters: {},
+      execute: vi.fn().mockResolvedValue("Chapter content"),
+    }
+    registry.register(tool)
+
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      cb.onError(new Error("Unsupported parameter: temperature"))
+    })
+
+    const callbacks = {
+      onText: vi.fn(),
+      onToolCall: vi.fn(),
+      onToolResult: vi.fn(),
+      onToolError: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    }
+
+    await runner.run(
+      {
+        maxRounds: 3,
+        tools: [tool],
+        systemPrompt: "",
+        llmConfig: mockLlmConfig,
+      },
+      registry,
+      [systemMsg, userMsg],
+      callbacks,
+      undefined,
+    )
+
+    expect(mockStreamChat).toHaveBeenCalledTimes(1)
+    expect(callbacks.onError).toHaveBeenCalledWith(expect.objectContaining({
+      message: "Unsupported parameter: temperature",
+    }))
+  })
+
   it("retries a reasoning-only model round once with reasoning disabled", async () => {
     const reasoningOnlyError = new Error("模型只输出了 543 字符的思考内容,但没有输出正文。")
     mockStreamChat.mockImplementationOnce(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {

+ 44 - 8
src/lib/agent/runner.ts

@@ -1,6 +1,6 @@
 import { streamChat } from "../llm-client"
 import type { StreamCallbacks } from "../llm-client"
-import { providerUsesTextToolCalls } from "./config"
+import { isFunctionCallingEnabled, providerUsesTextToolCalls } from "./config"
 import { accumulateToolCalls, parseTextToolCalls } from "./tool-call-parser"
 import { toOpenAITools } from "./tools-schema"
 import type { ToolRegistry } from "./registry"
@@ -140,12 +140,22 @@ export class AgentRunner {
         },
       }
 
-      const openaiTools = config.tools.length > 0 ? toOpenAITools(config.tools) : undefined
+      const toolsAllowed = isFunctionCallingEnabled(config.llmConfig) && config.tools.length > 0
+      let openaiTools = toolsAllowed ? toOpenAITools(config.tools) : undefined
+      let attemptedToolsFallback = false
       const buildRequestOverrides = (baseOverrides = config.requestOverrides) =>
         openaiTools
           ? { ...baseOverrides, tools: openaiTools as any, toolChoice: "auto" as const }
           : baseOverrides
       let requestOverrides = buildRequestOverrides()
+      const isToolUnsupportedError = (err: unknown) => {
+        const msg = err instanceof Error ? err.message : String(err)
+        return /function[\s_.-]*call|tool_choice|tools?\s+(?:is|are)\s+not\s+supported|does\s+not\s+support\s+(?:function|tools?)|unsupported\s+(?:function|tools?|tool_choice)|不支持\s*(?:工具|function\s*call|FunctionCall)/i.test(msg)
+      }
+      const failToolsUnsupported = () => {
+        callbacks.onError(new ModelDoesNotSupportToolsError())
+        return record
+      }
       const streamRound = async () => {
         const internalBudget = Math.max(1, Math.floor((config.llmConfig.maxContextSize || 204_800) * 0.75))
         const compacted = trimChatMessagesToBudget(workingMessages as ChatMessage[], internalBudget) as AgentMessage[]
@@ -158,17 +168,40 @@ export class AgentRunner {
           requestOverrides,
         )
       }
+      const retryWithoutTools = async () => {
+        attemptedToolsFallback = true
+        openaiTools = undefined
+        roundText = ""
+        toolCallDeltas.length = 0
+        streamError = undefined
+        requestOverrides = buildRequestOverrides(config.requestOverrides)
+        await streamRound()
+      }
       try {
         await streamRound()
       } catch (err) {
-        const msg = err instanceof Error ? err.message : String(err)
-        if (openaiTools && /tool|function.?call|unsupported|不支持工具/i.test(msg)) {
-          const modelErr = new ModelDoesNotSupportToolsError()
-          callbacks.onError(modelErr)
+        if (openaiTools && isToolUnsupportedError(err)) {
+          try {
+            await retryWithoutTools()
+          } catch {
+            return failToolsUnsupported()
+          }
+        } else {
+          callbacks.onError(err instanceof Error ? err : new Error(String(err)))
           return record
         }
-        callbacks.onError(err instanceof Error ? err : new Error(String(err)))
-        return record
+      }
+
+      if (
+        streamError &&
+        openaiTools &&
+        isToolUnsupportedError(streamError)
+      ) {
+        try {
+          await retryWithoutTools()
+        } catch {
+          return failToolsUnsupported()
+        }
       }
 
       if (
@@ -189,6 +222,9 @@ export class AgentRunner {
       }
 
       if (streamError) {
+        if (attemptedToolsFallback) {
+          return failToolsUnsupported()
+        }
         callbacks.onError(streamError)
         return record
       }

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

@@ -84,6 +84,30 @@ describe("internal request overrides", () => {
     expect(serialized).not.toContain("userMemoryProjectKey")
     expect(serialized).not.toContain("userMemorySessionKey")
   })
+
+  it("sends snake_case tool_choice without leaking camelCase toolChoice", () => {
+    const tools = [{
+      type: "function",
+      function: {
+        name: "read_chapter",
+        description: "read",
+        parameters: { type: "object", properties: {} },
+      },
+    }]
+    const body = getProviderConfig(customConfig()).buildBody(
+      [{ role: "user", content: "测试请求" }],
+      {
+        temperature: 0.2,
+        tools,
+        toolChoice: "auto",
+      },
+    ) as Record<string, unknown>
+
+    expect(body.tools).toEqual(tools)
+    expect(body.tool_choice).toBe("auto")
+    expect(body).not.toHaveProperty("toolChoice")
+    expect(JSON.stringify(body)).not.toContain("toolChoice")
+  })
 })
 
 describe("custom provider headers", () => {

+ 15 - 2
src/lib/llm-providers.ts

@@ -477,13 +477,24 @@ function buildResponsesBody(
   return body
 }
 
-function stripWireAgnosticOverrides(overrides?: RequestOverrides): Omit<RequestOverrides, "reasoning" | "skipUserMemory" | "userMemorySurface" | "userMemoryProjectKey" | "userMemorySessionKey"> {
+function stripWireAgnosticOverrides(overrides?: RequestOverrides): Omit<
+  RequestOverrides,
+  | "reasoning"
+  | "skipUserMemory"
+  | "userMemorySurface"
+  | "userMemoryProjectKey"
+  | "userMemorySessionKey"
+  | "tools"
+  | "toolChoice"
+> {
   const {
     reasoning: _reasoning,
     skipUserMemory: _skipUserMemory,
     userMemorySurface: _userMemorySurface,
     userMemoryProjectKey: _userMemoryProjectKey,
     userMemorySessionKey: _userMemorySessionKey,
+    tools: _tools,
+    toolChoice: _toolChoice,
     ...rest
   } = overrides ?? {}
   return rest
@@ -563,7 +574,9 @@ function buildOpenAiCompatibleBody(
   overrides?: RequestOverrides,
 ): Record<string, unknown> {
   const reasoning = effectiveReasoning(config, overrides)
-  const body: Record<string, unknown> = buildOpenAiBody(messages, stripWireAgnosticOverrides(overrides))
+  // Pass full overrides: buildOpenAiBody strips internal/wire-agnostic
+  // fields (including tools/toolChoice) then re-emits tools + tool_choice.
+  const body: Record<string, unknown> = buildOpenAiBody(messages, overrides)
   if (
     config.provider === "openai"
     || config.provider === "azure"

+ 53 - 0
src/lib/reset-project-state.spec.ts

@@ -0,0 +1,53 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+vi.mock("@/lib/ingest-queue", () => ({
+  pauseQueue: vi.fn().mockResolvedValue(undefined),
+}))
+
+import { resetProjectStores } from "./reset-project-state"
+import { useActivityStore } from "@/stores/activity-store"
+import { useChatStore } from "@/stores/chat-store"
+import { useOutlineChatStore } from "@/stores/outline-chat-store"
+import { useReviewStore } from "@/stores/review-store"
+
+beforeEach(() => {
+  useChatStore.setState({
+    conversations: [{ id: "chat-a", title: "chat-a", createdAt: 1, updatedAt: 1, deAiMode: false }],
+    messages: [{ id: "m1", role: "user", content: "hi", timestamp: 1, conversationId: "chat-a" }],
+    activeConversationId: "chat-a",
+    streamingContents: { "chat-a": "stream" },
+  })
+  useOutlineChatStore.setState({
+    conversations: [{ id: "outline-a", title: "outline-a", createdAt: 1, updatedAt: 1, messages: [] }],
+    activeConversationId: "outline-a",
+    streamingContents: { "outline-a": "stream" },
+    runStates: { "outline-a": { status: "idle", updatedAt: 1 } },
+    pendingReferenceTokens: [{ id: "ref", category: "outline", title: "引用", displayTitle: "引用" }],
+    loaded: true,
+  })
+  useReviewStore.setState({ items: [{ id: "r1" } as never] })
+  useActivityStore.setState({ items: [{ id: "a1" } as never] })
+})
+
+describe("resetProjectStores", () => {
+  it("清空大纲 AI 会话并重置 loaded,避免切书后残留历史", () => {
+    resetProjectStores()
+
+    expect(useOutlineChatStore.getState()).toMatchObject({
+      conversations: [],
+      activeConversationId: null,
+      streamingContents: {},
+      runStates: {},
+      pendingReferenceTokens: [],
+      loaded: false,
+    })
+    expect(useChatStore.getState()).toMatchObject({
+      conversations: [],
+      messages: [],
+      activeConversationId: null,
+      streamingContents: {},
+    })
+    expect(useReviewStore.getState().items).toEqual([])
+    expect(useActivityStore.getState().items).toEqual([])
+  })
+})

+ 10 - 0
src/lib/reset-project-state.ts

@@ -11,6 +11,7 @@
 import { pauseQueue as pauseIngestQueue } from "@/lib/ingest-queue"
 import { useActivityStore } from "@/stores/activity-store"
 import { useChatStore } from "@/stores/chat-store"
+import { useOutlineChatStore } from "@/stores/outline-chat-store"
 import { useReviewStore } from "@/stores/review-store"
 
 export function resetProjectStores(): void {
@@ -23,6 +24,15 @@ export function resetProjectStores(): void {
     streamingContents: {},
   })
 
+  useOutlineChatStore.setState({
+    conversations: [],
+    activeConversationId: null,
+    streamingContents: {},
+    runStates: {},
+    pendingReferenceTokens: [],
+    loaded: false,
+  })
+
   useReviewStore.setState({
     items: [],
   })

+ 26 - 0
src/stores/outline-chat-store.spec.ts

@@ -210,6 +210,32 @@ describe("outline-chat-store", () => {
       conversations: [], activeConversationId: null, pendingReferenceTokens: [], loaded: true,
     })
   })
+
+  it("切书后 reset 再 loadFromDisk 只加载新书会话,不残留旧书历史", async () => {
+    const { resetProjectStores } = await import("@/lib/reset-project-state")
+
+    useWikiStore.setState({ project: { id: "book-a", name: "书A", path: "C:/BookA" } })
+    useOutlineChatStore.setState({
+      conversations: [conversation("from-a")],
+      activeConversationId: "from-a",
+      loaded: true,
+    })
+
+    resetProjectStores()
+    useWikiStore.setState({ project: { id: "book-b", name: "书B", path: "C:/BookB" } })
+    fsMocks.readFile.mockResolvedValue(JSON.stringify({
+      conversations: [conversation("from-b")],
+      activeConversationId: "from-b",
+    }))
+
+    await useOutlineChatStore.getState().loadFromDisk()
+
+    const state = useOutlineChatStore.getState()
+    expect(fsMocks.readFile).toHaveBeenCalledWith("C:/BookB/.qmai/outline-chats.json")
+    expect(state.conversations.map((item) => item.id)).toEqual(["from-b"])
+    expect(state.activeConversationId).toBe("from-b")
+    expect(state.loaded).toBe(true)
+  })
   it("persists structured model content and reloads legacy messages", async () => {
     useWikiStore.setState({ project: { name: "??", path: "C:/Book" } })
     const request: OutlineWizardRequest = {

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

@@ -145,6 +145,8 @@ interface LlmConfig {
   reasoning?: ReasoningConfig
   localCliIsolation?: boolean
   codexCliTimeoutMinutes?: number
+  /** When false, Agent requests omit tools/tool_choice. Default/undefined = enabled. */
+  functionCallingEnabled?: boolean
 }
 
 export type SearchProvider = "tavily" | "serpapi" | "searxng" | "none"
@@ -450,6 +452,8 @@ export interface ProviderOverride {
   enabled?: boolean
   /** 已保存的模型列表(仅用于自定义供应商) */
   savedModels?: SavedModel[]
+  /** When false, Agent requests for this provider omit tools/tool_choice. Default/undefined = enabled. */
+  functionCallingEnabled?: boolean
 }
 
 export type ProviderConfigs = Record<string, ProviderOverride>