Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions src-tauri/src/api_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -1800,7 +1800,7 @@ struct AgentRuntimeConfig {
anytxt: Option<agent::tools::AnyTxtConfig>,
}

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();
};
Expand All @@ -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()
Expand Down
15 changes: 8 additions & 7 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -404,7 +405,10 @@ fn load_agent_app_state(app: &tauri::AppHandle) -> Option<Value> {
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();
};
Expand All @@ -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()
Expand Down
137 changes: 137 additions & 0 deletions src-tauri/src/llm_settings.rs
Original file line number Diff line number Diff line change
@@ -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<LlmConfig> {
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::<LlmConfig>(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");
}
}
63 changes: 31 additions & 32 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -119,6 +119,33 @@ function App() {
}
}

async function hydrateLlmSettings(projectId?: string): Promise<void> {
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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 5 additions & 4 deletions src/components/settings/sections/llm-provider-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, boolean>>({})
const [savedId, setSavedId] = useState<string | null>(null)
Expand All @@ -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()
Expand All @@ -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)
}
}

Expand Down
10 changes: 8 additions & 2 deletions src/components/settings/settings-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ export function SettingsView() {
const {
saveLlmConfig,
loadLlmConfig,
loadLlmSettings,
saveEmbeddingConfig,
loadEmbeddingConfig,
saveMultimodalConfig,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -534,7 +540,7 @@ export function SettingsView() {
persistedGeneral,
persistedZoom,
] = await Promise.allSettled([
loadLlmConfig(),
loadLlmConfig(project?.id),
loadEmbeddingConfig(),
loadMultimodalConfig(),
loadOutputLanguage(project?.id),
Expand Down
Loading