project-store.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864
  1. import { getStore } from "@/lib/web-store"
  2. import type { WikiProject } from "@/types/wiki"
  3. import type { LlmConfig, SearchApiConfig, EmbeddingConfig, MultimodalConfig, OutputLanguage, ProviderConfigs, ProxyConfig, ScheduledImportConfig, SourceWatchConfig, NovelConfig, RerankConfig } from "@/stores/wiki-store"
  4. import { DEFAULT_NOVEL_CONFIG, DEFAULT_RERANK_CONFIG } from "@/stores/wiki-store"
  5. import type { McpConfig } from "@/lib/mcp/config"
  6. import { normalizeMcpConfig } from "@/lib/mcp/config"
  7. import { normalizeSourceWatchConfig } from "@/lib/source-watch-config"
  8. import { normalizeUiFontFamily, type UiFontFamily } from "@/lib/font-settings"
  9. import {
  10. VISUAL_STYLE_STORAGE_VERSION,
  11. normalizeVisualStyle,
  12. resolveStoredVisualStyle,
  13. type VisualStyle,
  14. } from "@/lib/visual-style-settings"
  15. import { normalizePath } from "@/lib/path-utils"
  16. import { readFile, writeFile, fileExists } from "@/commands/fs"
  17. import {
  18. normalizeProviderConfigs,
  19. normalizeUserLlmConfig,
  20. } from "@/lib/llm-context-size"
  21. const RECENT_PROJECTS_KEY = "recentProjects"
  22. const LAST_PROJECT_KEY = "lastProject"
  23. export async function getRecentProjects(): Promise<WikiProject[]> {
  24. const store = await getStore()
  25. const projects = await store.get<WikiProject[]>(RECENT_PROJECTS_KEY)
  26. return projects ?? []
  27. }
  28. export async function getLastProject(): Promise<WikiProject | null> {
  29. const store = await getStore()
  30. const project = await store.get<WikiProject>(LAST_PROJECT_KEY)
  31. return project ?? null
  32. }
  33. export async function saveLastProject(project: WikiProject): Promise<void> {
  34. const store = await getStore()
  35. await store.set(LAST_PROJECT_KEY, project)
  36. await addToRecentProjects(project)
  37. }
  38. export async function addToRecentProjects(
  39. project: WikiProject
  40. ): Promise<void> {
  41. const store = await getStore()
  42. const existing = (await store.get<WikiProject[]>(RECENT_PROJECTS_KEY)) ?? []
  43. const filtered = existing.filter((p) => p.path !== project.path)
  44. const updated = [project, ...filtered].slice(0, 10)
  45. await store.set(RECENT_PROJECTS_KEY, updated)
  46. }
  47. const LLM_CONFIG_KEY = "llmConfig"
  48. // Separate markers per store slot: the two loaders run independently and in no
  49. // guaranteed order, so a shared marker would let whichever ran first cancel the
  50. // other's migration.
  51. const DEEPSEEK_WINDOW_MIGRATION_KEYS = {
  52. llmConfig: "deepseekWindowMigratedV1.llmConfig",
  53. providerConfigs: "deepseekWindowMigratedV1.providerConfigs",
  54. } as const
  55. /** DeepSeek's official published context window. */
  56. const DEEPSEEK_OFFICIAL_CONTEXT_SIZE = 1_000_000
  57. /** Preset id whose configuration is known to target api.deepseek.com. */
  58. const DEEPSEEK_PRESET_ID = "deepseek"
  59. function isDeepSeekOfficialEndpoint(endpoint: string | undefined): boolean {
  60. return typeof endpoint === "string" && /api\.deepseek\.com/i.test(endpoint)
  61. }
  62. /**
  63. * One-time lift of saved DeepSeek windows to the official 1M.
  64. *
  65. * The window used to be forced to 1M at request time, which hid whatever the
  66. * user had actually saved. Now that the forcing is gone those stale values
  67. * would take effect, so they get raised once — in the user's own settings,
  68. * where they can see and change it. The marker makes this genuinely one-time:
  69. * without it, anyone who deliberately lowered the window afterwards would find
  70. * it raised again on every launch, which is the hardcoding we just removed.
  71. *
  72. * Scoped to DeepSeek's own endpoint. Third-party hosts serving DeepSeek models
  73. * (Atlas Cloud, Ollama Cloud, Volcengine) set their own limits, and the 1M
  74. * figure has no authority there.
  75. */
  76. async function hasRunDeepSeekWindowMigration(
  77. slot: keyof typeof DEEPSEEK_WINDOW_MIGRATION_KEYS,
  78. ): Promise<boolean> {
  79. const store = await getStore()
  80. return (await store.get<boolean>(DEEPSEEK_WINDOW_MIGRATION_KEYS[slot])) === true
  81. }
  82. async function markDeepSeekWindowMigrationDone(
  83. slot: keyof typeof DEEPSEEK_WINDOW_MIGRATION_KEYS,
  84. ): Promise<void> {
  85. const store = await getStore()
  86. await store.set(DEEPSEEK_WINDOW_MIGRATION_KEYS[slot], true)
  87. }
  88. const AI_CHAT_MODEL_KEY = "aiChatModel"
  89. const AI_OUTLINE_MODEL_KEY = "aiOutlineModel"
  90. let aiOutlineModelSaveRevision = 0
  91. let latestAiOutlineModel = ""
  92. const DEFAULT_LLM_MODEL_KEY = "defaultLlmModel"
  93. const PROVIDER_CONFIGS_KEY = "providerConfigs"
  94. const ACTIVE_PRESET_KEY = "activePresetId"
  95. export async function saveLlmConfig(config: LlmConfig): Promise<void> {
  96. const store = await getStore()
  97. await store.set(LLM_CONFIG_KEY, normalizeUserLlmConfig(config))
  98. }
  99. export async function loadLlmConfig(): Promise<LlmConfig | null> {
  100. const store = await getStore()
  101. const saved = (await store.get<LlmConfig>(LLM_CONFIG_KEY)) ?? null
  102. if (!saved) return null
  103. let normalized = normalizeUserLlmConfig(saved)
  104. if (!(await hasRunDeepSeekWindowMigration("llmConfig"))) {
  105. if (
  106. isDeepSeekOfficialEndpoint(normalized.customEndpoint)
  107. && normalized.maxContextSize < DEEPSEEK_OFFICIAL_CONTEXT_SIZE
  108. ) {
  109. normalized = { ...normalized, maxContextSize: DEEPSEEK_OFFICIAL_CONTEXT_SIZE }
  110. }
  111. await markDeepSeekWindowMigrationDone("llmConfig")
  112. }
  113. if (normalized !== saved) await store.set(LLM_CONFIG_KEY, normalized)
  114. return normalized
  115. }
  116. export async function saveAiChatModel(model: string): Promise<void> {
  117. const store = await getStore()
  118. await store.set(AI_CHAT_MODEL_KEY, model)
  119. }
  120. export async function loadAiChatModel(): Promise<string | null> {
  121. const store = await getStore()
  122. return (await store.get<string>(AI_CHAT_MODEL_KEY)) ?? null
  123. }
  124. export async function saveAiOutlineModel(model: string): Promise<void> {
  125. const writeRevision = ++aiOutlineModelSaveRevision
  126. latestAiOutlineModel = model
  127. const store = await getStore()
  128. await store.set(AI_OUTLINE_MODEL_KEY, model)
  129. let persistedRevision = writeRevision
  130. while (persistedRevision !== aiOutlineModelSaveRevision) {
  131. persistedRevision = aiOutlineModelSaveRevision
  132. await store.set(AI_OUTLINE_MODEL_KEY, latestAiOutlineModel)
  133. }
  134. }
  135. export async function loadAiOutlineModel(): Promise<string | null> {
  136. const store = await getStore()
  137. return (await store.get<string>(AI_OUTLINE_MODEL_KEY)) ?? null
  138. }
  139. export async function saveDefaultLlmModel(model: string): Promise<void> {
  140. const store = await getStore()
  141. await store.set(DEFAULT_LLM_MODEL_KEY, model)
  142. }
  143. export async function loadDefaultLlmModel(): Promise<string | null> {
  144. const store = await getStore()
  145. return (await store.get<string>(DEFAULT_LLM_MODEL_KEY)) ?? null
  146. }
  147. export async function saveProviderConfigs(configs: ProviderConfigs): Promise<void> {
  148. const store = await getStore()
  149. await store.set(PROVIDER_CONFIGS_KEY, normalizeProviderConfigs(configs))
  150. }
  151. export async function loadProviderConfigs(): Promise<ProviderConfigs | null> {
  152. const store = await getStore()
  153. const saved = (await store.get<ProviderConfigs>(PROVIDER_CONFIGS_KEY)) ?? null
  154. if (!saved) return null
  155. let normalized = normalizeProviderConfigs(saved)
  156. if (!(await hasRunDeepSeekWindowMigration("providerConfigs"))) {
  157. const deepseek = normalized[DEEPSEEK_PRESET_ID]
  158. if (
  159. deepseek
  160. && deepseek.maxContextSize !== undefined
  161. && deepseek.maxContextSize < DEEPSEEK_OFFICIAL_CONTEXT_SIZE
  162. ) {
  163. normalized = {
  164. ...normalized,
  165. [DEEPSEEK_PRESET_ID]: { ...deepseek, maxContextSize: DEEPSEEK_OFFICIAL_CONTEXT_SIZE },
  166. }
  167. }
  168. await markDeepSeekWindowMigrationDone("providerConfigs")
  169. }
  170. if (normalized !== saved) await store.set(PROVIDER_CONFIGS_KEY, normalized)
  171. return normalized
  172. }
  173. export async function saveActivePresetId(id: string | null): Promise<void> {
  174. const store = await getStore()
  175. await store.set(ACTIVE_PRESET_KEY, id)
  176. }
  177. export async function loadActivePresetId(): Promise<string | null> {
  178. const store = await getStore()
  179. return (await store.get<string | null>(ACTIVE_PRESET_KEY)) ?? null
  180. }
  181. const SEARCH_API_KEY = "searchApiConfig"
  182. export async function saveSearchApiConfig(config: SearchApiConfig): Promise<void> {
  183. const store = await getStore()
  184. await store.set(SEARCH_API_KEY, config)
  185. }
  186. export async function loadSearchApiConfig(): Promise<SearchApiConfig | null> {
  187. const store = await getStore()
  188. return (await store.get<SearchApiConfig>(SEARCH_API_KEY)) ?? null
  189. }
  190. const MCP_CONFIG_KEY = "mcpConfig"
  191. export async function saveMcpConfig(config: McpConfig): Promise<void> {
  192. const store = await getStore()
  193. await store.set(MCP_CONFIG_KEY, normalizeMcpConfig(config))
  194. await store.save()
  195. }
  196. export async function loadMcpConfig(): Promise<McpConfig> {
  197. const store = await getStore()
  198. return normalizeMcpConfig(await store.get<McpConfig>(MCP_CONFIG_KEY))
  199. }
  200. const EMBEDDING_KEY = "embeddingConfig"
  201. export async function saveEmbeddingConfig(config: EmbeddingConfig): Promise<void> {
  202. const store = await getStore()
  203. await store.set(EMBEDDING_KEY, config)
  204. }
  205. export async function loadEmbeddingConfig(): Promise<EmbeddingConfig | null> {
  206. const store = await getStore()
  207. return (await store.get<EmbeddingConfig>(EMBEDDING_KEY)) ?? null
  208. }
  209. const MULTIMODAL_KEY = "multimodalConfig"
  210. export async function saveMultimodalConfig(config: MultimodalConfig): Promise<void> {
  211. const store = await getStore()
  212. await store.set(MULTIMODAL_KEY, config)
  213. }
  214. export async function loadMultimodalConfig(): Promise<MultimodalConfig | null> {
  215. const store = await getStore()
  216. return (await store.get<MultimodalConfig>(MULTIMODAL_KEY)) ?? null
  217. }
  218. // IMPORTANT: Keep this key in sync with the Rust setup hook
  219. // (src-tauri/src/proxy.rs), which reads this exact field name from
  220. // the same `app-state.json` store at app launch to translate the
  221. // config into HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars.
  222. const PROXY_CONFIG_KEY = "proxyConfig"
  223. export async function saveProxyConfig(config: ProxyConfig): Promise<void> {
  224. const store = await getStore()
  225. await store.set(PROXY_CONFIG_KEY, config)
  226. // Force-flush to disk. The store is opened with `autoSave: true`,
  227. // which is a 100ms debounce — not an immediate write. For most
  228. // settings that's fine, but the proxy config is on the startup
  229. // critical path: the Rust setup hook reads `app-state.json` on
  230. // launch to apply HTTP_PROXY / HTTPS_PROXY / NO_PROXY. If the
  231. // user saves and quits within the debounce window the disk
  232. // value would lag behind in-memory, and the next launch would
  233. // boot with the wrong proxy.
  234. await store.save()
  235. }
  236. export async function loadProxyConfig(): Promise<ProxyConfig | null> {
  237. const store = await getStore()
  238. return (await store.get<ProxyConfig>(PROXY_CONFIG_KEY)) ?? null
  239. }
  240. const SCHEDULED_IMPORT_KEY_PREFIX = "scheduledImportConfig:"
  241. function scheduledImportKey(projectPath: string): string {
  242. return `${SCHEDULED_IMPORT_KEY_PREFIX}${normalizePath(projectPath)}`
  243. }
  244. const SCHEDULED_IMPORT_GLOBAL_KEY = "scheduledImportConfig"
  245. export async function saveScheduledImportConfig(projectPath: string, config: ScheduledImportConfig): Promise<void> {
  246. const store = await getStore()
  247. await store.set(scheduledImportKey(projectPath), config)
  248. await store.save()
  249. }
  250. export async function loadScheduledImportConfig(projectPath: string): Promise<ScheduledImportConfig | null> {
  251. const store = await getStore()
  252. const perProject = await store.get<ScheduledImportConfig>(scheduledImportKey(projectPath))
  253. if (perProject) return perProject
  254. // Migrate from legacy global key (pre-0.4.8)
  255. const legacy = await store.get<ScheduledImportConfig>(SCHEDULED_IMPORT_GLOBAL_KEY)
  256. if (legacy) {
  257. await store.set(scheduledImportKey(projectPath), legacy)
  258. await store.delete(SCHEDULED_IMPORT_GLOBAL_KEY)
  259. await store.save()
  260. return legacy
  261. }
  262. return null
  263. }
  264. export async function removeFromRecentProjects(
  265. path: string
  266. ): Promise<void> {
  267. const store = await getStore()
  268. const existing = (await store.get<WikiProject[]>(RECENT_PROJECTS_KEY)) ?? []
  269. const updated = existing.filter((p) => p.path !== path)
  270. await store.set(RECENT_PROJECTS_KEY, updated)
  271. // ALSO clear the last-project pointer if it points at the project
  272. // we just removed. Without this, App.tsx's startup auto-open
  273. // (`getLastProject()` → `openProject()` → `saveLastProject()`)
  274. // re-adds the removed entry back to recents on the next launch,
  275. // making the delete look like it didn't take. Reported by user
  276. // as "deleted project comes back after restart."
  277. const last = await store.get<WikiProject>(LAST_PROJECT_KEY)
  278. if (last && last.path === path) {
  279. await store.delete(LAST_PROJECT_KEY)
  280. }
  281. }
  282. const LANGUAGE_KEY = "language"
  283. export async function saveLanguage(lang: string): Promise<void> {
  284. const store = await getStore()
  285. await store.set(LANGUAGE_KEY, lang)
  286. }
  287. export async function loadLanguage(): Promise<string | null> {
  288. const store = await getStore()
  289. return (await store.get<string>(LANGUAGE_KEY)) ?? null
  290. }
  291. const OUTPUT_LANGUAGE_KEY = "outputLanguage"
  292. const PROJECT_OUTPUT_LANGUAGE_KEY = "projectOutputLanguages"
  293. const PROJECT_FILE_SYNC_KEY = "projectFileSyncEnabled"
  294. const SOURCE_WATCH_CONFIG_KEY = "sourceWatchConfig"
  295. export async function saveOutputLanguage(lang: OutputLanguage, projectId?: string): Promise<void> {
  296. const store = await getStore()
  297. if (projectId) {
  298. const existing = (await store.get<Record<string, OutputLanguage>>(PROJECT_OUTPUT_LANGUAGE_KEY)) ?? {}
  299. await store.set(PROJECT_OUTPUT_LANGUAGE_KEY, { ...existing, [projectId]: lang })
  300. }
  301. await store.set(OUTPUT_LANGUAGE_KEY, lang)
  302. }
  303. export async function loadOutputLanguage(projectId?: string): Promise<OutputLanguage | null> {
  304. const store = await getStore()
  305. if (projectId) {
  306. const projectLanguages = await store.get<Record<string, OutputLanguage>>(PROJECT_OUTPUT_LANGUAGE_KEY)
  307. return projectLanguages?.[projectId] ?? null
  308. }
  309. return (await store.get<OutputLanguage>(OUTPUT_LANGUAGE_KEY)) ?? null
  310. }
  311. export async function saveProjectFileSyncEnabled(enabled: boolean, projectId?: string): Promise<void> {
  312. const store = await getStore()
  313. if (projectId) {
  314. const existing = (await store.get<Record<string, boolean>>(PROJECT_FILE_SYNC_KEY)) ?? {}
  315. await store.set(PROJECT_FILE_SYNC_KEY, { ...existing, [projectId]: enabled })
  316. return
  317. }
  318. const existing = (await store.get<Record<string, boolean>>(PROJECT_FILE_SYNC_KEY)) ?? {}
  319. await store.set(PROJECT_FILE_SYNC_KEY, { ...existing, default: enabled })
  320. }
  321. export async function loadProjectFileSyncEnabled(projectId?: string): Promise<boolean> {
  322. const store = await getStore()
  323. const settings = await store.get<Record<string, boolean>>(PROJECT_FILE_SYNC_KEY)
  324. if (projectId && settings && typeof settings[projectId] === "boolean") {
  325. return settings[projectId]
  326. }
  327. if (settings && typeof settings.default === "boolean") {
  328. return settings.default
  329. }
  330. return true
  331. }
  332. const SOURCE_WATCH_CONFIG_FILE = ".qmai/source-watch-config.json"
  333. function sourceWatchConfigFilePath(projectPath: string): string {
  334. return `${normalizePath(projectPath)}/${SOURCE_WATCH_CONFIG_FILE}`
  335. }
  336. export async function saveSourceWatchConfig(config: SourceWatchConfig, projectId?: string, projectPath?: string): Promise<void> {
  337. const store = await getStore()
  338. const normalized = normalizeSourceWatchConfig(config)
  339. const existing = (await store.get<Record<string, SourceWatchConfig>>(SOURCE_WATCH_CONFIG_KEY)) ?? {}
  340. await store.set(SOURCE_WATCH_CONFIG_KEY, {
  341. ...existing,
  342. [projectId ?? "default"]: normalized,
  343. })
  344. await store.save()
  345. if (projectPath) {
  346. try {
  347. await writeFile(sourceWatchConfigFilePath(projectPath), JSON.stringify(normalized, null, 2))
  348. } catch {
  349. // non-critical
  350. }
  351. }
  352. }
  353. export async function loadSourceWatchConfig(projectId?: string, projectPath?: string): Promise<SourceWatchConfig> {
  354. if (projectPath) {
  355. try {
  356. const filePath = sourceWatchConfigFilePath(projectPath)
  357. if (await fileExists(filePath)) {
  358. const raw = await readFile(filePath)
  359. const config = JSON.parse(raw)
  360. return normalizeSourceWatchConfig(config)
  361. }
  362. } catch {
  363. // fall through to global store
  364. }
  365. }
  366. const store = await getStore()
  367. const settings = await store.get<Record<string, SourceWatchConfig>>(SOURCE_WATCH_CONFIG_KEY)
  368. let config: SourceWatchConfig | undefined
  369. if (projectId && settings?.[projectId]) {
  370. config = normalizeSourceWatchConfig(settings[projectId])
  371. }
  372. if (!config && settings?.default) {
  373. config = normalizeSourceWatchConfig(settings.default)
  374. }
  375. if (!config) {
  376. const legacyEnabled = await loadProjectFileSyncEnabled(projectId)
  377. config = normalizeSourceWatchConfig({ enabled: legacyEnabled })
  378. }
  379. if (config && projectPath) {
  380. try {
  381. await writeFile(sourceWatchConfigFilePath(projectPath), JSON.stringify(config, null, 2))
  382. } catch {
  383. // non-critical migration
  384. }
  385. }
  386. return config
  387. }
  388. const NOVEL_MODE_KEY = "novelMode"
  389. const PROJECT_NOVEL_MODE_KEY = "projectNovelModes"
  390. const REVISION_FEEDBACK_WINDOW_CONFIG_KEY = "revisionFeedbackWindowConfig"
  391. const PROJECT_REVISION_FEEDBACK_WINDOW_CONFIG_KEY = "projectRevisionFeedbackWindowConfigs"
  392. export async function saveNovelMode(mode: boolean, projectId?: string, projectPath?: string): Promise<void> {
  393. const store = await getStore()
  394. if (projectId) {
  395. const existing = (await store.get<Record<string, boolean>>(PROJECT_NOVEL_MODE_KEY)) ?? {}
  396. await store.set(PROJECT_NOVEL_MODE_KEY, { ...existing, [projectId]: mode })
  397. }
  398. await store.set(NOVEL_MODE_KEY, mode)
  399. if (projectPath) {
  400. try {
  401. const { saveNovelProjectMeta, loadNovelProjectMeta } = await import("@/lib/novel/project-meta")
  402. const existing = await loadNovelProjectMeta(projectPath)
  403. if (existing) {
  404. await saveNovelProjectMeta(projectPath, { ...existing, novelMode: mode })
  405. }
  406. } catch {
  407. // non-critical
  408. }
  409. }
  410. }
  411. export async function loadNovelMode(projectId?: string, projectPath?: string): Promise<boolean | null> {
  412. if (projectPath) {
  413. try {
  414. const { loadNovelProjectMeta } = await import("@/lib/novel/project-meta")
  415. const meta = await loadNovelProjectMeta(projectPath)
  416. if (meta && typeof meta.novelMode === "boolean") {
  417. return meta.novelMode
  418. }
  419. } catch {
  420. // fall through to global store
  421. }
  422. }
  423. const store = await getStore()
  424. if (projectId) {
  425. const projectModes = await store.get<Record<string, boolean>>(PROJECT_NOVEL_MODE_KEY)
  426. if (projectModes && typeof projectModes[projectId] === "boolean") {
  427. return projectModes[projectId]
  428. }
  429. return null
  430. }
  431. return (await store.get<boolean>(NOVEL_MODE_KEY)) ?? null
  432. }
  433. export interface RevisionFeedbackWindowConfig {
  434. currentChapterIncludeShouldImprove: boolean
  435. previousChapterCarryEnabled: boolean
  436. lookbackChapterCount: number
  437. lookbackIncludeMustFixOnly: boolean
  438. }
  439. const DEFAULT_REVISION_FEEDBACK_WINDOW_CONFIG: RevisionFeedbackWindowConfig = {
  440. currentChapterIncludeShouldImprove: true,
  441. previousChapterCarryEnabled: true,
  442. lookbackChapterCount: 2,
  443. lookbackIncludeMustFixOnly: true,
  444. }
  445. const REVISION_FEEDBACK_CONFIG_FILE = ".qmai/revision-feedback-config.json"
  446. function revisionFeedbackConfigFilePath(projectPath: string): string {
  447. return `${normalizePath(projectPath)}/${REVISION_FEEDBACK_CONFIG_FILE}`
  448. }
  449. export async function saveRevisionFeedbackWindowConfig(
  450. config: RevisionFeedbackWindowConfig,
  451. projectId?: string,
  452. projectPath?: string,
  453. ): Promise<void> {
  454. const store = await getStore()
  455. if (projectId) {
  456. const existing = (await store.get<Record<string, RevisionFeedbackWindowConfig>>(PROJECT_REVISION_FEEDBACK_WINDOW_CONFIG_KEY)) ?? {}
  457. await store.set(PROJECT_REVISION_FEEDBACK_WINDOW_CONFIG_KEY, { ...existing, [projectId]: config })
  458. }
  459. await store.set(REVISION_FEEDBACK_WINDOW_CONFIG_KEY, config)
  460. if (projectPath) {
  461. try {
  462. await writeFile(revisionFeedbackConfigFilePath(projectPath), JSON.stringify(config, null, 2))
  463. } catch {
  464. // non-critical
  465. }
  466. }
  467. }
  468. export async function loadRevisionFeedbackWindowConfig(
  469. projectId?: string,
  470. projectPath?: string,
  471. ): Promise<RevisionFeedbackWindowConfig> {
  472. if (projectPath) {
  473. try {
  474. const filePath = revisionFeedbackConfigFilePath(projectPath)
  475. if (await fileExists(filePath)) {
  476. const raw = await readFile(filePath)
  477. const config = JSON.parse(raw)
  478. return normalizeRevisionFeedbackWindowConfig(config)
  479. }
  480. } catch {
  481. // fall through to global store
  482. }
  483. }
  484. const store = await getStore()
  485. let config: RevisionFeedbackWindowConfig | null = null
  486. if (projectId) {
  487. const projectConfigs = await store.get<Record<string, RevisionFeedbackWindowConfig>>(PROJECT_REVISION_FEEDBACK_WINDOW_CONFIG_KEY)
  488. if (projectConfigs && projectConfigs[projectId]) {
  489. config = normalizeRevisionFeedbackWindowConfig(projectConfigs[projectId])
  490. }
  491. }
  492. if (!config) {
  493. const globalConfig = await store.get<RevisionFeedbackWindowConfig>(REVISION_FEEDBACK_WINDOW_CONFIG_KEY)
  494. config = normalizeRevisionFeedbackWindowConfig(globalConfig)
  495. }
  496. if (config && projectPath) {
  497. try {
  498. await writeFile(revisionFeedbackConfigFilePath(projectPath), JSON.stringify(config, null, 2))
  499. } catch {
  500. // non-critical migration
  501. }
  502. }
  503. return config
  504. }
  505. function normalizeRevisionFeedbackWindowConfig(
  506. config?: Partial<RevisionFeedbackWindowConfig> | null,
  507. ): RevisionFeedbackWindowConfig {
  508. return {
  509. currentChapterIncludeShouldImprove: config?.currentChapterIncludeShouldImprove ?? DEFAULT_REVISION_FEEDBACK_WINDOW_CONFIG.currentChapterIncludeShouldImprove,
  510. previousChapterCarryEnabled: config?.previousChapterCarryEnabled ?? DEFAULT_REVISION_FEEDBACK_WINDOW_CONFIG.previousChapterCarryEnabled,
  511. lookbackChapterCount: Math.max(0, config?.lookbackChapterCount ?? DEFAULT_REVISION_FEEDBACK_WINDOW_CONFIG.lookbackChapterCount),
  512. lookbackIncludeMustFixOnly: config?.lookbackIncludeMustFixOnly ?? DEFAULT_REVISION_FEEDBACK_WINDOW_CONFIG.lookbackIncludeMustFixOnly,
  513. }
  514. }
  515. const NOVEL_CONFIG_KEY = "novelConfig"
  516. const PROJECT_NOVEL_CONFIG_KEY = "projectNovelConfigs"
  517. const NOVEL_CONFIG_FILE = ".qmai/novel-config.json"
  518. function novelConfigFilePath(projectPath: string): string {
  519. return `${normalizePath(projectPath)}/${NOVEL_CONFIG_FILE}`
  520. }
  521. export async function saveNovelConfig(config: NovelConfig, projectId?: string, projectPath?: string): Promise<void> {
  522. const store = await getStore()
  523. if (projectId) {
  524. const existing = (await store.get<Record<string, NovelConfig>>(PROJECT_NOVEL_CONFIG_KEY)) ?? {}
  525. await store.set(PROJECT_NOVEL_CONFIG_KEY, { ...existing, [projectId]: config })
  526. }
  527. await store.set(NOVEL_CONFIG_KEY, config)
  528. if (projectPath) {
  529. try {
  530. await writeFile(novelConfigFilePath(projectPath), JSON.stringify(config, null, 2))
  531. } catch {
  532. // non-critical
  533. }
  534. }
  535. }
  536. async function maybeMigrateLegacyDefaultLlmModel(
  537. config: NovelConfig,
  538. projectId?: string,
  539. projectPath?: string,
  540. ): Promise<NovelConfig> {
  541. if (config.defaultLlmModel.trim()) return config
  542. const legacyGlobal = await loadDefaultLlmModel()
  543. if (!legacyGlobal?.trim()) return config
  544. const migrated = { ...config, defaultLlmModel: legacyGlobal.trim() }
  545. await saveNovelConfig(migrated, projectId, projectPath)
  546. return migrated
  547. }
  548. export async function loadNovelConfig(projectId?: string, projectPath?: string): Promise<NovelConfig | null> {
  549. if (projectPath) {
  550. try {
  551. const filePath = novelConfigFilePath(projectPath)
  552. if (await fileExists(filePath)) {
  553. const raw = await readFile(filePath)
  554. const config = normalizeNovelConfig(JSON.parse(raw))
  555. if (!config) return null
  556. return maybeMigrateLegacyDefaultLlmModel(config, projectId, projectPath)
  557. }
  558. } catch {
  559. // fall through to global store
  560. }
  561. }
  562. const store = await getStore()
  563. let config: NovelConfig | null = null
  564. if (projectId) {
  565. const projectConfigs = await store.get<Record<string, NovelConfig>>(PROJECT_NOVEL_CONFIG_KEY)
  566. if (projectConfigs && projectConfigs[projectId]) {
  567. config = normalizeNovelConfig(projectConfigs[projectId])
  568. }
  569. }
  570. if (!config) {
  571. config = normalizeNovelConfig(await store.get<NovelConfig>(NOVEL_CONFIG_KEY))
  572. }
  573. if (config && projectPath) {
  574. try {
  575. await writeFile(novelConfigFilePath(projectPath), JSON.stringify(config, null, 2))
  576. } catch {
  577. // non-critical migration
  578. }
  579. }
  580. if (!config) return null
  581. return maybeMigrateLegacyDefaultLlmModel(config, projectId, projectPath)
  582. }
  583. const RERANK_CONFIG_KEY = "rerankConfig"
  584. const PROJECT_RERANK_CONFIG_KEY = "projectRerankConfigs"
  585. const RERANK_CONFIG_FILE = ".qmai/rerank-config.json"
  586. function rerankConfigFilePath(projectPath: string): string {
  587. return `${normalizePath(projectPath)}/${RERANK_CONFIG_FILE}`
  588. }
  589. export async function saveRerankConfig(config: RerankConfig, projectId?: string, projectPath?: string): Promise<void> {
  590. const store = await getStore()
  591. if (projectId) {
  592. const existing = (await store.get<Record<string, RerankConfig>>(PROJECT_RERANK_CONFIG_KEY)) ?? {}
  593. await store.set(PROJECT_RERANK_CONFIG_KEY, { ...existing, [projectId]: config })
  594. }
  595. await store.set(RERANK_CONFIG_KEY, config)
  596. if (projectPath) {
  597. try {
  598. await writeFile(rerankConfigFilePath(projectPath), JSON.stringify(config, null, 2))
  599. } catch {
  600. // non-critical
  601. }
  602. }
  603. }
  604. export async function loadRerankConfig(projectId?: string, projectPath?: string): Promise<RerankConfig | null> {
  605. if (projectPath) {
  606. try {
  607. const filePath = rerankConfigFilePath(projectPath)
  608. if (await fileExists(filePath)) {
  609. const raw = await readFile(filePath)
  610. const config = JSON.parse(raw)
  611. return normalizeRerankConfig(config)
  612. }
  613. } catch {
  614. // fall through to global store
  615. }
  616. }
  617. const store = await getStore()
  618. let config: RerankConfig | null = null
  619. if (projectId) {
  620. const projectConfigs = await store.get<Record<string, RerankConfig>>(PROJECT_RERANK_CONFIG_KEY)
  621. if (projectConfigs && projectConfigs[projectId]) {
  622. config = normalizeRerankConfig(projectConfigs[projectId])
  623. }
  624. }
  625. if (!config) {
  626. config = normalizeRerankConfig(await store.get<RerankConfig>(RERANK_CONFIG_KEY))
  627. }
  628. if (config && projectPath) {
  629. try {
  630. await writeFile(rerankConfigFilePath(projectPath), JSON.stringify(config, null, 2))
  631. } catch {
  632. // non-critical migration
  633. }
  634. }
  635. return config
  636. }
  637. const THEME_KEY = "theme"
  638. const VISUAL_STYLE_KEY = "visualStyle"
  639. const VISUAL_STYLE_VERSION_KEY = "visualStyleVersion"
  640. export async function saveTheme(theme: "light" | "dark" | "system"): Promise<void> {
  641. const store = await getStore()
  642. await store.set(THEME_KEY, theme)
  643. }
  644. export async function loadTheme(): Promise<"light" | "dark" | "system" | null> {
  645. const store = await getStore()
  646. const savedTheme = await store.get<"light" | "dark" | "system">(THEME_KEY)
  647. return savedTheme ?? null
  648. }
  649. export async function saveVisualStyle(style: VisualStyle): Promise<void> {
  650. const store = await getStore()
  651. await store.set(VISUAL_STYLE_KEY, normalizeVisualStyle(style))
  652. await store.set(VISUAL_STYLE_VERSION_KEY, VISUAL_STYLE_STORAGE_VERSION)
  653. await store.save()
  654. }
  655. export async function loadVisualStyle(): Promise<VisualStyle | null> {
  656. const store = await getStore()
  657. const saved = await store.get<string>(VISUAL_STYLE_KEY)
  658. if (!saved) return null
  659. const savedVersion = await store.get<string>(VISUAL_STYLE_VERSION_KEY)
  660. const normalized = normalizeVisualStyle(saved)
  661. const resolved = resolveStoredVisualStyle(saved, savedVersion)
  662. if (saved !== resolved || resolved !== normalized || savedVersion !== VISUAL_STYLE_STORAGE_VERSION) {
  663. await store.set(VISUAL_STYLE_KEY, resolved)
  664. await store.set(VISUAL_STYLE_VERSION_KEY, VISUAL_STYLE_STORAGE_VERSION)
  665. await store.save()
  666. }
  667. return resolved
  668. }
  669. const UI_FONT_SIZE_SCALE_KEY = "uiFontSizeScale"
  670. const UI_FONT_FAMILY_KEY = "uiFontFamily"
  671. const MAX_HISTORY_MESSAGES_KEY = "maxHistoryMessages"
  672. export async function saveUiFontSizeScale(scale: number, _projectId?: string, _projectPath?: string): Promise<void> {
  673. const store = await getStore()
  674. await store.set(UI_FONT_SIZE_SCALE_KEY, scale)
  675. await store.save()
  676. }
  677. export async function saveUiFontFamily(fontFamily: UiFontFamily): Promise<void> {
  678. const store = await getStore()
  679. await store.set(UI_FONT_FAMILY_KEY, normalizeUiFontFamily(fontFamily))
  680. await store.save()
  681. }
  682. export async function loadUiFontFamily(): Promise<UiFontFamily | null> {
  683. const store = await getStore()
  684. const saved = await store.get<string>(UI_FONT_FAMILY_KEY)
  685. return saved ? normalizeUiFontFamily(saved) : null
  686. }
  687. export async function saveMaxHistoryMessages(max: number, _projectId?: string, _projectPath?: string): Promise<void> {
  688. const store = await getStore()
  689. await store.set(MAX_HISTORY_MESSAGES_KEY, max)
  690. await store.save()
  691. }
  692. export async function loadMaxHistoryMessages(_projectId?: string, _projectPath?: string): Promise<number | null> {
  693. const store = await getStore()
  694. const val = await store.get<number>(MAX_HISTORY_MESSAGES_KEY)
  695. return val ?? null
  696. }
  697. function normalizeNovelConfig(
  698. config?: Partial<NovelConfig> | null,
  699. ): NovelConfig | null {
  700. if (!config) return null
  701. return {
  702. contextTokenBudget: Math.max(0, config.contextTokenBudget ?? DEFAULT_NOVEL_CONFIG.contextTokenBudget),
  703. recentSummaryWindow: Math.max(1, Math.min(30, config.recentSummaryWindow ?? DEFAULT_NOVEL_CONFIG.recentSummaryWindow)),
  704. searchTopK: Math.max(1, Math.min(20, config.searchTopK ?? DEFAULT_NOVEL_CONFIG.searchTopK)),
  705. chapterTargetChars: Math.max(500, Math.min(20000, config.chapterTargetChars ?? DEFAULT_NOVEL_CONFIG.chapterTargetChars)),
  706. autoIngestOnSave: config.autoIngestOnSave ?? DEFAULT_NOVEL_CONFIG.autoIngestOnSave,
  707. autoExtractOnImport: config.autoExtractOnImport ?? DEFAULT_NOVEL_CONFIG.autoExtractOnImport,
  708. deepPreviousChaptersAnalysis: config.deepPreviousChaptersAnalysis ?? DEFAULT_NOVEL_CONFIG.deepPreviousChaptersAnalysis,
  709. deepChapterReview: config.deepChapterReview ?? DEFAULT_NOVEL_CONFIG.deepChapterReview,
  710. reviewReasoningEffort: config.reviewReasoningEffort ?? DEFAULT_NOVEL_CONFIG.reviewReasoningEffort,
  711. defaultLlmModel: config.defaultLlmModel ?? DEFAULT_NOVEL_CONFIG.defaultLlmModel,
  712. writingModel: config.writingModel ?? DEFAULT_NOVEL_CONFIG.writingModel,
  713. reviewModel: config.reviewModel ?? DEFAULT_NOVEL_CONFIG.reviewModel,
  714. summaryModel: config.summaryModel ?? DEFAULT_NOVEL_CONFIG.summaryModel,
  715. extractModel: config.extractModel ?? DEFAULT_NOVEL_CONFIG.extractModel,
  716. deAiModel: config.deAiModel ?? DEFAULT_NOVEL_CONFIG.deAiModel,
  717. deAiBatchConcurrency: Math.max(1, Math.min(5, Math.floor(config.deAiBatchConcurrency ?? DEFAULT_NOVEL_CONFIG.deAiBatchConcurrency))),
  718. communitySummaryEnabled: config.communitySummaryEnabled ?? DEFAULT_NOVEL_CONFIG.communitySummaryEnabled,
  719. communitySummaryInterval: Math.max(1, Math.min(50, config.communitySummaryInterval ?? DEFAULT_NOVEL_CONFIG.communitySummaryInterval)),
  720. communitySummaryAsync: config.communitySummaryAsync ?? DEFAULT_NOVEL_CONFIG.communitySummaryAsync,
  721. autoGenerateChapterTitle: config.autoGenerateChapterTitle ?? DEFAULT_NOVEL_CONFIG.autoGenerateChapterTitle,
  722. }
  723. }
  724. function normalizeRerankConfig(
  725. config?: Partial<RerankConfig> | null,
  726. ): RerankConfig | null {
  727. if (!config) return null
  728. return {
  729. enabled: config.enabled ?? DEFAULT_RERANK_CONFIG.enabled,
  730. useMainLlm: config.useMainLlm ?? DEFAULT_RERANK_CONFIG.useMainLlm,
  731. provider: config.provider ?? DEFAULT_RERANK_CONFIG.provider,
  732. apiKey: config.apiKey ?? DEFAULT_RERANK_CONFIG.apiKey,
  733. model: config.model ?? DEFAULT_RERANK_CONFIG.model,
  734. ollamaUrl: config.ollamaUrl ?? DEFAULT_RERANK_CONFIG.ollamaUrl,
  735. customEndpoint: config.customEndpoint ?? DEFAULT_RERANK_CONFIG.customEndpoint,
  736. apiMode: config.apiMode ?? DEFAULT_RERANK_CONFIG.apiMode,
  737. maxCandidates: Math.max(3, Math.min(30, config.maxCandidates ?? DEFAULT_RERANK_CONFIG.maxCandidates)),
  738. }
  739. }
  740. const LAST_READ_CHAPTER_KEY = "lastReadChapter"
  741. const PROJECT_LAST_READ_CHAPTER_KEY = "projectLastReadChapters"
  742. export async function saveLastReadChapter(
  743. chapterPath: string,
  744. projectId?: string,
  745. ): Promise<void> {
  746. const store = await getStore()
  747. if (projectId) {
  748. const existing =
  749. (await store.get<Record<string, string>>(PROJECT_LAST_READ_CHAPTER_KEY)) ?? {}
  750. await store.set(PROJECT_LAST_READ_CHAPTER_KEY, {
  751. ...existing,
  752. [projectId]: chapterPath,
  753. })
  754. }
  755. // Keep legacy global key as a best-effort fallback for older backups/readers.
  756. await store.set(LAST_READ_CHAPTER_KEY, chapterPath)
  757. }
  758. export async function loadLastReadChapter(projectId?: string): Promise<string | null> {
  759. const store = await getStore()
  760. if (projectId) {
  761. const projectChapters =
  762. await store.get<Record<string, string>>(PROJECT_LAST_READ_CHAPTER_KEY)
  763. const projectPath = projectChapters?.[projectId]
  764. if (typeof projectPath === "string" && projectPath) {
  765. return projectPath
  766. }
  767. }
  768. // Legacy global key: callers must verify the path belongs to the opened project.
  769. const path = await store.get<string>(LAST_READ_CHAPTER_KEY)
  770. return path ?? null
  771. }