Selaa lähdekoodia

fix: 自定义 LLM 配置与「跟随模型」重启后丢失 (#72)

* fix(settings): 修复自定义 LLM 模型配置关闭后丢失

自定义模型卡片原先只在挂载时拍一次快照,写入又走 100ms 防抖且关窗不 flush。
改为从 store 派生卡片、用最新 providerConfigs 合并,并在前端关窗与原生 destroy 前强制落盘。

Co-authored-by: darknessomi <darknessomi@users.noreply.github.com>

* fix(settings): 勾选跟随聊天模型后重启不再被旧默认模型盖掉

空字符串表示跟随聊天模型,但 loadNovelConfig 每次都把空值当成未迁移,
用全局 defaultLlmModel 写回去。改为仅当旧文件缺少该字段时才迁移。

Co-authored-by: darknessomi <darknessomi@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: darknessomi <darknessomi@users.noreply.github.com>
darknessomi 1 kuukausi sitten
vanhempi
sitoutus
7a45c97cfc

+ 8 - 0
src-tauri/src/app_state.rs

@@ -251,6 +251,14 @@ pub fn persist_plugin_store<R: Runtime>(app: &AppHandle<R>) -> Result<PathBuf, S
     Ok(path)
 }
 
+/// Flush plugin-store memory to disk before the process dies.
+/// Debounced frontend writes can otherwise be lost on window destroy.
+pub fn persist_app_state_before_exit<R: Runtime>(app: &AppHandle<R>) {
+    if let Err(error) = persist_plugin_store(app) {
+        eprintln!("[app-state] 退出前持久化失败: {error}");
+    }
+}
+
 pub fn prepare_app_state_store<R: Runtime>(app: &AppHandle<R>) {
     let Ok(dir) = app.path().app_data_dir() else {
         eprintln!("[app-state] could not resolve app_data_dir");

+ 1 - 0
src-tauri/src/lib.rs

@@ -156,6 +156,7 @@ pub fn run() {
                             .blocking_show();
 
                         if confirmed {
+                            app_state::persist_app_state_before_exit(&app);
                             let _ = win.destroy();
                         }
                     });

+ 1 - 0
src-tauri/src/main.rs

@@ -158,6 +158,7 @@ fn main() {
                             .blocking_show();
 
                         if confirmed {
+                            app_state::persist_app_state_before_exit(&app);
                             let _ = win.destroy();
                         }
                     });

+ 4 - 0
src/App.tsx

@@ -10,6 +10,7 @@ import { getLastProject, saveLastProject, loadLlmConfig, loadAiChatModel, loadAi
 import { loadReviewItems, loadChatHistory, saveChatHistory, saveReviewItems } from "@/lib/persist"
 import { initializeAiOutlineModelFromStorage } from "@/lib/ai-outline-model-initialization"
 import { setupAutoSave, teardownAutoSave } from "@/lib/auto-save"
+import { flushAppState } from "@/lib/web-store"
 import { checkForAppUpdate } from "@/lib/app-updater"
 import { initAnalytics } from "@/lib/analytics"
 import { AppLayout } from "@/components/layout/app-layout"
@@ -175,6 +176,9 @@ function App() {
           // 阻止窗口立即关闭,等待保存完成
           event.preventDefault()
 
+          // LLM 模型配置走 app-state 防抖写入;关窗前必须立刻 flush,否则自定义模型会丢失。
+          await flushAppState().catch((err) => console.error("关闭前保存应用配置失败:", err))
+
           // 关闭前执行最终保存,防止丢失最后几秒的数据
           const project = useWikiStore.getState().project
           if (project) {

+ 135 - 0
src/components/settings/sections/custom-provider-cards.persist.spec.tsx

@@ -0,0 +1,135 @@
+// @vitest-environment jsdom
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import { act } from "react"
+import { createRoot, type Root } from "react-dom/client"
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import type { ProviderConfigs } from "@/stores/wiki-store"
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true
+
+const persistMocks = vi.hoisted(() => ({
+  saveProviderConfigs: vi.fn(async () => {}),
+  saveActivePresetId: vi.fn(async () => {}),
+}))
+
+vi.mock("@/lib/project-store", () => persistMocks)
+
+vi.mock("react-i18next", () => ({
+  initReactI18next: {
+    type: "3rdParty",
+    init: () => {},
+  },
+  useTranslation: () => ({
+    t: (key: string) => key,
+  }),
+}))
+
+import { CustomProviderCards, listCustomProviderCards } from "./custom-provider-cards"
+import { useWikiStore } from "@/stores/wiki-store"
+
+const SAVED_CONFIGS: ProviderConfigs = {
+  openai: { apiKey: "sk-openai", enabled: true, model: "gpt-5.5" },
+  "custom-1710000000000": {
+    label: "自建 DeepSeek",
+    apiKey: "sk-custom",
+    model: "deepseek-v4",
+    baseUrl: "https://api.deepseek.com/v1",
+    apiMode: "chat_completions",
+    enabled: true,
+    savedModels: [{ id: "m1", name: "v4", model: "deepseek-v4", createdAt: 1 }],
+  },
+}
+
+describe("listCustomProviderCards restart mapping", () => {
+  it("reloads custom-* configs after a simulated app restart", () => {
+    const cards = listCustomProviderCards(SAVED_CONFIGS)
+    expect(cards).toHaveLength(1)
+    expect(cards[0]?.id).toBe("custom-1710000000000")
+    expect(cards[0]?.label).toBe("自建 DeepSeek")
+    expect(cards[0]?.model).toBe("deepseek-v4")
+    expect(cards[0]?.savedModels[0]?.model).toBe("deepseek-v4")
+  })
+
+  it("does not treat the built-in custom preset as a user-created card", () => {
+    expect(listCustomProviderCards({
+      custom: { label: "自定义模型", model: "legacy" },
+    })).toEqual([])
+  })
+})
+
+describe("CustomProviderCards persistence UI", () => {
+  let host: HTMLDivElement
+  let root: Root
+
+  beforeEach(() => {
+    persistMocks.saveProviderConfigs.mockClear()
+    persistMocks.saveActivePresetId.mockClear()
+    useWikiStore.setState({ providerConfigs: {}, activePresetId: null })
+    host = document.createElement("div")
+    document.body.appendChild(host)
+    root = createRoot(host)
+  })
+
+  afterEach(async () => {
+    await act(async () => root.unmount())
+    host.remove()
+    useWikiStore.setState({ providerConfigs: {}, activePresetId: null })
+  })
+
+  it("shows custom models that arrive after the panel has already mounted", async () => {
+    await act(async () => root.render(<CustomProviderCards />))
+    expect(host.textContent).toContain("暂未添加任何模型配置")
+
+    await act(async () => {
+      useWikiStore.getState().setProviderConfigs(SAVED_CONFIGS)
+    })
+
+    expect(host.textContent).toContain("自建 DeepSeek")
+    expect(host.textContent).not.toContain("暂未添加任何模型配置")
+  })
+
+  it("keeps a newly added model in the store so a remount can restore it", async () => {
+    vi.spyOn(Date, "now").mockReturnValue(1710000000123)
+    await act(async () => root.render(<CustomProviderCards />))
+
+    const add = [...host.querySelectorAll("button")].find((button) => button.textContent?.includes("添加模型"))
+    expect(add).toBeTruthy()
+    await act(async () => add!.click())
+
+    expect(useWikiStore.getState().providerConfigs["custom-1710000000123"]).toMatchObject({
+      label: "自定义模型",
+      enabled: true,
+    })
+    expect(persistMocks.saveProviderConfigs).toHaveBeenCalled()
+    expect(persistMocks.saveProviderConfigs.mock.calls.at(-1)?.[0]).toMatchObject({
+      "custom-1710000000123": { label: "自定义模型", enabled: true },
+    })
+
+    await act(async () => root.unmount())
+    root = createRoot(host)
+    await act(async () => root.render(<CustomProviderCards />))
+    expect(host.textContent).toContain("自定义模型")
+    expect(host.textContent).not.toContain("暂未添加任何模型配置")
+    vi.restoreAllMocks()
+  })
+})
+
+describe("close-path wiring", () => {
+  it("flushes app-state on window close and before native destroy", () => {
+    const appSource = readFileSync(resolve(process.cwd(), "src/App.tsx"), "utf8")
+    expect(appSource).toContain("flushAppState")
+    expect(appSource).toContain("关闭前保存应用配置失败")
+
+    const rustLib = readFileSync(resolve(process.cwd(), "src-tauri/src/lib.rs"), "utf8")
+    const rustMain = readFileSync(resolve(process.cwd(), "src-tauri/src/main.rs"), "utf8")
+    expect(rustLib).toContain("persist_app_state_before_exit")
+    expect(rustMain).toContain("persist_app_state_before_exit")
+  })
+
+  it("derives custom cards from the store instead of a one-shot local snapshot", () => {
+    const source = readFileSync(resolve(__dirname, "custom-provider-cards.tsx"), "utf8")
+    expect(source).toContain("listCustomProviderCards(providerConfigs)")
+    expect(source).not.toContain("useState<CustomProviderCard[]>(")
+  })
+})

+ 47 - 57
src/components/settings/sections/custom-provider-cards.tsx

@@ -3,7 +3,7 @@ import { Plus, Trash2, ChevronDown, X } from "lucide-react"
 import { Button } from "@/components/ui/button"
 import { Input } from "@/components/ui/input"
 import { Label } from "@/components/ui/label"
-import { useWikiStore, type ProviderOverride, type SavedModel, type ReasoningConfig } from "@/stores/wiki-store"
+import { useWikiStore, type ProviderConfigs, type ProviderOverride, type SavedModel, type ReasoningConfig } from "@/stores/wiki-store"
 import { ContextSizeSelector } from "../context-size-selector"
 import { OutputTokensSelector } from "../output-tokens-selector"
 import { resolveConfig } from "../preset-resolver"
@@ -21,7 +21,7 @@ import {
   normalizeUserLlmMaxOutputTokens,
 } from "@/lib/llm-context-size"
 
-interface CustomProviderCard {
+export interface CustomProviderCard {
   id: string
   label: string
   apiMode: "chat_completions" | "responses" | "anthropic_messages"
@@ -36,17 +36,11 @@ interface CustomProviderCard {
   savedModels: SavedModel[]
 }
 
-export function CustomProviderCards() {
-  const providerConfigs = useWikiStore((s) => s.providerConfigs)
-  const setProviderConfigs = useWikiStore((s) => s.setProviderConfigs)
-  const activePresetId = useWikiStore((s) => s.activePresetId)
-  const setActivePresetId = useWikiStore((s) => s.setActivePresetId)
-
-  // Load existing custom provider configs as cards
-  const [cards, setCards] = useState<CustomProviderCard[]>(() => {
-    const customKeys = Object.keys(providerConfigs).filter((k) => k.startsWith("custom-"))
-    return customKeys.map((key) => {
-      const config = providerConfigs[key]
+export function listCustomProviderCards(providerConfigs: ProviderConfigs): CustomProviderCard[] {
+  return Object.keys(providerConfigs)
+    .filter((key) => key.startsWith("custom-"))
+    .map((key) => {
+      const config = providerConfigs[key] ?? {}
       return {
         id: key,
         label: config.label || "自定义模型",
@@ -62,46 +56,37 @@ export function CustomProviderCards() {
         savedModels: config.savedModels || [],
       }
     })
-  })
+}
+
+export function CustomProviderCards() {
+  const providerConfigs = useWikiStore((s) => s.providerConfigs)
+  const setProviderConfigs = useWikiStore((s) => s.setProviderConfigs)
+  const setActivePresetId = useWikiStore((s) => s.setActivePresetId)
+  const cards = useMemo(() => listCustomProviderCards(providerConfigs), [providerConfigs])
 
   function addCard() {
     const newId = `custom-${Date.now()}`
-    const newCard: CustomProviderCard = {
-      id: newId,
-      label: "自定义模型",
-      apiMode: "chat_completions",
-      baseUrl: "",
-      apiKey: "",
-      model: "",
-      maxContextSize: normalizeUserLlmContextSize(undefined),
-      enabled: true,
-      savedModels: [],
-    }
-    setCards([...cards, newCard])
-
-    // Also add to store
-    const newConfigs = {
-      ...providerConfigs,
+    const current = useWikiStore.getState().providerConfigs
+    const newConfigs: ProviderConfigs = {
+      ...current,
       [newId]: {
-        label: newCard.label,
-        apiMode: newCard.apiMode,
-        baseUrl: newCard.baseUrl,
-        apiKey: newCard.apiKey,
-        model: newCard.model,
-        maxContextSize: newCard.maxContextSize,
+        label: "自定义模型",
+        apiMode: "chat_completions",
+        baseUrl: "",
+        apiKey: "",
+        model: "",
+        maxContextSize: normalizeUserLlmContextSize(undefined),
         enabled: true,
-        savedModels: newCard.savedModels,
+        savedModels: [],
       },
     }
     setProviderConfigs(newConfigs)
-    persistConfigs(newConfigs)
+    void persistConfigs(newConfigs)
   }
 
   function updateCard(id: string, updates: Partial<CustomProviderCard>) {
-    setCards(cards.map((c) => (c.id === id ? { ...c, ...updates } : c)))
-
-    // Update store — 用 ?? 回退到 store 已有值,避免 updates 中未指定的字段被 undefined 覆盖
-    const prev = providerConfigs[id] ?? {}
+    const current = useWikiStore.getState().providerConfigs
+    const prev = current[id] ?? {}
     const updatedConfig: ProviderOverride = {
       ...prev,
       label: updates.label ?? prev.label,
@@ -125,30 +110,27 @@ export function CustomProviderCards() {
       savedModels: updates.savedModels ?? prev.savedModels,
     }
     const newConfigs = {
-      ...providerConfigs,
+      ...current,
       [id]: updatedConfig,
     }
     setProviderConfigs(newConfigs)
-    persistConfigs(newConfigs)
+    void persistConfigs(newConfigs)
   }
 
   function deleteCard(id: string) {
     if (!confirm("确定删除此配置吗?")) return
 
-    setCards(cards.filter((c) => c.id !== id))
-
-    // Remove from store
-    const newConfigs = { ...providerConfigs }
+    const current = useWikiStore.getState().providerConfigs
+    const newConfigs = { ...current }
     delete newConfigs[id]
     setProviderConfigs(newConfigs)
 
-    // If this was active, deactivate
-    if (activePresetId === id) {
+    if (useWikiStore.getState().activePresetId === id) {
       setActivePresetId(null)
-      persistActiveId(null)
+      void persistActiveId(null)
     }
 
-    persistConfigs(newConfigs)
+    void persistConfigs(newConfigs)
   }
 
   function toggleEnabled(id: string) {
@@ -157,14 +139,22 @@ export function CustomProviderCards() {
     updateCard(id, { enabled: !card.enabled })
   }
 
-  async function persistConfigs(newConfigs: typeof providerConfigs) {
-    const { saveProviderConfigs } = await import("@/lib/project-store")
-    await saveProviderConfigs(newConfigs)
+  async function persistConfigs(newConfigs: ProviderConfigs) {
+    try {
+      const { saveProviderConfigs } = await import("@/lib/project-store")
+      await saveProviderConfigs(newConfigs)
+    } catch (error) {
+      console.error("保存自定义模型配置失败:", error)
+    }
   }
 
   async function persistActiveId(id: string | null) {
-    const { saveActivePresetId } = await import("@/lib/project-store")
-    await saveActivePresetId(id)
+    try {
+      const { saveActivePresetId } = await import("@/lib/project-store")
+      await saveActivePresetId(id)
+    } catch (error) {
+      console.error("保存当前模型预设失败:", error)
+    }
   }
 
   return (

+ 16 - 11
src/components/settings/sections/llm-provider-section.tsx

@@ -82,15 +82,17 @@ export function LlmProviderSection() {
   }
 
   function updateOverride(id: string, patch: ProviderOverride) {
+    const current = useWikiStore.getState().providerConfigs
+    const currentActive = useWikiStore.getState().activePresetId
     const merged: ProviderOverride = normalizeProviderOverride({
-      ...(providerConfigs[id] ?? {}),
+      ...(current[id] ?? {}),
       ...patch,
     })
-    const next = { ...providerConfigs, [id]: merged }
+    const next = { ...current, [id]: merged }
     setProviderConfigs(next)
-    persist(next, activePresetId).catch(() => {})
+    persist(next, currentActive).catch(() => {})
     // If this preset is active, refresh the resolved LlmConfig live.
-    if (id === activePresetId) {
+    if (id === currentActive) {
       const preset = LLM_PRESETS.find((p) => p.id === id)
       if (preset) setLlmConfig(resolveConfig(preset, merged, llmConfig))
     }
@@ -99,18 +101,21 @@ export function LlmProviderSection() {
   }
 
   function toggleActive(id: string) {
-    const next = id === activePresetId ? null : id
+    const currentActive = useWikiStore.getState().activePresetId
+    const next = id === currentActive ? null : id
     setActivePresetId(next)
-    persist(providerConfigs, next).catch(() => {})
+    persist(useWikiStore.getState().providerConfigs, next).catch(() => {})
   }
 
   function toggleEnabled(id: string) {
-    const current = providerConfigs[id]
-    const currentEnabled = current?.enabled === true
-    const merged: ProviderOverride = { ...(current ?? {}), enabled: !currentEnabled }
-    const next = { ...providerConfigs, [id]: merged }
+    const current = useWikiStore.getState().providerConfigs
+    const currentActive = useWikiStore.getState().activePresetId
+    const entry = current[id]
+    const currentEnabled = entry?.enabled === true
+    const merged: ProviderOverride = { ...(entry ?? {}), enabled: !currentEnabled }
+    const next = { ...current, [id]: merged }
     setProviderConfigs(next)
-    persist(next, activePresetId).catch(() => {})
+    persist(next, currentActive).catch(() => {})
   }
 
   return (

+ 60 - 0
src/lib/project-store.integration.test.ts

@@ -40,6 +40,8 @@ import {
   loadMcpConfig,
   loadLlmConfig,
   loadProviderConfigs,
+  saveProviderConfigs,
+  saveDefaultLlmModel,
 } from "./project-store"
 
 let tmp: { path: string; cleanup: () => Promise<void> }
@@ -312,6 +314,39 @@ describe("novelConfig — project-directory persistence", () => {
     expect(loaded).toEqual({ ...config, contextTokenBudget: 0 })
   })
 
+  it("keeps explicit follow-chat (empty defaultLlmModel) even when a legacy global model exists", async () => {
+    await saveDefaultLlmModel("openai/gpt-5.5")
+    const config = makeNovelConfig({
+      defaultLlmModel: "",
+      reviewModel: "",
+      summaryModel: "",
+      extractModel: "",
+      deAiModel: "",
+    })
+    await saveNovelConfig(config, "proj-follow", tmp.path)
+
+    const loaded = await loadNovelConfig("proj-follow", tmp.path)
+    expect(loaded?.defaultLlmModel).toBe("")
+    expect(loaded?.reviewModel).toBe("")
+    expect(loaded?.summaryModel).toBe("")
+    expect(loaded?.extractModel).toBe("")
+    expect(loaded?.deAiModel).toBe("")
+  })
+
+  it("fills defaultLlmModel from the legacy global slot only when the saved file lacks the field", async () => {
+    await saveDefaultLlmModel("openai/gpt-5.5")
+    const legacy = makeNovelConfig({ searchTopK: 9 })
+    const { defaultLlmModel: _omitted, ...withoutField } = legacy
+    await writeFileRaw(
+      `${tmp.path}/.qmai/novel-config.json`,
+      JSON.stringify(withoutField),
+    )
+
+    const loaded = await loadNovelConfig("proj-legacy-follow", tmp.path)
+    expect(loaded?.defaultLlmModel).toBe("openai/gpt-5.5")
+    expect(loaded?.searchTopK).toBe(9)
+  })
+
   it("persists to .qmai/novel-config.json", async () => {
     await saveNovelConfig(makeNovelConfig(), "proj-2", tmp.path)
     expect(await fileExists(`${tmp.path}/.qmai/novel-config.json`)).toBe(true)
@@ -527,3 +562,28 @@ describe("mcpConfig persistence", () => {
     expect(loaded).toEqual({ servers: [] })
   })
 })
+
+describe("custom LLM providerConfigs persistence", () => {
+  it("roundtrips custom-* model configs across save and reload", async () => {
+    const configs: ProviderConfigs = {
+      openai: { apiKey: "sk-openai", enabled: true, model: "gpt-5.5" },
+      "custom-1710000000000": {
+        label: "自建 DeepSeek",
+        apiKey: "sk-custom",
+        model: "deepseek-v4",
+        baseUrl: "https://api.deepseek.com/v1",
+        apiMode: "chat_completions",
+        enabled: true,
+        savedModels: [{ id: "m1", name: "v4", model: "deepseek-v4", createdAt: 1 }],
+      },
+    }
+
+    await saveProviderConfigs(configs)
+    const loaded = await loadProviderConfigs()
+
+    expect(loaded?.["custom-1710000000000"]?.label).toBe("自建 DeepSeek")
+    expect(loaded?.["custom-1710000000000"]?.model).toBe("deepseek-v4")
+    expect(loaded?.["custom-1710000000000"]?.savedModels?.[0]?.model).toBe("deepseek-v4")
+    expect(loaded?.openai?.apiKey).toBe("sk-openai")
+  })
+})

+ 27 - 5
src/lib/project-store.ts

@@ -246,6 +246,7 @@ export async function loadOutlineWorkflowMode(): Promise<OutlineWorkflowMode | n
 export async function saveDefaultLlmModel(model: string): Promise<void> {
   const store = await getStore()
   await store.set(DEFAULT_LLM_MODEL_KEY, model)
+  await store.save()
 }
 
 export async function loadDefaultLlmModel(): Promise<string | null> {
@@ -723,12 +724,19 @@ export async function saveNovelConfig(config: NovelConfig, projectId?: string, p
   }
 }
 
+function rawHasDefaultLlmModel(raw: unknown): boolean {
+  return !!raw && typeof raw === "object" && Object.prototype.hasOwnProperty.call(raw, "defaultLlmModel")
+}
+
 async function maybeMigrateLegacyDefaultLlmModel(
   config: NovelConfig,
   projectId?: string,
   projectPath?: string,
+  rawHasField = false,
 ): Promise<NovelConfig> {
-  if (config.defaultLlmModel.trim()) return config
+  // Empty string is a real user choice: 「跟随聊天模型」. Only fill from the
+  // old global slot when the saved object never had this field at all.
+  if (rawHasField || config.defaultLlmModel.trim()) return config
   const legacyGlobal = await loadDefaultLlmModel()
   if (!legacyGlobal?.trim()) return config
   const migrated = { ...config, defaultLlmModel: legacyGlobal.trim() }
@@ -742,9 +750,15 @@ export async function loadNovelConfig(projectId?: string, projectPath?: string):
       const filePath = novelConfigFilePath(projectPath)
       if (await fileExists(filePath)) {
         const raw = await readFile(filePath)
-        const config = normalizeNovelConfig(JSON.parse(raw))
+        const parsed: unknown = JSON.parse(raw)
+        const config = normalizeNovelConfig(parsed as Partial<NovelConfig>)
         if (!config) return null
-        return maybeMigrateLegacyDefaultLlmModel(config, projectId, projectPath)
+        return maybeMigrateLegacyDefaultLlmModel(
+          config,
+          projectId,
+          projectPath,
+          rawHasDefaultLlmModel(parsed),
+        )
       }
     } catch {
       // fall through to global store
@@ -752,14 +766,17 @@ export async function loadNovelConfig(projectId?: string, projectPath?: string):
   }
   const store = await getStore()
   let config: NovelConfig | null = null
+  let storedRaw: unknown
   if (projectId) {
     const projectConfigs = await store.get<Record<string, NovelConfig>>(PROJECT_NOVEL_CONFIG_KEY)
     if (projectConfigs && projectConfigs[projectId]) {
+      storedRaw = projectConfigs[projectId]
       config = normalizeNovelConfig(projectConfigs[projectId])
     }
   }
   if (!config) {
-    config = normalizeNovelConfig(await store.get<NovelConfig>(NOVEL_CONFIG_KEY))
+    storedRaw = await store.get<NovelConfig>(NOVEL_CONFIG_KEY)
+    config = normalizeNovelConfig(storedRaw as NovelConfig)
   }
   if (config && projectPath) {
     try {
@@ -769,7 +786,12 @@ export async function loadNovelConfig(projectId?: string, projectPath?: string):
     }
   }
   if (!config) return null
-  return maybeMigrateLegacyDefaultLlmModel(config, projectId, projectPath)
+  return maybeMigrateLegacyDefaultLlmModel(
+    config,
+    projectId,
+    projectPath,
+    rawHasDefaultLlmModel(storedRaw),
+  )
 }
 
 const RERANK_CONFIG_KEY = "rerankConfig"

+ 29 - 0
src/lib/web-store.spec.ts

@@ -74,6 +74,25 @@ describe("wrapStoreForAtomicPersist", () => {
     expect(persist).toHaveBeenCalledTimes(1)
     expect(persist).toHaveBeenCalledWith({ aiOutlineModel: "second" })
   })
+
+  it("does not write to disk until debounce or save() — close-without-flush would lose LLM configs", async () => {
+    const persist = vi.fn(async () => {})
+    const inner = createInner()
+    const store = wrapStoreForAtomicPersist(inner.store, persist, 100)
+
+    await store.set("providerConfigs", {
+      "custom-1": { label: "自建模型", model: "deepseek-v4" },
+    })
+
+    expect(persist).not.toHaveBeenCalled()
+    await store.save()
+    expect(persist).toHaveBeenCalledTimes(1)
+    expect(persist).toHaveBeenCalledWith({
+      providerConfigs: {
+        "custom-1": { label: "自建模型", model: "deepseek-v4" },
+      },
+    })
+  })
 })
 
 describe("atomic write command name", () => {
@@ -81,3 +100,13 @@ describe("atomic write command name", () => {
     expect(APP_STATE_ATOMIC_WRITE_COMMAND).toBe("write_app_state_atomic")
   })
 })
+
+describe("flushAppState", () => {
+  it("is the close-path helper that forces an immediate persist", async () => {
+    const source = await import("node:fs").then((fs) =>
+      fs.readFileSync(new URL("./web-store.ts", import.meta.url), "utf8"),
+    )
+    expect(source).toContain("export async function flushAppState")
+    expect(source).toContain("await store.save()")
+  })
+})

+ 6 - 0
src/lib/web-store.ts

@@ -76,3 +76,9 @@ export async function getStore(): Promise<AtomicAppStateStore> {
   }
   return storePromise
 }
+
+/** Flush pending app-state writes. Call on window close so LLM configs survive restart. */
+export async function flushAppState(): Promise<void> {
+  const store = await getStore()
+  await store.save()
+}