diff --git a/MosaicIQ/src-tauri/Cargo.lock b/MosaicIQ/src-tauri/Cargo.lock index 091cdbb..af57a8b 100644 --- a/MosaicIQ/src-tauri/Cargo.lock +++ b/MosaicIQ/src-tauri/Cargo.lock @@ -593,7 +593,7 @@ dependencies = [ "bitflags 2.11.0", "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1087,6 +1087,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1094,7 +1103,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1108,6 +1117,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1725,6 +1740,22 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2304,6 +2335,7 @@ name = "mosaiciq" version = "0.1.0" dependencies = [ "futures", + "reqwest 0.12.28", "rig-core", "serde", "serde_json", @@ -2344,6 +2376,23 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2574,12 +2623,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3320,6 +3407,46 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "reqwest" version = "0.13.2" @@ -3385,7 +3512,7 @@ dependencies = [ "nanoid", "ordered-float", "pin-project-lite", - "reqwest", + "reqwest 0.13.2", "schemars 1.2.1", "serde", "serde_json", @@ -3520,6 +3647,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3773,6 +3906,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.18.0" @@ -4210,7 +4355,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -4605,6 +4750,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5028,6 +5183,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/MosaicIQ/src-tauri/Cargo.toml b/MosaicIQ/src-tauri/Cargo.toml index 37b79a7..38bb493 100644 --- a/MosaicIQ/src-tauri/Cargo.toml +++ b/MosaicIQ/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ rig-core = "0.34.0" tauri-plugin-store = "2" tokio = { version = "1", features = ["time"] } futures = "0.3" +reqwest = { version = "0.12", features = ["json"] } [dev-dependencies] tauri = { version = "2", features = ["test"] } diff --git a/MosaicIQ/src-tauri/src/agent/gateway.rs b/MosaicIQ/src-tauri/src/agent/gateway.rs index bcabff5..b91b070 100644 --- a/MosaicIQ/src-tauri/src/agent/gateway.rs +++ b/MosaicIQ/src-tauri/src/agent/gateway.rs @@ -8,7 +8,7 @@ use rig::{ streaming::StreamedAssistantContent, }; -use crate::agent::AgentRuntimeConfig; +use crate::agent::{AgentRuntimeConfig, ProviderMode}; use crate::error::AppError; const SYSTEM_PROMPT: &str = "You are MosaicIQ's terminal chat assistant. Answer concisely in plain text. Do not claim to run tools, commands, or file operations. If the request is unclear, ask a short clarifying question."; @@ -39,8 +39,13 @@ impl ChatGateway for RigChatGateway { history: Vec, ) -> BoxFuture<'static, Result> { Box::pin(async move { + let _task_profile = runtime.task_profile; + let api_key = match runtime.provider_mode { + ProviderMode::Remote => runtime.api_key.unwrap_or_default(), + ProviderMode::Local => "local".to_string(), + }; let client = openai::CompletionsClient::builder() - .api_key(runtime.api_key) + .api_key(api_key) .base_url(&runtime.base_url) .build() .map_err(|error| AppError::ProviderInit(error.to_string()))?; diff --git a/MosaicIQ/src-tauri/src/agent/mod.rs b/MosaicIQ/src-tauri/src/agent/mod.rs index a848134..5b7836b 100644 --- a/MosaicIQ/src-tauri/src/agent/mod.rs +++ b/MosaicIQ/src-tauri/src/agent/mod.rs @@ -1,6 +1,7 @@ //! Agent domain logic and request/response types. mod gateway; +mod routing; mod service; mod settings; mod types; @@ -8,8 +9,10 @@ mod types; pub use gateway::{ChatGateway, RigChatGateway}; pub use service::AgentService; pub use types::{ - AgentConfigStatus, AgentDeltaEvent, AgentErrorEvent, AgentResultEvent, AgentRuntimeConfig, - AgentStoredSettings, ChatPromptRequest, ChatStreamStart, PreparedChatTurn, - SaveAgentSettingsRequest, UpdateAgentApiKeyRequest, AGENT_SETTINGS_STORE_PATH, - DEFAULT_AGENT_BASE_URL, DEFAULT_AGENT_MODEL, + default_task_defaults, AgentConfigStatus, AgentDeltaEvent, AgentErrorEvent, AgentResultEvent, + AgentRuntimeConfig, AgentStoredSettings, AgentTaskRoute, ChatPromptRequest, ChatStreamStart, + LocalModelList, LocalProviderHealthStatus, LocalProviderSettings, PreparedChatTurn, + ProviderMode, RemoteProviderSettings, SaveAgentRuntimeConfigRequest, TaskProfile, + UpdateRemoteApiKeyRequest, AGENT_SETTINGS_STORE_PATH, DEFAULT_LOCAL_BASE_URL, + DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL, }; diff --git a/MosaicIQ/src-tauri/src/agent/routing.rs b/MosaicIQ/src-tauri/src/agent/routing.rs new file mode 100644 index 0000000..34ecebe --- /dev/null +++ b/MosaicIQ/src-tauri/src/agent/routing.rs @@ -0,0 +1,213 @@ +use crate::agent::{ + AgentRuntimeConfig, AgentStoredSettings, AgentTaskRoute, ProviderMode, TaskProfile, +}; +use crate::error::AppError; + +pub fn resolve_runtime( + settings: &AgentStoredSettings, + task_profile: TaskProfile, + provider_override: Option, + model_override: Option, +) -> Result { + let route = settings + .task_defaults + .get(&task_profile) + .ok_or(AppError::TaskRouteMissing(task_profile))?; + + let provider_mode = provider_override.unwrap_or(route.provider_mode); + let model = resolve_model(settings, task_profile, route, provider_mode, model_override)?; + + match provider_mode { + ProviderMode::Remote => { + if !settings.remote.enabled { + return Err(AppError::ProviderNotConfigured(ProviderMode::Remote)); + } + + let api_key = settings.remote.api_key.trim().to_string(); + if api_key.is_empty() { + return Err(AppError::RemoteApiKeyMissing); + } + + Ok(AgentRuntimeConfig { + provider_mode, + base_url: settings.remote.base_url.clone(), + model, + api_key: Some(api_key), + task_profile, + }) + } + ProviderMode::Local => { + if !settings.local.enabled { + return Err(AppError::ProviderNotConfigured(ProviderMode::Local)); + } + + Ok(AgentRuntimeConfig { + provider_mode, + base_url: settings.local.base_url.clone(), + model, + api_key: None, + task_profile, + }) + } + } +} + +pub fn validate_settings(settings: &AgentStoredSettings) -> Result<(), AppError> { + if settings.remote.base_url.trim().is_empty() { + return Err(AppError::InvalidSettings( + "remote base URL cannot be empty".to_string(), + )); + } + + if settings.local.base_url.trim().is_empty() { + return Err(AppError::InvalidSettings( + "local base URL cannot be empty".to_string(), + )); + } + + if settings.default_remote_model.trim().is_empty() { + return Err(AppError::InvalidSettings( + "default remote model cannot be empty".to_string(), + )); + } + + for task in TaskProfile::all() { + let route = settings + .task_defaults + .get(&task) + .ok_or(AppError::TaskRouteMissing(task))?; + let model = normalize_route_model(settings, task, route.clone())?.model; + + match route.provider_mode { + ProviderMode::Remote => { + if !settings.remote.enabled { + return Err(AppError::ProviderNotConfigured(ProviderMode::Remote)); + } + if model.trim().is_empty() { + return Err(AppError::ModelMissing(task)); + } + } + ProviderMode::Local => { + if !settings.local.enabled { + return Err(AppError::ProviderNotConfigured(ProviderMode::Local)); + } + if model.trim().is_empty() { + return Err(AppError::ModelMissing(task)); + } + } + } + } + + Ok(()) +} + +pub fn normalize_routes(settings: &mut AgentStoredSettings) -> Result<(), AppError> { + for task in TaskProfile::all() { + let route = settings + .task_defaults + .get(&task) + .cloned() + .ok_or(AppError::TaskRouteMissing(task))?; + let normalized = normalize_route_model(settings, task, route)?; + settings.task_defaults.insert(task, normalized); + } + + Ok(()) +} + +pub fn compute_remote_configured(settings: &AgentStoredSettings) -> bool { + settings.remote.enabled + && !settings.remote.base_url.trim().is_empty() + && !settings.remote.api_key.trim().is_empty() + && !settings.default_remote_model.trim().is_empty() +} + +pub fn compute_local_configured(settings: &AgentStoredSettings) -> bool { + settings.local.enabled && !settings.local.base_url.trim().is_empty() +} + +pub fn compute_overall_configured(settings: &AgentStoredSettings) -> bool { + if validate_settings(settings).is_err() { + return false; + } + + let route = match settings.task_defaults.get(&TaskProfile::InteractiveChat) { + Some(route) => route, + None => return false, + }; + + match route.provider_mode { + ProviderMode::Remote => compute_remote_configured(settings), + ProviderMode::Local => compute_local_configured(settings), + } +} + +fn resolve_model( + settings: &AgentStoredSettings, + task_profile: TaskProfile, + route: &AgentTaskRoute, + provider_mode: ProviderMode, + model_override: Option, +) -> Result { + if let Some(model) = model_override { + let trimmed = model.trim(); + if trimmed.is_empty() { + return Err(AppError::ModelMissing(task_profile)); + } + return Ok(trimmed.to_string()); + } + + let normalized = normalize_route_model(settings, task_profile, route.clone())?; + + match provider_mode { + ProviderMode::Remote if normalized.provider_mode == ProviderMode::Local => { + Ok(settings.default_remote_model.clone()) + } + ProviderMode::Local if normalized.provider_mode == ProviderMode::Remote => { + let fallback = settings + .local + .available_models + .first() + .cloned() + .ok_or(AppError::ModelMissing(task_profile))?; + Ok(fallback) + } + _ => Ok(normalized.model), + } +} + +fn normalize_route_model( + settings: &AgentStoredSettings, + task_profile: TaskProfile, + route: AgentTaskRoute, +) -> Result { + let trimmed = route.model.trim(); + + match route.provider_mode { + ProviderMode::Remote => Ok(AgentTaskRoute { + provider_mode: ProviderMode::Remote, + model: if trimmed.is_empty() { + settings.default_remote_model.clone() + } else { + trimmed.to_string() + }, + }), + ProviderMode::Local => { + if !trimmed.is_empty() { + return Ok(AgentTaskRoute { + provider_mode: ProviderMode::Local, + model: trimmed.to_string(), + }); + } + + if let Some(model) = settings.local.available_models.first() { + return Ok(AgentTaskRoute { + provider_mode: ProviderMode::Local, + model: model.clone(), + }); + } + + Err(AppError::ModelMissing(task_profile)) + } + } +} diff --git a/MosaicIQ/src-tauri/src/agent/service.rs b/MosaicIQ/src-tauri/src/agent/service.rs index 7165957..a3ffb49 100644 --- a/MosaicIQ/src-tauri/src/agent/service.rs +++ b/MosaicIQ/src-tauri/src/agent/service.rs @@ -7,11 +7,16 @@ use tauri::{AppHandle, Runtime}; use crate::agent::{ AgentConfigStatus, AgentRuntimeConfig, AgentStoredSettings, ChatPromptRequest, - PreparedChatTurn, RigChatGateway, SaveAgentSettingsRequest, UpdateAgentApiKeyRequest, + LocalProviderSettings, PreparedChatTurn, ProviderMode, RemoteProviderSettings, RigChatGateway, + SaveAgentRuntimeConfigRequest, TaskProfile, UpdateRemoteApiKeyRequest, }; use crate::error::AppError; use super::gateway::ChatGateway; +use super::routing::{ + compute_local_configured, compute_overall_configured, compute_remote_configured, + normalize_routes, resolve_runtime, validate_settings, +}; use super::settings::AgentSettingsService; #[derive(Debug, Default)] @@ -95,7 +100,11 @@ impl AgentService { &mut self, request: ChatPromptRequest, ) -> Result { - let runtime = self.resolve_runtime()?; + let runtime = self.resolve_runtime( + request.agent_profile, + request.provider_override, + request.model_override.clone(), + )?; self.session_manager.prepare_turn(request, runtime) } @@ -115,81 +124,104 @@ impl AgentService { Ok(self.build_status(settings)) } - /// Persist the base URL and model. - pub fn save_settings( + /// Persist the provider and task routing configuration. + pub fn save_runtime_config( &mut self, - request: SaveAgentSettingsRequest, + request: SaveAgentRuntimeConfigRequest, ) -> Result { - let base_url = request.base_url.trim(); - let model = request.model.trim(); - - if base_url.is_empty() { - return Err(AppError::InvalidSettings( - "base URL cannot be empty".to_string(), - )); - } - - if model.is_empty() { - return Err(AppError::InvalidSettings( - "model cannot be empty".to_string(), - )); - } - let mut settings = self.settings.load()?; - settings.base_url = base_url.to_string(); - settings.model = model.to_string(); + settings.remote = RemoteProviderSettings { + enabled: request.remote_enabled, + base_url: request.remote_base_url.trim().to_string(), + api_key: settings.remote.api_key, + }; + settings.local = LocalProviderSettings { + enabled: request.local_enabled, + base_url: request.local_base_url.trim().to_string(), + available_models: normalize_models(request.local_available_models), + }; + settings.default_remote_model = request.default_remote_model.trim().to_string(); + settings.task_defaults = request.task_defaults; + normalize_routes(&mut settings)?; + validate_settings(&settings)?; let persisted = self.settings.save(settings)?; Ok(self.build_status(persisted)) } - /// Save or replace the plaintext API key. - pub fn update_api_key( + /// Save or replace the plaintext remote API key. + pub fn update_remote_api_key( &mut self, - request: UpdateAgentApiKeyRequest, + request: UpdateRemoteApiKeyRequest, ) -> Result { let api_key = request.api_key.trim().to_string(); if api_key.is_empty() { - return Err(AppError::ApiKeyMissing); + return Err(AppError::RemoteApiKeyMissing); } - let settings = self.settings.set_api_key(api_key)?; + let settings = self.settings.set_remote_api_key(api_key)?; Ok(self.build_status(settings)) } - /// Remove the stored API key. - pub fn clear_api_key(&mut self) -> Result { - let settings = self.settings.set_api_key(String::new())?; + /// Remove the stored remote API key. + pub fn clear_remote_api_key(&mut self) -> Result { + let settings = self.settings.set_remote_api_key(String::new())?; Ok(self.build_status(settings)) } fn build_status(&self, settings: AgentStoredSettings) -> AgentConfigStatus { - let has_api_key = !settings.api_key.trim().is_empty(); - AgentConfigStatus { - configured: has_api_key, - has_api_key, - base_url: settings.base_url, - model: settings.model, + configured: compute_overall_configured(&settings), + remote_configured: compute_remote_configured(&settings), + local_configured: compute_local_configured(&settings), + remote_enabled: settings.remote.enabled, + local_enabled: settings.local.enabled, + has_remote_api_key: !settings.remote.api_key.trim().is_empty(), + remote_base_url: settings.remote.base_url, + local_base_url: settings.local.base_url, + default_remote_model: settings.default_remote_model, + local_available_models: settings.local.available_models, + task_defaults: settings.task_defaults, } } - fn resolve_runtime(&self) -> Result { + fn resolve_runtime( + &self, + task_profile: Option, + provider_override: Option, + model_override: Option, + ) -> Result { let settings = self.settings.load()?; - let api_key = settings.api_key.trim().to_string(); - - if api_key.is_empty() { + if !compute_overall_configured(&settings) { return Err(AppError::AgentNotConfigured); } - Ok(AgentRuntimeConfig { - base_url: settings.base_url, - model: settings.model, - api_key, - }) + resolve_runtime( + &settings, + task_profile.unwrap_or(TaskProfile::InteractiveChat), + provider_override, + model_override, + ) } } +fn normalize_models(models: Vec) -> Vec { + let mut normalized = Vec::new(); + + for model in models { + let trimmed = model.trim(); + if trimmed.is_empty() { + continue; + } + + if !normalized.iter().any(|existing| existing == trimmed) { + normalized.push(trimmed.to_string()); + } + } + + normalized +} + #[cfg(test)] mod tests { use std::env; @@ -200,8 +232,9 @@ mod tests { use super::SessionManager; use crate::agent::{ - AgentRuntimeConfig, AgentService, ChatPromptRequest, SaveAgentSettingsRequest, - UpdateAgentApiKeyRequest, DEFAULT_AGENT_BASE_URL, DEFAULT_AGENT_MODEL, + default_task_defaults, AgentRuntimeConfig, AgentService, ChatPromptRequest, ProviderMode, + SaveAgentRuntimeConfigRequest, TaskProfile, UpdateRemoteApiKeyRequest, + DEFAULT_LOCAL_BASE_URL, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL, }; use crate::error::AppError; @@ -217,12 +250,11 @@ mod tests { workspace_id: "workspace-1".to_string(), session_id: None, prompt: " ".to_string(), + agent_profile: None, + model_override: None, + provider_override: None, }, - AgentRuntimeConfig { - base_url: "https://example.com".to_string(), - model: "glm-5.1".to_string(), - api_key: "key".to_string(), - }, + sample_runtime(), ); assert_eq!(result.unwrap_err(), AppError::EmptyPrompt); @@ -238,12 +270,11 @@ mod tests { workspace_id: "workspace-1".to_string(), session_id: None, prompt: "Summarize AAPL".to_string(), + agent_profile: None, + model_override: None, + provider_override: None, }, - AgentRuntimeConfig { - base_url: "https://example.com".to_string(), - model: "glm-5.1".to_string(), - api_key: "key".to_string(), - }, + sample_runtime(), ) .unwrap(); @@ -262,12 +293,11 @@ mod tests { workspace_id: "workspace-1".to_string(), session_id: Some(session_id.clone()), prompt: "First prompt".to_string(), + agent_profile: None, + model_override: None, + provider_override: None, }, - AgentRuntimeConfig { - base_url: "https://example.com".to_string(), - model: "glm-5.1".to_string(), - api_key: "key".to_string(), - }, + sample_runtime(), ) .unwrap(); sessions @@ -280,12 +310,11 @@ mod tests { workspace_id: "workspace-1".to_string(), session_id: Some(session_id), prompt: "Second prompt".to_string(), + agent_profile: None, + model_override: None, + provider_override: None, }, - AgentRuntimeConfig { - base_url: "https://example.com".to_string(), - model: "glm-5.1".to_string(), - api_key: "key".to_string(), - }, + sample_runtime(), ) .unwrap(); @@ -302,66 +331,290 @@ mod tests { let initial = service.get_config_status().unwrap(); assert!(!initial.configured); - assert!(!initial.has_api_key); - assert_eq!(initial.base_url, DEFAULT_AGENT_BASE_URL); - assert_eq!(initial.model, DEFAULT_AGENT_MODEL); + assert!(!initial.has_remote_api_key); + assert_eq!(initial.remote_base_url, DEFAULT_REMOTE_BASE_URL); + assert_eq!(initial.local_base_url, DEFAULT_LOCAL_BASE_URL); + assert_eq!(initial.default_remote_model, DEFAULT_REMOTE_MODEL); let saved = service - .save_settings(SaveAgentSettingsRequest { - base_url: "https://example.test/v4".to_string(), - model: "glm-test".to_string(), + .save_runtime_config(SaveAgentRuntimeConfigRequest { + remote_enabled: true, + remote_base_url: "https://example.test/v4".to_string(), + default_remote_model: "glm-test".to_string(), + local_enabled: true, + local_base_url: "http://127.0.0.1:1234".to_string(), + local_available_models: vec!["qwen-small".to_string()], + task_defaults: default_task_defaults("glm-test"), }) .unwrap(); - assert_eq!(saved.base_url, "https://example.test/v4"); - assert_eq!(saved.model, "glm-test"); - assert!(!saved.has_api_key); + assert_eq!(saved.remote_base_url, "https://example.test/v4"); + assert_eq!(saved.default_remote_model, "glm-test"); + assert!(!saved.has_remote_api_key); let updated = service - .update_api_key(UpdateAgentApiKeyRequest { + .update_remote_api_key(UpdateRemoteApiKeyRequest { api_key: "z-ai-key-1".to_string(), }) .unwrap(); assert!(updated.configured); - assert!(updated.has_api_key); + assert!(updated.has_remote_api_key); let prepared = service .prepare_turn(ChatPromptRequest { workspace_id: "workspace-1".to_string(), session_id: None, prompt: "hello".to_string(), + agent_profile: None, + model_override: None, + provider_override: None, }) .unwrap(); assert_eq!(prepared.runtime.base_url, "https://example.test/v4"); assert_eq!(prepared.runtime.model, "glm-test"); - assert_eq!(prepared.runtime.api_key, "z-ai-key-1"); + assert_eq!(prepared.runtime.provider_mode, ProviderMode::Remote); + assert_eq!(prepared.runtime.api_key.as_deref(), Some("z-ai-key-1")); }); } #[test] - fn clears_plaintext_api_key_from_store() { + fn clears_plaintext_remote_api_key_from_store() { with_test_home("clear", || { let app = build_test_app(); let mut service = AgentService::new(&app.handle()).unwrap(); service - .update_api_key(UpdateAgentApiKeyRequest { + .save_runtime_config(SaveAgentRuntimeConfigRequest { + remote_enabled: true, + remote_base_url: "https://example.test/v4".to_string(), + default_remote_model: "glm-test".to_string(), + local_enabled: false, + local_base_url: DEFAULT_LOCAL_BASE_URL.to_string(), + local_available_models: Vec::new(), + task_defaults: default_task_defaults("glm-test"), + }) + .unwrap(); + + service + .update_remote_api_key(UpdateRemoteApiKeyRequest { api_key: "z-ai-key-1".to_string(), }) .unwrap(); - let cleared = service.clear_api_key().unwrap(); + let cleared = service.clear_remote_api_key().unwrap(); assert!(!cleared.configured); - assert!(!cleared.has_api_key); + assert!(!cleared.has_remote_api_key); let result = service.prepare_turn(ChatPromptRequest { workspace_id: "workspace-1".to_string(), session_id: None, prompt: "hello".to_string(), + agent_profile: None, + model_override: None, + provider_override: None, }); assert_eq!(result.unwrap_err(), AppError::AgentNotConfigured); }); } + #[test] + fn local_task_route_resolves_without_remote_api_key() { + with_test_home("local-route", || { + let app = build_test_app(); + let mut service = AgentService::new(&app.handle()).unwrap(); + let mut task_defaults = default_task_defaults("glm-test"); + task_defaults.insert( + TaskProfile::InteractiveChat, + crate::agent::AgentTaskRoute { + provider_mode: ProviderMode::Local, + model: "qwen-local".to_string(), + }, + ); + + service + .save_runtime_config(SaveAgentRuntimeConfigRequest { + remote_enabled: true, + remote_base_url: "https://example.test/v4".to_string(), + default_remote_model: "glm-test".to_string(), + local_enabled: true, + local_base_url: "http://127.0.0.1:1234".to_string(), + local_available_models: vec!["qwen-local".to_string()], + task_defaults, + }) + .unwrap(); + + let prepared = service + .prepare_turn(ChatPromptRequest { + workspace_id: "workspace-1".to_string(), + session_id: None, + prompt: "hello".to_string(), + agent_profile: Some(TaskProfile::InteractiveChat), + model_override: None, + provider_override: None, + }) + .unwrap(); + + assert_eq!(prepared.runtime.provider_mode, ProviderMode::Local); + assert_eq!(prepared.runtime.model, "qwen-local"); + assert_eq!(prepared.runtime.api_key, None); + }); + } + + #[test] + fn provider_override_replaces_task_default() { + with_test_home("provider-override", || { + let app = build_test_app(); + let mut service = AgentService::new(&app.handle()).unwrap(); + let mut task_defaults = default_task_defaults("glm-test"); + task_defaults.insert( + TaskProfile::InteractiveChat, + crate::agent::AgentTaskRoute { + provider_mode: ProviderMode::Local, + model: "qwen-local".to_string(), + }, + ); + + service + .save_runtime_config(SaveAgentRuntimeConfigRequest { + remote_enabled: true, + remote_base_url: "https://example.test/v4".to_string(), + default_remote_model: "glm-test".to_string(), + local_enabled: true, + local_base_url: "http://127.0.0.1:1234".to_string(), + local_available_models: vec!["qwen-local".to_string()], + task_defaults, + }) + .unwrap(); + service + .update_remote_api_key(UpdateRemoteApiKeyRequest { + api_key: "z-ai-key-1".to_string(), + }) + .unwrap(); + + let prepared = service + .prepare_turn(ChatPromptRequest { + workspace_id: "workspace-1".to_string(), + session_id: None, + prompt: "hello".to_string(), + agent_profile: Some(TaskProfile::InteractiveChat), + model_override: None, + provider_override: Some(ProviderMode::Remote), + }) + .unwrap(); + + assert_eq!(prepared.runtime.provider_mode, ProviderMode::Remote); + assert_eq!(prepared.runtime.model, "glm-test"); + assert_eq!(prepared.runtime.api_key.as_deref(), Some("z-ai-key-1")); + }); + } + + #[test] + fn model_override_replaces_task_default() { + with_test_home("model-override", || { + let app = build_test_app(); + let mut service = AgentService::new(&app.handle()).unwrap(); + service + .save_runtime_config(SaveAgentRuntimeConfigRequest { + remote_enabled: true, + remote_base_url: "https://example.test/v4".to_string(), + default_remote_model: "glm-test".to_string(), + local_enabled: false, + local_base_url: DEFAULT_LOCAL_BASE_URL.to_string(), + local_available_models: Vec::new(), + task_defaults: default_task_defaults("glm-test"), + }) + .unwrap(); + service + .update_remote_api_key(UpdateRemoteApiKeyRequest { + api_key: "z-ai-key-1".to_string(), + }) + .unwrap(); + + let prepared = service + .prepare_turn(ChatPromptRequest { + workspace_id: "workspace-1".to_string(), + session_id: None, + prompt: "hello".to_string(), + agent_profile: None, + model_override: Some("glm-override".to_string()), + provider_override: None, + }) + .unwrap(); + + assert_eq!(prepared.runtime.model, "glm-override"); + }); + } + + #[test] + fn local_task_without_model_fails_validation() { + with_test_home("local-validation", || { + let app = build_test_app(); + let mut service = AgentService::new(&app.handle()).unwrap(); + let mut task_defaults = default_task_defaults("glm-test"); + task_defaults.insert( + TaskProfile::InteractiveChat, + crate::agent::AgentTaskRoute { + provider_mode: ProviderMode::Local, + model: String::new(), + }, + ); + + let result = service.save_runtime_config(SaveAgentRuntimeConfigRequest { + remote_enabled: true, + remote_base_url: "https://example.test/v4".to_string(), + default_remote_model: "glm-test".to_string(), + local_enabled: true, + local_base_url: "http://127.0.0.1:1234".to_string(), + local_available_models: Vec::new(), + task_defaults, + }); + + assert_eq!( + result.unwrap_err(), + AppError::ModelMissing(TaskProfile::InteractiveChat) + ); + }); + } + + #[test] + fn remote_task_without_api_key_fails_validation_at_prepare_time() { + with_test_home("remote-validation", || { + let app = build_test_app(); + let mut service = AgentService::new(&app.handle()).unwrap(); + service + .save_runtime_config(SaveAgentRuntimeConfigRequest { + remote_enabled: true, + remote_base_url: "https://example.test/v4".to_string(), + default_remote_model: "glm-test".to_string(), + local_enabled: false, + local_base_url: DEFAULT_LOCAL_BASE_URL.to_string(), + local_available_models: Vec::new(), + task_defaults: default_task_defaults("glm-test"), + }) + .unwrap(); + + let result = service.prepare_turn(ChatPromptRequest { + workspace_id: "workspace-1".to_string(), + session_id: None, + prompt: "hello".to_string(), + agent_profile: None, + model_override: None, + provider_override: None, + }); + + assert_eq!(result.unwrap_err(), AppError::AgentNotConfigured); + }); + } + + fn sample_runtime() -> AgentRuntimeConfig { + AgentRuntimeConfig { + provider_mode: ProviderMode::Remote, + base_url: "https://example.com".to_string(), + model: "glm-5.1".to_string(), + api_key: Some("key".to_string()), + task_profile: TaskProfile::InteractiveChat, + } + } + fn build_test_app() -> tauri::App { mock_builder() .plugin(tauri_plugin_store::Builder::new().build()) diff --git a/MosaicIQ/src-tauri/src/agent/settings.rs b/MosaicIQ/src-tauri/src/agent/settings.rs index 3c7f5bd..76c9845 100644 --- a/MosaicIQ/src-tauri/src/agent/settings.rs +++ b/MosaicIQ/src-tauri/src/agent/settings.rs @@ -3,13 +3,23 @@ use tauri::{AppHandle, Runtime}; use tauri_plugin_store::StoreExt; use crate::agent::{ - AgentStoredSettings, AGENT_SETTINGS_STORE_PATH, DEFAULT_AGENT_BASE_URL, DEFAULT_AGENT_MODEL, + default_task_defaults, AgentStoredSettings, LocalProviderSettings, RemoteProviderSettings, + AGENT_SETTINGS_STORE_PATH, DEFAULT_LOCAL_BASE_URL, DEFAULT_REMOTE_BASE_URL, + DEFAULT_REMOTE_MODEL, }; use crate::error::AppError; -const BASE_URL_KEY: &str = "baseUrl"; -const MODEL_KEY: &str = "model"; -const API_KEY_KEY: &str = "apiKey"; +const REMOTE_ENABLED_KEY: &str = "remoteEnabled"; +const REMOTE_BASE_URL_KEY: &str = "remoteBaseUrl"; +const REMOTE_API_KEY_KEY: &str = "remoteApiKey"; +const DEFAULT_REMOTE_MODEL_KEY: &str = "defaultRemoteModel"; +const LOCAL_ENABLED_KEY: &str = "localEnabled"; +const LOCAL_BASE_URL_KEY: &str = "localBaseUrl"; +const LOCAL_AVAILABLE_MODELS_KEY: &str = "localAvailableModels"; +const TASK_DEFAULTS_KEY: &str = "taskDefaults"; +const LEGACY_BASE_URL_KEY: &str = "baseUrl"; +const LEGACY_MODEL_KEY: &str = "model"; +const LEGACY_API_KEY_KEY: &str = "apiKey"; /// Manages the provider settings and plaintext API key stored through the Tauri store plugin. #[derive(Debug, Clone)] @@ -32,19 +42,62 @@ impl AgentSettingsService { .store(AGENT_SETTINGS_STORE_PATH) .map_err(|error| AppError::SettingsStore(error.to_string()))?; + let default_remote_model = store + .get(DEFAULT_REMOTE_MODEL_KEY) + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .or_else(|| { + store + .get(LEGACY_MODEL_KEY) + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + }) + .unwrap_or_else(|| DEFAULT_REMOTE_MODEL.to_string()); + + let task_defaults = store + .get(TASK_DEFAULTS_KEY) + .and_then(|value| serde_json::from_value(value.clone()).ok()) + .unwrap_or_else(|| default_task_defaults(&default_remote_model)); + Ok(AgentStoredSettings { - base_url: store - .get(BASE_URL_KEY) - .and_then(|value| value.as_str().map(ToOwned::to_owned)) - .unwrap_or_else(|| DEFAULT_AGENT_BASE_URL.to_string()), - model: store - .get(MODEL_KEY) - .and_then(|value| value.as_str().map(ToOwned::to_owned)) - .unwrap_or_else(|| DEFAULT_AGENT_MODEL.to_string()), - api_key: store - .get(API_KEY_KEY) - .and_then(|value| value.as_str().map(ToOwned::to_owned)) - .unwrap_or_default(), + remote: RemoteProviderSettings { + enabled: store + .get(REMOTE_ENABLED_KEY) + .and_then(|value| value.as_bool()) + .unwrap_or(true), + base_url: store + .get(REMOTE_BASE_URL_KEY) + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .or_else(|| { + store + .get(LEGACY_BASE_URL_KEY) + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + }) + .unwrap_or_else(|| DEFAULT_REMOTE_BASE_URL.to_string()), + api_key: store + .get(REMOTE_API_KEY_KEY) + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .or_else(|| { + store + .get(LEGACY_API_KEY_KEY) + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + }) + .unwrap_or_default(), + }, + local: LocalProviderSettings { + enabled: store + .get(LOCAL_ENABLED_KEY) + .and_then(|value| value.as_bool()) + .unwrap_or(false), + base_url: store + .get(LOCAL_BASE_URL_KEY) + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| DEFAULT_LOCAL_BASE_URL.to_string()), + available_models: store + .get(LOCAL_AVAILABLE_MODELS_KEY) + .and_then(|value| serde_json::from_value(value.clone()).ok()) + .unwrap_or_default(), + }, + default_remote_model, + task_defaults, }) } @@ -54,10 +107,10 @@ impl AgentSettingsService { Ok(settings) } - /// Update only the plaintext API key. - pub fn set_api_key(&self, api_key: String) -> Result { + /// Update only the plaintext remote API key. + pub fn set_remote_api_key(&self, api_key: String) -> Result { let mut settings = self.load()?; - settings.api_key = api_key; + settings.remote.api_key = api_key; self.save_inner(&settings)?; Ok(settings) } @@ -68,11 +121,37 @@ impl AgentSettingsService { .store(AGENT_SETTINGS_STORE_PATH) .map_err(|error| AppError::SettingsStore(error.to_string()))?; - // The API key is intentionally persisted in plain text per the current - // product requirement, so it lives in the same store as the runtime config. - store.set(BASE_URL_KEY.to_string(), json!(settings.base_url)); - store.set(MODEL_KEY.to_string(), json!(settings.model)); - store.set(API_KEY_KEY.to_string(), json!(settings.api_key)); + store.set( + REMOTE_ENABLED_KEY.to_string(), + json!(settings.remote.enabled), + ); + store.set( + REMOTE_BASE_URL_KEY.to_string(), + json!(settings.remote.base_url), + ); + store.set( + REMOTE_API_KEY_KEY.to_string(), + json!(settings.remote.api_key), + ); + store.set( + DEFAULT_REMOTE_MODEL_KEY.to_string(), + json!(settings.default_remote_model), + ); + store.set(LOCAL_ENABLED_KEY.to_string(), json!(settings.local.enabled)); + store.set( + LOCAL_BASE_URL_KEY.to_string(), + json!(settings.local.base_url), + ); + store.set( + LOCAL_AVAILABLE_MODELS_KEY.to_string(), + json!(settings.local.available_models), + ); + store.set(TASK_DEFAULTS_KEY.to_string(), json!(settings.task_defaults)); + + // Remove legacy flat keys after writing the new schema. + store.delete(LEGACY_BASE_URL_KEY); + store.delete(LEGACY_MODEL_KEY); + store.delete(LEGACY_API_KEY_KEY); store .save() .map_err(|error| AppError::SettingsStore(error.to_string())) diff --git a/MosaicIQ/src-tauri/src/agent/types.rs b/MosaicIQ/src-tauri/src/agent/types.rs index 6f19b06..9cca02c 100644 --- a/MosaicIQ/src-tauri/src/agent/types.rs +++ b/MosaicIQ/src-tauri/src/agent/types.rs @@ -1,13 +1,34 @@ use rig::completion::Message; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; /// Default Z.AI coding plan endpoint used by the app. -pub const DEFAULT_AGENT_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4"; +pub const DEFAULT_REMOTE_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4"; /// Default model used for plain-text terminal chat. -pub const DEFAULT_AGENT_MODEL: &str = "glm-5.1"; +pub const DEFAULT_REMOTE_MODEL: &str = "glm-5.1"; +/// Default local Mistral HTTP sidecar URL. +pub const DEFAULT_LOCAL_BASE_URL: &str = "http://127.0.0.1:1234"; /// Store file used for agent settings and plaintext API key storage. pub const AGENT_SETTINGS_STORE_PATH: &str = "agent-settings.json"; +/// Supported runtime provider modes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ProviderMode { + Remote, + Local, +} + +/// Stable harness task profiles that can be routed independently. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TaskProfile { + InteractiveChat, + Analysis, + Summarization, + ToolUse, +} + /// Request payload for an interactive chat turn. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -18,17 +39,27 @@ pub struct ChatPromptRequest { pub session_id: Option, /// User-entered prompt content. pub prompt: String, + /// Optional task profile used to resolve provider and model defaults. + pub agent_profile: Option, + /// Optional one-off model override for the current request. + pub model_override: Option, + /// Optional one-off provider override for the current request. + pub provider_override: Option, } /// Runtime provider configuration after settings resolution. #[derive(Debug, Clone)] pub struct AgentRuntimeConfig { - /// OpenAI-compatible base URL. + /// Resolved provider mode for this turn. + pub provider_mode: ProviderMode, + /// OpenAI-compatible base URL for remote or local Mistral HTTP. pub base_url: String, /// Upstream model identifier. pub model: String, - /// Runtime API key loaded from plaintext application storage. - pub api_key: String, + /// Optional runtime API key loaded from plaintext application storage. + pub api_key: Option, + /// Task profile used to resolve this target. + pub task_profile: TaskProfile, } /// Prepared chat turn after validation and session history lookup. @@ -102,20 +133,23 @@ pub struct AgentErrorEvent { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AgentStoredSettings { - /// OpenAI-compatible base URL. - pub base_url: String, - /// Upstream model identifier. - pub model: String, - /// Plaintext API key saved in the application store. - pub api_key: String, + /// Remote OpenAI-compatible provider configuration. + pub remote: RemoteProviderSettings, + /// Local Mistral HTTP provider configuration. + pub local: LocalProviderSettings, + /// Default remote model used when a task route does not override it. + pub default_remote_model: String, + /// Default route per task profile. + pub task_defaults: HashMap, } impl Default for AgentStoredSettings { fn default() -> Self { Self { - base_url: DEFAULT_AGENT_BASE_URL.to_string(), - model: DEFAULT_AGENT_MODEL.to_string(), - api_key: String::new(), + remote: RemoteProviderSettings::default(), + local: LocalProviderSettings::default(), + default_remote_model: DEFAULT_REMOTE_MODEL.to_string(), + task_defaults: default_task_defaults(DEFAULT_REMOTE_MODEL), } } } @@ -126,28 +160,151 @@ impl Default for AgentStoredSettings { pub struct AgentConfigStatus { /// Whether the app has everything needed to start chat immediately. pub configured: bool, - /// Whether the app currently has an API key stored. - pub has_api_key: bool, - /// Current provider base URL. - pub base_url: String, - /// Current provider model. - pub model: String, + /// Whether the remote provider has enough config for a routed request. + pub remote_configured: bool, + /// Whether the local provider has enough config for a routed request. + pub local_configured: bool, + /// Whether the remote provider is enabled in settings. + pub remote_enabled: bool, + /// Whether the local provider is enabled in settings. + pub local_enabled: bool, + /// Whether a remote API key is currently stored. + pub has_remote_api_key: bool, + /// Current remote provider base URL. + pub remote_base_url: String, + /// Current local provider base URL. + pub local_base_url: String, + /// Current default remote model. + pub default_remote_model: String, + /// Current available local model suggestions. + pub local_available_models: Vec, + /// Current route defaults per task profile. + pub task_defaults: HashMap, } /// Request payload for updating persisted non-secret settings. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct SaveAgentSettingsRequest { - /// OpenAI-compatible base URL. - pub base_url: String, - /// Upstream model identifier. - pub model: String, +pub struct SaveAgentRuntimeConfigRequest { + /// Whether the remote provider is enabled. + pub remote_enabled: bool, + /// Remote OpenAI-compatible base URL. + pub remote_base_url: String, + /// Default model used for remote-routed tasks. + pub default_remote_model: String, + /// Whether the local provider is enabled. + pub local_enabled: bool, + /// Local Mistral HTTP base URL. + pub local_base_url: String, + /// User-provided local model suggestions. + pub local_available_models: Vec, + /// Default task routes. + pub task_defaults: HashMap, } -/// Request payload for rotating the stored API key. +/// Request payload for rotating the stored remote API key. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct UpdateAgentApiKeyRequest { +pub struct UpdateRemoteApiKeyRequest { /// Replacement plaintext API key to store. pub api_key: String, } + +/// Remote provider settings persisted in the application store. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RemoteProviderSettings { + /// Whether the provider can be selected by task routing. + pub enabled: bool, + /// OpenAI-compatible base URL. + pub base_url: String, + /// Plaintext API key saved in the application store. + pub api_key: String, +} + +impl Default for RemoteProviderSettings { + fn default() -> Self { + Self { + enabled: true, + base_url: DEFAULT_REMOTE_BASE_URL.to_string(), + api_key: String::new(), + } + } +} + +/// Local provider settings persisted in the application store. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LocalProviderSettings { + /// Whether the local provider can be selected by task routing. + pub enabled: bool, + /// Local Mistral HTTP base URL. + pub base_url: String, + /// User-provided local model suggestions. + pub available_models: Vec, +} + +impl Default for LocalProviderSettings { + fn default() -> Self { + Self { + enabled: false, + base_url: DEFAULT_LOCAL_BASE_URL.to_string(), + available_models: Vec::new(), + } + } +} + +/// Default provider/model assignment for a task profile. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AgentTaskRoute { + /// Provider selected for the task by default. + pub provider_mode: ProviderMode, + /// Model selected for the task by default. + pub model: String, +} + +/// Response payload for local provider health checks. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalProviderHealthStatus { + /// Whether the local provider responded successfully. + pub reachable: bool, + /// Optional user-visible status detail. + pub message: String, +} + +/// Response payload for local model listing. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalModelList { + /// Whether model discovery reached the local provider. + pub reachable: bool, + /// Unique model names combined from discovery and stored settings. + pub models: Vec, +} + +pub fn default_task_defaults(default_remote_model: &str) -> HashMap { + let mut defaults = HashMap::new(); + for task in TaskProfile::all() { + defaults.insert( + task, + AgentTaskRoute { + provider_mode: ProviderMode::Remote, + model: default_remote_model.to_string(), + }, + ); + } + defaults +} + +impl TaskProfile { + pub const fn all() -> [TaskProfile; 4] { + [ + TaskProfile::InteractiveChat, + TaskProfile::Analysis, + TaskProfile::Summarization, + TaskProfile::ToolUse, + ] + } +} diff --git a/MosaicIQ/src-tauri/src/commands/settings.rs b/MosaicIQ/src-tauri/src/commands/settings.rs index 0ce98cc..5157a21 100644 --- a/MosaicIQ/src-tauri/src/commands/settings.rs +++ b/MosaicIQ/src-tauri/src/commands/settings.rs @@ -1,4 +1,9 @@ -use crate::agent::{AgentConfigStatus, SaveAgentSettingsRequest, UpdateAgentApiKeyRequest}; +use serde::Deserialize; + +use crate::agent::{ + AgentConfigStatus, LocalModelList, LocalProviderHealthStatus, SaveAgentRuntimeConfigRequest, + UpdateRemoteApiKeyRequest, +}; use crate::state::AppState; /// Return the current public configuration state for the AI chat runtime. @@ -14,11 +19,11 @@ pub async fn get_agent_config_status( agent.get_config_status().map_err(|error| error.to_string()) } -/// Persist the non-secret base URL and model settings. +/// Persist the non-secret provider and task routing settings. #[tauri::command] -pub async fn save_agent_settings( +pub async fn save_agent_runtime_config( state: tauri::State<'_, AppState>, - request: SaveAgentSettingsRequest, + request: SaveAgentRuntimeConfigRequest, ) -> Result { let mut agent = state .agent @@ -26,15 +31,15 @@ pub async fn save_agent_settings( .map_err(|_| "agent state is unavailable".to_string())?; agent - .save_settings(request) + .save_runtime_config(request) .map_err(|error| error.to_string()) } -/// Save or replace the plaintext API key. +/// Save or replace the plaintext remote API key. #[tauri::command] -pub async fn update_agent_api_key( +pub async fn update_remote_api_key( state: tauri::State<'_, AppState>, - request: UpdateAgentApiKeyRequest, + request: UpdateRemoteApiKeyRequest, ) -> Result { let mut agent = state .agent @@ -42,13 +47,13 @@ pub async fn update_agent_api_key( .map_err(|_| "agent state is unavailable".to_string())?; agent - .update_api_key(request) + .update_remote_api_key(request) .map_err(|error| error.to_string()) } -/// Remove the stored plaintext API key. +/// Remove the stored plaintext remote API key. #[tauri::command] -pub async fn clear_agent_api_key( +pub async fn clear_remote_api_key( state: tauri::State<'_, AppState>, ) -> Result { let mut agent = state @@ -56,5 +61,104 @@ pub async fn clear_agent_api_key( .lock() .map_err(|_| "agent state is unavailable".to_string())?; - agent.clear_api_key().map_err(|error| error.to_string()) + agent + .clear_remote_api_key() + .map_err(|error| error.to_string()) +} + +/// Lists local models from the running Mistral HTTP endpoint and stored settings. +#[tauri::command] +pub async fn list_local_models( + state: tauri::State<'_, AppState>, +) -> Result { + let status = { + let agent = state + .agent + .lock() + .map_err(|_| "agent state is unavailable".to_string())?; + agent + .get_config_status() + .map_err(|error| error.to_string())? + }; + + let discovered = fetch_local_models(&status.local_base_url).await; + let mut models = status.local_available_models; + + if let Ok(mut discovered_models) = discovered { + for model in discovered_models.drain(..) { + if !models.iter().any(|existing| existing == &model) { + models.push(model); + } + } + + return Ok(LocalModelList { + reachable: true, + models, + }); + } + + Ok(LocalModelList { + reachable: false, + models, + }) +} + +/// Checks whether the local Mistral HTTP provider is reachable. +#[tauri::command] +pub async fn check_local_provider_health( + state: tauri::State<'_, AppState>, +) -> Result { + let local_base_url = { + let agent = state + .agent + .lock() + .map_err(|_| "agent state is unavailable".to_string())?; + agent + .get_config_status() + .map_err(|error| error.to_string())? + .local_base_url + }; + + match fetch_local_models(&local_base_url).await { + Ok(models) => Ok(LocalProviderHealthStatus { + reachable: true, + message: format!("Local provider reachable with {} model(s).", models.len()), + }), + Err(error) => Ok(LocalProviderHealthStatus { + reachable: false, + message: error, + }), + } +} + +#[derive(Debug, Deserialize)] +struct OpenAiModelListResponse { + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct OpenAiModelDescriptor { + id: String, +} + +async fn fetch_local_models(base_url: &str) -> Result, String> { + let base_url = base_url.trim_end_matches('/'); + let endpoint = format!("{base_url}/v1/models"); + let response = reqwest::get(&endpoint) + .await + .map_err(|error| format!("Failed to reach local provider: {error}"))?; + + if !response.status().is_success() { + return Err(format!( + "Local provider health check failed with status {}", + response.status() + )); + } + + let payload = response + .json::() + .await + .map_err(|error| format!("Failed to parse local provider model list: {error}"))?; + + Ok(payload.data.into_iter().map(|model| model.id).collect()) } diff --git a/MosaicIQ/src-tauri/src/error.rs b/MosaicIQ/src-tauri/src/error.rs index 7587c8e..5037188 100644 --- a/MosaicIQ/src-tauri/src/error.rs +++ b/MosaicIQ/src-tauri/src/error.rs @@ -1,17 +1,22 @@ use std::error::Error; use std::fmt::{Display, Formatter}; +use crate::agent::{ProviderMode, TaskProfile}; + /// Backend error type for application-level validation and runtime failures. #[derive(Debug, PartialEq, Eq)] pub enum AppError { EmptyPrompt, AgentNotConfigured, - ApiKeyMissing, + RemoteApiKeyMissing, InvalidSettings(String), UnknownSession(String), SettingsStore(String), ProviderInit(String), ProviderRequest(String), + ProviderNotConfigured(ProviderMode), + TaskRouteMissing(TaskProfile), + ModelMissing(TaskProfile), } impl Display for AppError { @@ -19,9 +24,9 @@ impl Display for AppError { match self { Self::EmptyPrompt => formatter.write_str("prompt cannot be empty"), Self::AgentNotConfigured => formatter.write_str( - "AI chat is not configured yet. Open AI Settings to save a model and API key.", + "AI chat is not configured yet. Open AI Settings to configure a provider and task route.", ), - Self::ApiKeyMissing => formatter.write_str("API key cannot be empty"), + Self::RemoteApiKeyMissing => formatter.write_str("remote API key cannot be empty"), Self::InvalidSettings(message) => formatter.write_str(message), Self::UnknownSession(session_id) => { write!(formatter, "unknown session: {session_id}") @@ -35,6 +40,18 @@ impl Display for AppError { Self::ProviderRequest(message) => { write!(formatter, "AI provider request failed: {message}") } + Self::ProviderNotConfigured(ProviderMode::Remote) => formatter.write_str( + "remote provider is not configured. Save a remote base URL, model, and API key.", + ), + Self::ProviderNotConfigured(ProviderMode::Local) => formatter.write_str( + "local provider is not configured. Save a local base URL and local task model.", + ), + Self::TaskRouteMissing(task) => { + write!(formatter, "task route is missing for {task:?}") + } + Self::ModelMissing(task) => { + write!(formatter, "model is missing for task {task:?}") + } } } } diff --git a/MosaicIQ/src-tauri/src/lib.rs b/MosaicIQ/src-tauri/src/lib.rs index 0de184e..7687bd4 100644 --- a/MosaicIQ/src-tauri/src/lib.rs +++ b/MosaicIQ/src-tauri/src/lib.rs @@ -29,9 +29,11 @@ pub fn run() { commands::terminal::execute_terminal_command, commands::terminal::start_chat_stream, commands::settings::get_agent_config_status, - commands::settings::save_agent_settings, - commands::settings::update_agent_api_key, - commands::settings::clear_agent_api_key + commands::settings::save_agent_runtime_config, + commands::settings::update_remote_api_key, + commands::settings::clear_remote_api_key, + commands::settings::list_local_models, + commands::settings::check_local_provider_health ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/MosaicIQ/src/App.tsx b/MosaicIQ/src/App.tsx index 4e9a840..c9c0b83 100644 --- a/MosaicIQ/src/App.tsx +++ b/MosaicIQ/src/App.tsx @@ -127,6 +127,7 @@ function App() { workspaceId, sessionId: currentWorkspace?.chatSessionId, prompt: trimmedCommand, + agentProfile: 'interactiveChat', }, { onDelta: (event) => { diff --git a/MosaicIQ/src/components/Settings/AgentSettingsModal.tsx b/MosaicIQ/src/components/Settings/AgentSettingsModal.tsx index 93e1280..eed47db 100644 --- a/MosaicIQ/src/components/Settings/AgentSettingsModal.tsx +++ b/MosaicIQ/src/components/Settings/AgentSettingsModal.tsx @@ -1,6 +1,13 @@ import React, { useEffect, useState } from 'react'; import { agentSettingsBridge } from '../../lib/agentSettingsBridge'; -import { AgentConfigStatus } from '../../types/agentSettings'; +import { + AgentConfigStatus, + AgentTaskRoute, + ProviderMode, + TASK_LABELS, + TASK_PROFILES, + TaskProfile, +} from '../../types/agentSettings'; interface AgentSettingsModalProps { isOpen: boolean; @@ -12,17 +19,56 @@ interface AgentSettingsModalProps { const inputClassName = 'w-full rounded border border-[#2a2a2a] bg-[#111111] px-3 py-2 text-sm font-mono text-[#e0e0e0] outline-none transition-colors focus:border-[#58a6ff]'; +const textareaClassName = `${inputClassName} min-h-28 resize-y`; + +const buttonClassName = + 'rounded border border-[#2a2a2a] bg-[#151515] px-3 py-2 text-xs font-mono text-[#e0e0e0] transition-colors hover:border-[#58a6ff] hover:text-[#58a6ff] disabled:cursor-not-allowed disabled:opacity-50'; + +const normalizeModelList = (value: string): string[] => { + const items = value + .split(/[\n,]/) + .map((item) => item.trim()) + .filter(Boolean); + + return Array.from(new Set(items)); +}; + +const mergeTaskDefaults = ( + taskDefaults: Partial>, + defaultRemoteModel: string, + localModels: string[], +): Record => { + const fallbackLocalModel = localModels[0] ?? ''; + + return TASK_PROFILES.reduce((acc, profile) => { + const existing = taskDefaults[profile]; + acc[profile] = existing ?? { + providerMode: 'remote', + model: defaultRemoteModel || fallbackLocalModel, + }; + return acc; + }, {} as Record); +}; + export const AgentSettingsModal: React.FC = ({ isOpen, status, onClose, onStatusChange, }) => { - const [baseUrl, setBaseUrl] = useState(''); - const [model, setModel] = useState(''); - const [apiKey, setApiKey] = useState(''); + const [remoteEnabled, setRemoteEnabled] = useState(true); + const [remoteBaseUrl, setRemoteBaseUrl] = useState(''); + const [defaultRemoteModel, setDefaultRemoteModel] = useState(''); + const [localEnabled, setLocalEnabled] = useState(false); + const [localBaseUrl, setLocalBaseUrl] = useState(''); + const [localAvailableModelsText, setLocalAvailableModelsText] = useState(''); + const [taskDefaults, setTaskDefaults] = useState>( + mergeTaskDefaults({}, '', []), + ); + const [remoteApiKey, setRemoteApiKey] = useState(''); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); + const [localStatusMessage, setLocalStatusMessage] = useState(null); const [isBusy, setIsBusy] = useState(false); useEffect(() => { @@ -30,81 +76,196 @@ export const AgentSettingsModal: React.FC = ({ return; } - setBaseUrl(status.baseUrl); - setModel(status.model); - setApiKey(''); + setRemoteEnabled(status.remoteEnabled); + setRemoteBaseUrl(status.remoteBaseUrl); + setDefaultRemoteModel(status.defaultRemoteModel); + setLocalEnabled(status.localEnabled); + setLocalBaseUrl(status.localBaseUrl); + setLocalAvailableModelsText(status.localAvailableModels.join('\n')); + setTaskDefaults( + mergeTaskDefaults( + status.taskDefaults, + status.defaultRemoteModel, + status.localAvailableModels, + ), + ); + setRemoteApiKey(''); setError(null); setSuccess(null); + setLocalStatusMessage(null); }, [isOpen, status]); if (!isOpen || !status) { return null; } - const saveRuntimeSettings = async () => { - // Runtime config and API key are saved through separate backend commands, so - // key actions persist the latest base URL/model first to keep them in sync. - const nextStatus = await agentSettingsBridge.saveSettings({ baseUrl, model }); + const localAvailableModels = normalizeModelList(localAvailableModelsText); + + const runtimeRequest = { + remoteEnabled, + remoteBaseUrl, + defaultRemoteModel, + localEnabled, + localBaseUrl, + localAvailableModels, + taskDefaults, + }; + + const setTaskRoute = ( + task: TaskProfile, + updater: (route: AgentTaskRoute) => AgentTaskRoute, + ) => { + setTaskDefaults((current) => ({ + ...current, + [task]: updater(current[task]), + })); + }; + + const saveRuntimeConfig = async () => { + const nextStatus = await agentSettingsBridge.saveRuntimeConfig(runtimeRequest); onStatusChange(nextStatus); return nextStatus; }; - const handleSaveSettings = async () => { + const handleSaveRuntime = async () => { setIsBusy(true); setError(null); setSuccess(null); try { - await saveRuntimeSettings(); - setSuccess('Runtime settings saved.'); + await saveRuntimeConfig(); + setSuccess('Provider routing saved.'); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save settings.'); + setError(err instanceof Error ? err.message : 'Failed to save runtime settings.'); } finally { setIsBusy(false); } }; - const handleSaveApiKey = async () => { + const handleSaveRemoteApiKey = async () => { setIsBusy(true); setError(null); setSuccess(null); try { - const savedStatus = await saveRuntimeSettings(); - const nextStatus = await agentSettingsBridge.updateApiKey({ apiKey }); + const savedStatus = await saveRuntimeConfig(); + const nextStatus = await agentSettingsBridge.updateRemoteApiKey({ apiKey: remoteApiKey }); onStatusChange({ ...savedStatus, ...nextStatus }); - setApiKey(''); - setSuccess(status.hasApiKey ? 'Plaintext API key updated.' : 'Plaintext API key saved.'); + setRemoteApiKey(''); + setSuccess( + status.hasRemoteApiKey + ? 'Remote API key updated.' + : 'Remote API key saved.', + ); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save API key.'); + setError(err instanceof Error ? err.message : 'Failed to save remote API key.'); } finally { setIsBusy(false); } }; - const handleClearApiKey = async () => { + const handleClearRemoteApiKey = async () => { setIsBusy(true); setError(null); setSuccess(null); try { - const savedStatus = await saveRuntimeSettings(); - const nextStatus = await agentSettingsBridge.clearApiKey(); + const savedStatus = await saveRuntimeConfig(); + const nextStatus = await agentSettingsBridge.clearRemoteApiKey(); onStatusChange({ ...savedStatus, ...nextStatus }); - setApiKey(''); - setSuccess('Plaintext API key cleared.'); + setRemoteApiKey(''); + setSuccess('Remote API key cleared.'); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to clear API key.'); + setError(err instanceof Error ? err.message : 'Failed to clear remote API key.'); } finally { setIsBusy(false); } }; + const handleDiscoverLocalModels = async () => { + setIsBusy(true); + setError(null); + setSuccess(null); + setLocalStatusMessage(null); + try { + const result = await agentSettingsBridge.listLocalModels(); + setLocalAvailableModelsText(result.models.join('\n')); + setTaskDefaults((current) => { + const next = { ...current }; + for (const profile of TASK_PROFILES) { + if ( + next[profile].providerMode === 'local' && + !next[profile].model.trim() && + result.models[0] + ) { + next[profile] = { ...next[profile], model: result.models[0] }; + } + } + return next; + }); + setLocalStatusMessage( + result.reachable + ? `Discovered ${result.models.length} local model(s).` + : 'Loaded stored local models because the sidecar was unreachable.', + ); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to list local models.'); + } finally { + setIsBusy(false); + } + }; + + const handleCheckLocalHealth = async () => { + setIsBusy(true); + setError(null); + setSuccess(null); + try { + const result = await agentSettingsBridge.checkLocalProviderHealth(); + setLocalStatusMessage(result.message); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to reach local provider.'); + } finally { + setIsBusy(false); + } + }; + + const handleTaskProviderChange = (task: TaskProfile, providerMode: ProviderMode) => { + const suggestedLocalModel = localAvailableModels[0] ?? ''; + setTaskRoute(task, (route) => ({ + providerMode, + model: + providerMode === 'remote' + ? route.providerMode === 'remote' && route.model.trim() + ? route.model + : defaultRemoteModel + : route.providerMode === 'local' && route.model.trim() + ? route.model + : suggestedLocalModel, + })); + }; + + const handleDefaultRemoteModelChange = (nextValue: string) => { + const previousValue = defaultRemoteModel; + setDefaultRemoteModel(nextValue); + setTaskDefaults((current) => { + const next = { ...current }; + for (const profile of TASK_PROFILES) { + if ( + next[profile].providerMode === 'remote' && + next[profile].model.trim() === previousValue.trim() + ) { + next[profile] = { ...next[profile], model: nextValue }; + } + } + return next; + }); + }; + return (
-
+

AI Settings

- Configure the Z.AI coding endpoint and store the API key in plain text. + Configure remote and local providers, then assign a default provider/model per task.

+ +
+
+ +