From 38e126fee18c000aa6dcbe686d0315257c7e93b3 Mon Sep 17 00:00:00 2001 From: "Dongmin, Yu" Date: Thu, 16 Jul 2026 21:21:44 +0900 Subject: [PATCH 1/2] fix: keep provider task routing consistent --- src-tauri/src/api_server.rs | 104 ++++++++++++++---- .../sections/llm-provider-section.tsx | 47 ++++++-- src/lib/clip-watcher.ts | 3 +- src/lib/llm-task-routing.test.ts | 11 ++ src/lib/llm-task-routing.ts | 2 + src/lib/project-store.ts | 10 +- src/stores/wiki-store.ts | 2 + 7 files changed, 150 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 5cf59f503..31a27d3a8 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -1802,22 +1802,56 @@ struct AgentRuntimeConfig { fn project_llm_config(parsed: &Value, project_id: &str) -> Option { let global = parsed.get("llmConfig").cloned(); - let Some(project) = parsed + let project = parsed .get("projectLlmOverrides") - .and_then(|value| value.get(project_id)) - else { - return global.and_then(|value| serde_json::from_value(value).ok()); - }; - if project.get("enabled").and_then(Value::as_bool) != Some(true) { - return global.and_then(|value| serde_json::from_value(value).ok()); + .and_then(|value| value.get(project_id)); + if let Some(project) = project { + if project.get("enabled").and_then(Value::as_bool) == Some(true) { + let profile = project.get("profile")?.clone(); + let preset_id = project.get("presetId").and_then(Value::as_str)?; + let model_override = project.get("model").and_then(Value::as_str); + return resolve_routed_llm_config(parsed, profile, preset_id, model_override) + .or_else(|| global.and_then(|value| serde_json::from_value(value).ok())); + } } + let global_config = global + .clone() + .and_then(|value| serde_json::from_value::(value).ok()); + if !global_config.as_ref().is_some_and(is_llm_enabled) { + return global_config; + } + let chat_config = parsed + .get("taskModelRouting") + .and_then(|routing| { + Some(( + routing.get("chatProfile")?.clone(), + routing.get("chatPresetId")?.as_str()?, + )) + }) + .and_then(|(profile, preset_id)| { + resolve_routed_llm_config(parsed, profile, preset_id, None) + }); + chat_config.or(global_config) +} + +fn is_llm_enabled(config: &agent::provider::LlmConfig) -> bool { + matches!( + config.provider.as_str(), + "ollama" | "custom" | "claude-code" | "codex-cli" + ) || !config.api_key.trim().is_empty() +} + +fn resolve_routed_llm_config( + parsed: &Value, + mut profile: Value, + preset_id: &str, + model_override: Option<&str>, +) -> Option { // The frontend persists a resolved, non-secret profile. Merge the current // provider override here so credential rotation and endpoint edits apply to // native API calls without duplicating API keys in every project record. - let mut profile = project.get("profile")?.clone(); let profile_object = profile.as_object_mut()?; - let preset_id = project.get("presetId").and_then(Value::as_str)?; let provider = parsed .get("providerConfigs") .and_then(|value| value.get(preset_id)) @@ -1835,10 +1869,10 @@ fn project_llm_config(parsed: &Value, project_id: &str) -> Option Option {}) - // If this preset is active, refresh the resolved LlmConfig live. - if (id === activePresetId) { + const usesProjectOverride = projectLlmOverride.enabled && projectLlmOverride.presetId === id + if (id === activePresetId || usesProjectOverride) { const preset = findLlmPreset(id, customLlmPresets) if (preset) { - const resolved = resolveConfig(preset, merged, globalLlmConfig) - setGlobalLlmConfig(resolved) - setLlmConfig(resolveProjectLlmConfig(resolved, next, projectLlmOverride, customLlmPresets)) + const global = id === activePresetId + ? resolveConfig(preset, merged, globalLlmConfig) + : globalLlmConfig + if (id === activePresetId) setGlobalLlmConfig(global) + const resolved = resolveProjectLlmConfig(global, next, projectLlmOverride, customLlmPresets) + setLlmConfig(resolved) + if (project && usesProjectOverride) { + const updatedOverride = { ...projectLlmOverride, profile: projectLlmProfile(resolved) } + setProjectLlmOverride(updatedOverride) + saveProjectLlmOverride(project.id, updatedOverride).catch(() => {}) + } + if (state.taskModelRouting.chatPresetId === id) { + const nextRouting = { + ...state.taskModelRouting, + chatProfile: projectLlmProfile(resolveConfig(preset, merged, globalLlmConfig)), + } + setTaskModelRouting(nextRouting) + import("@/lib/project-store") + .then(({ saveTaskModelRouting }) => saveTaskModelRouting(nextRouting)) + .catch(() => {}) + } } } setSavedId(id) @@ -95,10 +114,22 @@ export function LlmProviderSection() { } async function updateTaskRouting(task: "chat" | "ingest", value: string) { + const state = useWikiStore.getState() + const presetId = value || null const next = { - ...taskModelRouting, + ...state.taskModelRouting, [task === "chat" ? "chatPresetId" : "ingestPresetId"]: value || null, } + if (task === "chat") { + if (!presetId) { + next.chatProfile = undefined + } else { + const preset = findLlmPreset(presetId, state.customLlmPresets) + next.chatProfile = preset + ? projectLlmProfile(resolveConfig(preset, state.providerConfigs[presetId], state.globalLlmConfig)) + : undefined + } + } setTaskModelRouting(next) const { saveTaskModelRouting } = await import("@/lib/project-store") await saveTaskModelRouting(next) @@ -136,7 +167,9 @@ export function LlmProviderSection() { const nextPresets = state.customLlmPresets.filter((preset) => preset.id !== id) const { [id]: _removed, ...nextConfigs } = state.providerConfigs const nextRouting = { + ...state.taskModelRouting, chatPresetId: state.taskModelRouting.chatPresetId === id ? null : state.taskModelRouting.chatPresetId, + chatProfile: state.taskModelRouting.chatPresetId === id ? undefined : state.taskModelRouting.chatProfile, ingestPresetId: state.taskModelRouting.ingestPresetId === id ? null : state.taskModelRouting.ingestPresetId, } setCustomLlmPresets(nextPresets) diff --git a/src/lib/clip-watcher.ts b/src/lib/clip-watcher.ts index 47a12e199..97907d5fe 100644 --- a/src/lib/clip-watcher.ts +++ b/src/lib/clip-watcher.ts @@ -2,6 +2,7 @@ import { useWikiStore } from "@/stores/wiki-store" import { enqueueIngest } from "./ingest-queue" import { hasUsableLlm } from "@/lib/has-usable-llm" import { refreshProjectFileTree } from "@/lib/project-file-tree-refresh" +import { getTaskLlmConfig } from "@/lib/llm-task-routing" const POLL_INTERVAL = 3000 // Check every 3 seconds let intervalId: ReturnType | null = null @@ -36,7 +37,7 @@ export function startClipWatcher() { // a UI refresh. Same path used by file imports from sources-view. // Pass the project's stable UUID — the queue looks up the // current filesystem path from the registry at run time. - if (hasUsableLlm(store.llmConfig)) { + if (hasUsableLlm(getTaskLlmConfig("ingest", store.llmConfig))) { enqueueIngest(project.id, clipFilePath).catch((err) => { console.error("Failed to enqueue web clip:", err) }) diff --git a/src/lib/llm-task-routing.test.ts b/src/lib/llm-task-routing.test.ts index 96d0d5e58..f4fba9183 100644 --- a/src/lib/llm-task-routing.test.ts +++ b/src/lib/llm-task-routing.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest" import { resolveProjectLlmConfig, resolveTaskLlmConfig } from "./llm-task-routing" +import { disabledLlmConfig } from "@/components/settings/preset-resolver" import type { LlmConfig } from "@/stores/wiki-store" const fallback: LlmConfig = { @@ -45,6 +46,16 @@ describe("resolveTaskLlmConfig", () => { })).toBe(fallback) }) + it("does not resolve a saved task route after the global provider is disabled", () => { + const disabled = disabledLlmConfig(fallback) + expect(resolveTaskLlmConfig("ingest", disabled, { + anthropic: { apiKey: "saved-key", model: "claude-sonnet-4-6" }, + }, { + chatPresetId: null, + ingestPresetId: "anthropic", + })).toBe(disabled) + }) + it("routes tasks through a user-defined custom provider", () => { const custom = [{ id: "custom-team", label: "Team Gateway" }] expect(resolveTaskLlmConfig( diff --git a/src/lib/llm-task-routing.ts b/src/lib/llm-task-routing.ts index 72e1f811a..69168214d 100644 --- a/src/lib/llm-task-routing.ts +++ b/src/lib/llm-task-routing.ts @@ -1,5 +1,6 @@ import { findLlmPreset } from "@/components/settings/llm-presets" import { resolveConfig } from "@/components/settings/preset-resolver" +import { hasUsableLlm } from "@/lib/has-usable-llm" import type { LlmConfig, ProjectLlmOverride, @@ -50,6 +51,7 @@ export function resolveTaskLlmConfig( customPresets: CustomLlmPreset[] = [], ): LlmConfig { if (projectOverride?.enabled) return fallback + if (!hasUsableLlm(fallback)) return fallback const presetId = task === "chat" ? routing.chatPresetId : routing.ingestPresetId if (!presetId) return fallback const preset = findLlmPreset(presetId, customPresets) diff --git a/src/lib/project-store.ts b/src/lib/project-store.ts index e10c45709..a09dac35c 100644 --- a/src/lib/project-store.ts +++ b/src/lib/project-store.ts @@ -49,6 +49,7 @@ const PROJECT_LLM_OVERRIDES_KEY = "projectLlmOverrides" const CUSTOM_LLM_PRESETS_KEY = "customLlmPresets" let projectLlmOverrideWrite = Promise.resolve() let customLlmPresetWrite = Promise.resolve() +let taskModelRoutingWrite = Promise.resolve() export async function saveLlmConfig(config: LlmConfig): Promise { const store = await getStore() @@ -113,8 +114,12 @@ export async function loadActivePresetId(): Promise { } export async function saveTaskModelRouting(config: TaskModelRoutingConfig): Promise { - const store = await getStore() - await store.set(TASK_MODEL_ROUTING_KEY, config) + const write = taskModelRoutingWrite.then(async () => { + const store = await getStore() + await store.set(TASK_MODEL_ROUTING_KEY, config) + }) + taskModelRoutingWrite = write.catch(() => {}) + await write } export async function loadTaskModelRouting(): Promise { @@ -123,6 +128,7 @@ export async function loadTaskModelRouting(): Promise /** Null keeps ingest on the globally active provider preset. */ ingestPresetId: string | null } From 2d902315a73c5b97f325e7913b2a054adfab1e45 Mon Sep 17 00:00:00 2001 From: "Dongmin, Yu" Date: Thu, 16 Jul 2026 21:21:50 +0900 Subject: [PATCH 2/2] fix: reject project-internal archive exports --- src-tauri/src/commands/project_maintenance.rs | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/project_maintenance.rs b/src-tauri/src/commands/project_maintenance.rs index b2b2bdebe..ec7dd3e04 100644 --- a/src-tauri/src/commands/project_maintenance.rs +++ b/src-tauri/src/commands/project_maintenance.rs @@ -76,6 +76,27 @@ mod tests { let _ = fs::remove_dir_all(target); let _ = fs::remove_file(archive); } + + #[test] + fn export_rejects_destination_that_resolves_inside_project() { + let root = temp("export-inside-project"); + fs::create_dir_all(root.join("wiki")).unwrap(); + fs::create_dir_all(root.join("nested")).unwrap(); + fs::write(root.join("wiki/index.md"), "# Index").unwrap(); + let destination = root.join("nested/../wiki/index.md"); + + let result = export_project_archive_inner( + root.to_string_lossy().into_owned(), + destination.to_string_lossy().into_owned(), + ); + + assert!(result.is_err()); + assert_eq!( + fs::read_to_string(root.join("wiki/index.md")).unwrap(), + "# Index" + ); + let _ = fs::remove_dir_all(root); + } } #[tauri::command] @@ -98,7 +119,21 @@ fn export_project_archive_inner(project_path: String, destination: String) -> Re .canonicalize() .map_err(|e| e.to_string())?; let output = PathBuf::from(destination); - if output.starts_with(&root) { + let parent = output + .parent() + .ok_or_else(|| "Export destination must have a parent directory".to_string())?; + let filename = output + .file_name() + .ok_or_else(|| "Export destination must be a file path".to_string())?; + let resolved_output = if output.exists() { + output.canonicalize().map_err(|e| e.to_string())? + } else { + parent + .canonicalize() + .map_err(|e| e.to_string())? + .join(filename) + }; + if resolved_output.starts_with(&root) { return Err("Export destination must be outside the project directory".into()); } let file = File::create(&output).map_err(|e| e.to_string())?;