diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 661d492e4..1d552e923 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -1716,7 +1716,7 @@ fn handle_chat(app: &AppHandle, project_id: &str, body: &str) -> ApiResponse { .collect(); } } - let runtime_config = load_agent_runtime_config(app); + let runtime_config = load_agent_runtime_config(app, Some(&project.id)); let runtime = agent::AgentRuntime::new( project.id.clone(), project.path.clone(), @@ -1800,7 +1800,7 @@ struct AgentRuntimeConfig { anytxt: Option, } -fn load_agent_runtime_config(app: &AppHandle) -> AgentRuntimeConfig { +fn load_agent_runtime_config(app: &AppHandle, project_id: Option<&str>) -> AgentRuntimeConfig { let Some(parsed) = load_app_state(app) else { return AgentRuntimeConfig::default(); }; @@ -1809,10 +1809,7 @@ fn load_agent_runtime_config(app: &AppHandle) -> AgentRuntimeConfig { .get("embeddingConfig") .cloned() .and_then(|value| serde_json::from_value(value).ok()), - llm: parsed - .get("llmConfig") - .cloned() - .and_then(|value| serde_json::from_value(value).ok()), + llm: crate::llm_settings::resolve_project_llm_config(&parsed, project_id), web_search: parsed .get("searchApiConfig") .cloned() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e8d7ffd70..82387830a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod api_server; mod clip_server; mod commands; mod cors; +mod llm_settings; mod panic_guard; mod proxy; mod server_bind; @@ -101,7 +102,7 @@ async fn agent_start_turn( .collect(); } } - let runtime_config = load_agent_runtime_config(&app); + let runtime_config = load_agent_runtime_config(&app, Some(&project.id)); let runtime = agent::AgentRuntime::new( project.id.clone(), project.path.clone(), @@ -185,7 +186,7 @@ async fn agent_start_turn_stream( }) .collect(); } - let runtime_config = load_agent_runtime_config(&app); + let runtime_config = load_agent_runtime_config(&app, Some(&project.id)); let runtime = agent::AgentRuntime::new( project.id.clone(), project.path.clone(), @@ -404,7 +405,10 @@ fn load_agent_app_state(app: &tauri::AppHandle) -> Option { serde_json::from_str(&raw).ok() } -fn load_agent_runtime_config(app: &tauri::AppHandle) -> AgentRuntimeConfig { +fn load_agent_runtime_config( + app: &tauri::AppHandle, + project_id: Option<&str>, +) -> AgentRuntimeConfig { let Some(parsed) = load_agent_app_state(app) else { return AgentRuntimeConfig::default(); }; @@ -413,10 +417,7 @@ fn load_agent_runtime_config(app: &tauri::AppHandle) -> AgentRuntimeConfig { .get("embeddingConfig") .cloned() .and_then(|value| serde_json::from_value(value).ok()), - llm: parsed - .get("llmConfig") - .cloned() - .and_then(|value| serde_json::from_value(value).ok()), + llm: llm_settings::resolve_project_llm_config(&parsed, project_id), web_search: parsed .get("searchApiConfig") .cloned() diff --git a/src-tauri/src/llm_settings.rs b/src-tauri/src/llm_settings.rs new file mode 100644 index 000000000..d6fd7b637 --- /dev/null +++ b/src-tauri/src/llm_settings.rs @@ -0,0 +1,137 @@ +use serde_json::Value; + +use crate::agent::provider::LlmConfig; + +pub fn resolve_project_llm_config( + app_state: &Value, + project_id: Option<&str>, +) -> Option { + let global_config = app_state.get("llmConfig").cloned(); + let project_settings = project_id.and_then(|id| { + app_state + .get("projectLlmSettings") + .and_then(Value::as_object) + .and_then(|settings| settings.get(id)) + }); + + let selected = match project_settings { + Some(settings) if has_own(settings, "llmConfig") => settings.get("llmConfig").cloned(), + Some(settings) + if has_own(settings, "activePresetId") + && settings + .get("activePresetId") + .map(Value::is_null) + .unwrap_or(false) => + { + None + } + _ => global_config, + }; + + selected.and_then(|value| serde_json::from_value::(value).ok()) +} + +fn has_own(value: &Value, key: &str) -> bool { + value + .as_object() + .map(|object| object.contains_key(key)) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn llm(provider: &str, model: &str) -> Value { + json!({ + "provider": provider, + "apiKey": "test-key", + "model": model, + "customEndpoint": "https://example.invalid/v1/chat/completions", + "apiMode": "chat_completions", + "maxContextSize": 12345 + }) + } + + #[test] + fn falls_back_to_global_when_project_has_no_llm_settings() { + let state = json!({ + "llmConfig": llm("openai", "global-model"), + "projectLlmSettings": { + "project-a": { + "providerConfigs": {} + } + } + }); + + let config = resolve_project_llm_config(&state, Some("project-a")).unwrap(); + + assert_eq!(config.provider, "openai"); + assert_eq!(config.model, "global-model"); + } + + #[test] + fn uses_project_llm_config_when_present() { + let state = json!({ + "llmConfig": llm("openai", "global-model"), + "projectLlmSettings": { + "project-a": { + "llmConfig": llm("custom", "project-model"), + "activePresetId": "custom" + } + } + }); + + let config = resolve_project_llm_config(&state, Some("project-a")).unwrap(); + + assert_eq!(config.provider, "custom"); + assert_eq!(config.model, "project-model"); + } + + #[test] + fn active_preset_null_without_llm_config_disables_project_llm() { + let state = json!({ + "llmConfig": llm("openai", "global-model"), + "projectLlmSettings": { + "project-a": { + "activePresetId": null + } + } + }); + + assert!(resolve_project_llm_config(&state, Some("project-a")).is_none()); + } + + #[test] + fn explicit_null_project_llm_config_disables_project_llm() { + let state = json!({ + "llmConfig": llm("openai", "global-model"), + "projectLlmSettings": { + "project-a": { + "llmConfig": null, + "activePresetId": null + } + } + }); + + assert!(resolve_project_llm_config(&state, Some("project-a")).is_none()); + } + + #[test] + fn no_project_uses_global_llm_config() { + let state = json!({ + "llmConfig": llm("openai", "global-model"), + "projectLlmSettings": { + "project-a": { + "llmConfig": llm("custom", "project-model") + } + } + }); + + let config = resolve_project_llm_config(&state, None).unwrap(); + + assert_eq!(config.provider, "openai"); + assert_eq!(config.model, "global-model"); + } +} diff --git a/src/App.tsx b/src/App.tsx index 3d5b522ae..67d6456c1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,13 +3,13 @@ import { open } from "@tauri-apps/plugin-dialog" import { invoke } from "@tauri-apps/api/core" import { disable as disableAutostart, enable as enableAutostart, isEnabled as isAutostartEnabled } from "@tauri-apps/plugin-autostart" import i18n from "@/i18n" -import { useWikiStore } from "@/stores/wiki-store" +import { DEFAULT_LLM_CONFIG, useWikiStore } from "@/stores/wiki-store" import { useReviewStore } from "@/stores/review-store" import { useLintStore } from "@/stores/lint-store" import { useChatStore } from "@/stores/chat-store" import { BASE_FONT_SIZE_PX, useZoomStore } from "@/stores/zoom-store" import { openProject } from "@/commands/fs" -import { getLastProject, getRecentProjects, saveLastProject, loadLlmConfig, loadLanguage, loadSearchApiConfig, loadEmbeddingConfig, loadMineruConfig, loadMultimodalConfig, loadOutputLanguage, loadProviderConfigs, loadActivePresetId, loadProxyConfig, loadScheduledImportConfig, saveScheduledImportConfig, loadSourceWatchConfig, loadApiConfig, loadGeneralConfig, loadZoomLevel } from "@/lib/project-store" +import { getLastProject, getRecentProjects, saveLastProject, loadLlmSettings, loadLanguage, loadSearchApiConfig, loadEmbeddingConfig, loadMineruConfig, loadMultimodalConfig, loadOutputLanguage, loadProxyConfig, loadScheduledImportConfig, saveScheduledImportConfig, loadSourceWatchConfig, loadApiConfig, loadGeneralConfig, loadZoomLevel } from "@/lib/project-store" import { loadReviewItems, loadLintItems, loadChatHistory, loadChatPreferences } from "@/lib/persist" import { setupAutoSave } from "@/lib/auto-save" import { startClipWatcher } from "@/lib/clip-watcher" @@ -119,6 +119,33 @@ function App() { } } + async function hydrateLlmSettings(projectId?: string): Promise { + const savedSettings = await loadLlmSettings(projectId) + const providerConfigs = savedSettings.providerConfigs ?? {} + const activePresetId = savedSettings.activePresetId + const fallbackConfig = savedSettings.llmConfig ?? DEFAULT_LLM_CONFIG + + useWikiStore.getState().setProviderConfigs(providerConfigs) + useWikiStore.getState().setActivePresetId(activePresetId) + + if (activePresetId) { + const { LLM_PRESETS } = await import("@/components/settings/llm-presets") + const { resolveConfig } = await import("@/components/settings/preset-resolver") + const preset = LLM_PRESETS.find((p) => p.id === activePresetId) + if (preset) { + const resolved = resolveConfig(preset, providerConfigs[activePresetId], fallbackConfig) + useWikiStore.getState().setLlmConfig(resolved) + if (!projectId || savedSettings.hasProjectSettings) { + const { saveLlmConfig } = await import("@/lib/project-store") + await saveLlmConfig(resolved, projectId) + } + return + } + } + + useWikiStore.getState().setLlmConfig(fallbackConfig) + } + // Set up auto-save and clip watcher once on mount useEffect(() => { setupAutoSave() @@ -283,36 +310,7 @@ function App() { applyDocumentZoom(savedZoom) useZoomStore.getState().setLevel(savedZoom) - const savedConfig = await loadLlmConfig() - if (savedConfig) { - useWikiStore.getState().setLlmConfig(savedConfig) - } - const savedProviderConfigs = await loadProviderConfigs() - if (savedProviderConfigs) { - useWikiStore.getState().setProviderConfigs(savedProviderConfigs) - } - const savedActivePreset = await loadActivePresetId() - if (savedActivePreset) { - useWikiStore.getState().setActivePresetId(savedActivePreset) - // Re-resolve the active preset's LlmConfig from (preset defaults - // + saved overrides). Without this, preset default updates - // (e.g. a corrected Anthropic model ID shipped in a release) - // never reach users who are relying on defaults — their stored - // `llmConfig` snapshot from a previous launch would keep the - // old value. Overrides still win, so an explicit user choice - // is preserved. - const { LLM_PRESETS } = await import("@/components/settings/llm-presets") - const { resolveConfig } = await import("@/components/settings/preset-resolver") - const preset = LLM_PRESETS.find((p) => p.id === savedActivePreset) - if (preset) { - const currentFallback = useWikiStore.getState().llmConfig - const override = (savedProviderConfigs ?? {})[savedActivePreset] - const resolved = resolveConfig(preset, override, currentFallback) - useWikiStore.getState().setLlmConfig(resolved) - const { saveLlmConfig } = await import("@/lib/project-store") - await saveLlmConfig(resolved) - } - } + await hydrateLlmSettings() const savedSearchConfig = await loadSearchApiConfig() if (savedSearchConfig) { useWikiStore.getState().setSearchApiConfig(savedSearchConfig) @@ -410,6 +408,7 @@ function App() { await resetProjectState() setProject(proj) + await hydrateLlmSettings(proj.id) const projectOutputLang = await loadOutputLanguage(proj.id) useWikiStore.getState().setOutputLanguage(projectOutputLang ?? "auto") setSelectedFile(null) diff --git a/src/components/settings/sections/llm-provider-section.tsx b/src/components/settings/sections/llm-provider-section.tsx index 7d6b9995e..42bcbf83d 100644 --- a/src/components/settings/sections/llm-provider-section.tsx +++ b/src/components/settings/sections/llm-provider-section.tsx @@ -20,6 +20,7 @@ export function LlmProviderSection() { const setActivePresetId = useWikiStore((s) => s.setActivePresetId) const setLlmConfig = useWikiStore((s) => s.setLlmConfig) const llmConfig = useWikiStore((s) => s.llmConfig) + const projectId = useWikiStore((s) => s.project?.id) const [expanded, setExpanded] = useState>({}) const [savedId, setSavedId] = useState(null) @@ -32,14 +33,14 @@ export function LlmProviderSection() { const { saveProviderConfigs, saveActivePresetId, saveLlmConfig } = await import( "@/lib/project-store" ) - await saveProviderConfigs(newConfigs) - await saveActivePresetId(newActive) + await saveProviderConfigs(newConfigs, projectId) + await saveActivePresetId(newActive, projectId) if (newActive) { const preset = LLM_PRESETS.find((p) => p.id === newActive) if (preset) { const resolved = resolveConfig(preset, newConfigs[newActive], llmConfig) setLlmConfig(resolved) - await saveLlmConfig(resolved) + await saveLlmConfig(resolved, projectId) } } else { // All presets disabled: write llmConfig into a state where hasUsableLlm() @@ -50,7 +51,7 @@ export function LlmProviderSection() { // so the cleared values here do not affect the user's saved settings. const cleared = disabledLlmConfig(llmConfig) setLlmConfig(cleared) - await saveLlmConfig(cleared) + await saveLlmConfig(cleared, projectId) } } diff --git a/src/components/settings/settings-view.tsx b/src/components/settings/settings-view.tsx index 786c6eceb..76ffa2c39 100644 --- a/src/components/settings/settings-view.tsx +++ b/src/components/settings/settings-view.tsx @@ -317,6 +317,7 @@ export function SettingsView() { const { saveLlmConfig, loadLlmConfig, + loadLlmSettings, saveEmbeddingConfig, loadEmbeddingConfig, saveMultimodalConfig, @@ -427,7 +428,12 @@ export function SettingsView() { setGeneralConfig(newGeneralConfig) try { - await saveLlmConfig(newLlm) + const llmSettingsScope = project ? await loadLlmSettings(project.id) : null + if (project && llmSettingsScope?.hasProjectSettings) { + await saveLlmConfig(newLlm, project.id) + } else { + await saveLlmConfig(newLlm) + } await saveEmbeddingConfig(newEmbed) await saveMultimodalConfig(newMultimodal) await saveOutputLanguage(draft.outputLanguage as typeof outputLanguage, project?.id) @@ -534,7 +540,7 @@ export function SettingsView() { persistedGeneral, persistedZoom, ] = await Promise.allSettled([ - loadLlmConfig(), + loadLlmConfig(project?.id), loadEmbeddingConfig(), loadMultimodalConfig(), loadOutputLanguage(project?.id), diff --git a/src/lib/project-store.test.ts b/src/lib/project-store.test.ts index 0ca83011e..3f856b016 100644 --- a/src/lib/project-store.test.ts +++ b/src/lib/project-store.test.ts @@ -49,3 +49,82 @@ describe("project-store zoom normalization", () => { expect(__projectStoreTest.normalizeZoomLevel("150")).toBe(1) }) }) + +describe("project-store per-project LLM settings", () => { + const globalLlm = { + provider: "openai" as const, + apiKey: "global-key", + model: "gpt-global", + ollamaUrl: "http://localhost:11434", + customEndpoint: "", + maxContextSize: 128000, + } + const projectLlm = { + provider: "google" as const, + apiKey: "project-key", + model: "gemini-project", + ollamaUrl: "http://localhost:11434", + customEndpoint: "", + maxContextSize: 1000000, + } + + it("falls back to global LLM settings when a project has no override", () => { + expect(__projectStoreTest.resolveStoredLlmSettings({}, "project-a", { + llmConfig: globalLlm, + providerConfigs: { openai: { model: "gpt-global" } }, + activePresetId: "openai", + })).toEqual({ + llmConfig: globalLlm, + providerConfigs: { openai: { model: "gpt-global" } }, + activePresetId: "openai", + hasProjectSettings: false, + }) + }) + + it("loads project-scoped LLM settings ahead of global defaults", () => { + const settings = __projectStoreTest.mergeProjectLlmSettings({}, "project-a", { + llmConfig: projectLlm, + providerConfigs: { google: { model: "gemini-project" } }, + activePresetId: "google", + }) + + expect(__projectStoreTest.resolveStoredLlmSettings(settings, "project-a", { + llmConfig: globalLlm, + providerConfigs: { openai: { model: "gpt-global" } }, + activePresetId: "openai", + })).toEqual({ + llmConfig: projectLlm, + providerConfigs: { google: { model: "gemini-project" } }, + activePresetId: "google", + hasProjectSettings: true, + }) + }) + + it("keeps project saves isolated and preserves explicit disabled state", () => { + const settings = __projectStoreTest.mergeProjectLlmSettings( + { + "project-a": { + llmConfig: projectLlm, + providerConfigs: { google: { model: "gemini-project" } }, + activePresetId: "google", + }, + }, + "project-b", + { + activePresetId: null, + }, + ) + + expect(settings["project-a"]?.activePresetId).toBe("google") + expect(__projectStoreTest.resolveStoredLlmSettings(settings, "project-b", { + llmConfig: globalLlm, + providerConfigs: { openai: { model: "gpt-global" } }, + activePresetId: "openai", + })).toEqual({ + llmConfig: null, + providerConfigs: { openai: { model: "gpt-global" } }, + activePresetId: null, + hasProjectSettings: true, + }) + }) +}) diff --git a/src/lib/project-store.ts b/src/lib/project-store.ts index 45e16e27d..ad994346e 100644 --- a/src/lib/project-store.ts +++ b/src/lib/project-store.ts @@ -44,37 +44,151 @@ export async function addToRecentProjects( const LLM_CONFIG_KEY = "llmConfig" const PROVIDER_CONFIGS_KEY = "providerConfigs" const ACTIVE_PRESET_KEY = "activePresetId" +const PROJECT_LLM_SETTINGS_KEY = "projectLlmSettings" -export async function saveLlmConfig(config: LlmConfig): Promise { +interface StoredLlmSettings { + llmConfig?: LlmConfig + providerConfigs?: ProviderConfigs + activePresetId?: string | null +} + +type ProjectLlmSettings = Record + +export interface LoadedLlmSettings { + llmConfig: LlmConfig | null + providerConfigs: ProviderConfigs | null + activePresetId: string | null + hasProjectSettings: boolean +} + +function hasOwn(obj: T, key: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(obj, key) +} + +function mergeProjectLlmSettings( + existing: ProjectLlmSettings, + projectId: string, + patch: StoredLlmSettings, +): ProjectLlmSettings { + return { + ...existing, + [projectId]: { + ...(existing[projectId] ?? {}), + ...patch, + }, + } +} + +function resolveStoredLlmSettings( + projectSettingsById: ProjectLlmSettings, + projectId: string | undefined, + globalSettings: Omit, +): LoadedLlmSettings { + const projectSettings = projectId ? projectSettingsById[projectId] : undefined + const hasProjectLlmConfig = !!projectSettings && hasOwn(projectSettings, "llmConfig") + const hasProjectProviderConfigs = !!projectSettings && hasOwn(projectSettings, "providerConfigs") + const hasProjectActivePreset = !!projectSettings && hasOwn(projectSettings, "activePresetId") + const hasProjectSettings = !!projectSettings && ( + hasProjectLlmConfig || + hasProjectProviderConfigs || + hasProjectActivePreset + ) + + return { + llmConfig: + hasProjectLlmConfig + ? projectSettings.llmConfig ?? null + : hasProjectActivePreset && projectSettings.activePresetId === null + ? null + : globalSettings.llmConfig, + providerConfigs: + hasProjectProviderConfigs + ? projectSettings.providerConfigs ?? null + : globalSettings.providerConfigs, + activePresetId: + hasProjectActivePreset + ? projectSettings.activePresetId ?? null + : globalSettings.activePresetId, + hasProjectSettings, + } +} + +async function updateProjectLlmSettings( + projectId: string, + patch: StoredLlmSettings, +): Promise { + const store = await getStore() + const existing = (await store.get(PROJECT_LLM_SETTINGS_KEY)) ?? {} + await store.set(PROJECT_LLM_SETTINGS_KEY, mergeProjectLlmSettings(existing, projectId, patch)) +} + +export async function saveLlmConfig(config: LlmConfig, projectId?: string): Promise { + if (projectId) { + await updateProjectLlmSettings(projectId, { llmConfig: config }) + return + } const store = await getStore() await store.set(LLM_CONFIG_KEY, config) } -export async function loadLlmConfig(): Promise { +export async function loadLlmConfig(projectId?: string): Promise { + if (projectId) { + const settings = await loadLlmSettings(projectId) + return settings.llmConfig + } const store = await getStore() return (await store.get(LLM_CONFIG_KEY)) ?? null } -export async function saveProviderConfigs(configs: ProviderConfigs): Promise { +export async function saveProviderConfigs(configs: ProviderConfigs, projectId?: string): Promise { + if (projectId) { + await updateProjectLlmSettings(projectId, { providerConfigs: configs }) + return + } const store = await getStore() await store.set(PROVIDER_CONFIGS_KEY, configs) } -export async function loadProviderConfigs(): Promise { +export async function loadProviderConfigs(projectId?: string): Promise { + if (projectId) { + const settings = await loadLlmSettings(projectId) + return settings.providerConfigs + } const store = await getStore() return (await store.get(PROVIDER_CONFIGS_KEY)) ?? null } -export async function saveActivePresetId(id: string | null): Promise { +export async function saveActivePresetId(id: string | null, projectId?: string): Promise { + if (projectId) { + await updateProjectLlmSettings(projectId, { activePresetId: id }) + return + } const store = await getStore() await store.set(ACTIVE_PRESET_KEY, id) } -export async function loadActivePresetId(): Promise { +export async function loadActivePresetId(projectId?: string): Promise { + if (projectId) { + const settings = await loadLlmSettings(projectId) + return settings.activePresetId + } const store = await getStore() return (await store.get(ACTIVE_PRESET_KEY)) ?? null } +export async function loadLlmSettings(projectId?: string): Promise { + const store = await getStore() + const globalSettings = { + llmConfig: (await store.get(LLM_CONFIG_KEY)) ?? null, + providerConfigs: (await store.get(PROVIDER_CONFIGS_KEY)) ?? null, + activePresetId: (await store.get(ACTIVE_PRESET_KEY)) ?? null, + } + const projectSettingsById = projectId + ? (await store.get(PROJECT_LLM_SETTINGS_KEY)) ?? {} + : {} + return resolveStoredLlmSettings(projectSettingsById, projectId, globalSettings) +} + const SEARCH_API_KEY = "searchApiConfig" export async function saveSearchApiConfig(config: SearchApiConfig): Promise { @@ -130,6 +244,8 @@ function normalizeZoomLevel(level: unknown): number { export const __projectStoreTest = { normalizeMineruConfig, normalizeZoomLevel, + mergeProjectLlmSettings, + resolveStoredLlmSettings, } export async function saveMineruConfig(config: MineruConfig): Promise { diff --git a/src/stores/wiki-store.ts b/src/stores/wiki-store.ts index cab7338bc..954501f5f 100644 --- a/src/stores/wiki-store.ts +++ b/src/stores/wiki-store.ts @@ -44,6 +44,18 @@ interface LlmConfig { codexCliTimeoutMinutes?: number } +export const DEFAULT_LLM_CONFIG: LlmConfig = { + provider: "openai", + apiKey: "", + maxContextSize: 204800, + model: "", + ollamaUrl: "http://localhost:11434", + customEndpoint: "", + azureApiVersion: "2024-10-21", + reasoning: { mode: "auto" }, + localCliIsolation: false, +} + export type SearchProvider = | "tavily" | "serpapi" @@ -430,17 +442,7 @@ export const useWikiStore = create((set) => ({ previewReturnView: null, pendingScrollImageSrc: null, activeView: "wiki", - llmConfig: { - provider: "openai", - apiKey: "", - maxContextSize: 204800, - model: "", - ollamaUrl: "http://localhost:11434", - customEndpoint: "", - azureApiVersion: "2024-10-21", - reasoning: { mode: "auto" }, - localCliIsolation: false, - }, + llmConfig: { ...DEFAULT_LLM_CONFIG }, providerConfigs: {}, activePresetId: null,