From e31c7dd2e204d92c3ee41bf29fa37e4416728451 Mon Sep 17 00:00:00 2001 From: francy51 Date: Sat, 11 Apr 2026 15:22:07 -0400 Subject: [PATCH] Support per-flow remote and Ollama routing --- MosaicIQ/src-tauri/src/agent/gateway.rs | 108 +-- MosaicIQ/src-tauri/src/agent/mod.rs | 11 +- MosaicIQ/src-tauri/src/agent/routing.rs | 206 +++-- MosaicIQ/src-tauri/src/agent/service.rs | 47 +- MosaicIQ/src-tauri/src/agent/settings.rs | 254 ++++-- MosaicIQ/src-tauri/src/agent/types.rs | 102 ++- MosaicIQ/src-tauri/src/research/ai.rs | 4 +- MosaicIQ/src-tauri/src/research/service.rs | 22 +- .../components/Settings/AgentSettingsForm.tsx | 863 +++++++++--------- .../src/components/Settings/ModelSelector.tsx | 17 +- .../Settings/SecEdgarSettingsCard.tsx | 14 +- .../src/components/Settings/SettingsPage.tsx | 37 +- MosaicIQ/src/types/agentSettings.ts | 46 +- 13 files changed, 1006 insertions(+), 725 deletions(-) diff --git a/MosaicIQ/src-tauri/src/agent/gateway.rs b/MosaicIQ/src-tauri/src/agent/gateway.rs index 1649293..907eb55 100644 --- a/MosaicIQ/src-tauri/src/agent/gateway.rs +++ b/MosaicIQ/src-tauri/src/agent/gateway.rs @@ -6,20 +6,18 @@ use rig::{ client::completion::CompletionClient, completion::{Message, Prompt}, message::ToolChoice, - providers::{anthropic, openai}, + providers::openai, streaming::{StreamedAssistantContent, StreamingPrompt}, }; use crate::agent::stream_events::AgentStreamEmitter; use crate::agent::tools::terminal_command::{AgentCommandExecutor, RunTerminalCommandTool}; -use crate::agent::{AgentProviderKind, AgentRuntimeConfig}; +use crate::agent::AgentRuntimeConfig; use crate::error::AppError; use crate::state::PendingAgentToolApprovals; const SYSTEM_PROMPT: &str = "You are MosaicIQ's terminal chat assistant. Answer concisely in plain text. Use the available terminal command tool whenever current workspace data or live MosaicIQ terminal actions would improve the answer. Never claim to have run a command unless the tool actually ran it. If the request is unclear, ask a short clarifying question."; const MAX_TOOL_TURNS: usize = 4; -const CHAT_MAX_TOKENS: u64 = 1_024; -const SUMMARY_MAX_TOKENS: u64 = 512; const SUMMARY_SYSTEM_PROMPT: &str = "You maintain a rolling summary for MosaicIQ terminal chats. Return plain text only. Keep the summary under 1200 characters and roughly 10 bullets. Preserve concrete facts, user preferences, decisions, portfolio actions, unresolved questions, and symbols/tickers. Use exactly these sections: User goals, Established facts, Actions taken, Open threads."; #[derive(Clone)] @@ -71,44 +69,21 @@ impl ChatGateway for RigChatGateway { pending_approvals: tool_runtime.pending_approvals.clone(), workspace_id: tool_runtime.workspace_id.clone(), }; + let client = build_openai_client(&runtime)?; + let rig_stream = client + .agent(runtime.model) + .preamble(SYSTEM_PROMPT) + .temperature(0.2) + .tool(tool) + .tool_choice(ToolChoice::Auto) + .default_max_turns(MAX_TOOL_TURNS) + .build() + .stream_prompt(prompt) + .with_history(history) + .multi_turn(MAX_TOOL_TURNS) + .await; - match runtime.provider_kind { - AgentProviderKind::Remote | AgentProviderKind::OllamaOpenAI => { - let client = build_openai_client(&runtime)?; - let rig_stream = client - .agent(runtime.model) - .preamble(SYSTEM_PROMPT) - .temperature(0.2) - .tool(tool) - .tool_choice(ToolChoice::Auto) - .default_max_turns(MAX_TOOL_TURNS) - .build() - .stream_prompt(prompt) - .with_history(history) - .multi_turn(MAX_TOOL_TURNS) - .await; - - consume_stream(rig_stream, &tool_runtime).await - } - AgentProviderKind::OllamaAnthropic => { - let client = build_anthropic_client(&runtime)?; - let rig_stream = client - .agent(runtime.model) - .preamble(SYSTEM_PROMPT) - .temperature(0.2) - .max_tokens(CHAT_MAX_TOKENS) - .tool(tool) - .tool_choice(ToolChoice::Auto) - .default_max_turns(MAX_TOOL_TURNS) - .build() - .stream_prompt(prompt) - .with_history(history) - .multi_turn(MAX_TOOL_TURNS) - .await; - - consume_stream(rig_stream, &tool_runtime).await - } - } + consume_stream(rig_stream, &tool_runtime).await }) } @@ -129,37 +104,18 @@ impl ChatGateway for RigChatGateway { } history.extend(messages); - match runtime.provider_kind { - AgentProviderKind::Remote | AgentProviderKind::OllamaOpenAI => { - let client = build_openai_client(&runtime)?; - let response = client - .agent(runtime.model) - .preamble(SUMMARY_SYSTEM_PROMPT) - .temperature(0.1) - .build() - .prompt("Update the rolling summary using the conversation history.") - .with_history(history) - .await - .map_err(map_prompt_error)?; + let client = build_openai_client(&runtime)?; + let response = client + .agent(runtime.model) + .preamble(SUMMARY_SYSTEM_PROMPT) + .temperature(0.1) + .build() + .prompt("Update the rolling summary using the conversation history.") + .with_history(history) + .await + .map_err(map_prompt_error)?; - Ok(response.trim().to_string()) - } - AgentProviderKind::OllamaAnthropic => { - let client = build_anthropic_client(&runtime)?; - let response = client - .agent(runtime.model) - .preamble(SUMMARY_SYSTEM_PROMPT) - .temperature(0.1) - .max_tokens(SUMMARY_MAX_TOKENS) - .build() - .prompt("Update the rolling summary using the conversation history.") - .with_history(history) - .await - .map_err(map_prompt_error)?; - - Ok(response.trim().to_string()) - } - } + Ok(response.trim().to_string()) }) } } @@ -174,16 +130,6 @@ fn build_openai_client(runtime: &AgentRuntimeConfig) -> Result Result { - let api_key = runtime.api_key.clone().unwrap_or_default(); - - anthropic::Client::builder() - .api_key(api_key) - .base_url(&runtime.base_url) - .build() - .map_err(|error| AppError::ProviderInit(error.to_string())) -} - fn compose_request_messages(context_messages: Vec, history: Vec) -> Vec { context_messages.into_iter().chain(history).collect() } diff --git a/MosaicIQ/src-tauri/src/agent/mod.rs b/MosaicIQ/src-tauri/src/agent/mod.rs index 2f3be19..ef9768f 100644 --- a/MosaicIQ/src-tauri/src/agent/mod.rs +++ b/MosaicIQ/src-tauri/src/agent/mod.rs @@ -14,10 +14,11 @@ pub use service::AgentService; pub(crate) use settings::AgentSettingsService; pub use stream_events::AgentStreamEmitter; pub use types::{ - default_task_defaults, AgentConfigStatus, AgentProviderKind, AgentRuntimeConfig, - AgentStoredSettings, AgentStreamItemEvent, AgentStreamItemKind, AgentTaskRoute, - ChatPanelContext, ChatPromptRequest, ChatStreamStart, PreparedChatTurn, - RemoteProviderSettings, ResolveAgentToolApprovalRequest, SaveAgentRuntimeConfigRequest, - TaskProfile, UpdateRemoteApiKeyRequest, AGENT_SETTINGS_STORE_PATH, + default_task_defaults, AgentConfigStatus, AgentProviderKind, AgentProviderStatuses, + AgentRuntimeConfig, AgentStoredSettings, AgentStreamItemEvent, AgentStreamItemKind, + AgentTaskRoute, ChatPanelContext, ChatPromptRequest, ChatStreamStart, + OllamaProviderSettings, PreparedChatTurn, ProviderConfigStatus, RemoteProviderSettings, + ResolveAgentToolApprovalRequest, SaveAgentRuntimeConfigRequest, TaskProfile, + UpdateRemoteApiKeyRequest, AGENT_SETTINGS_STORE_PATH, DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_COMPAT_API_KEY, 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 index d476c92..a6fd8cd 100644 --- a/MosaicIQ/src-tauri/src/agent/routing.rs +++ b/MosaicIQ/src-tauri/src/agent/routing.rs @@ -13,55 +13,61 @@ pub fn resolve_runtime( .task_defaults .get(&task_profile) .ok_or(AppError::TaskRouteMissing(task_profile))?; + let provider = provider_settings(settings, route.provider); - if !settings.remote.enabled { + if !provider.enabled() { return Err(AppError::ProviderNotConfigured); } - let api_key = settings.remote.api_key.trim().to_string(); - if settings.remote.provider_kind.uses_api_key() && api_key.is_empty() { - return Err(AppError::RemoteApiKeyMissing); - } + let model = resolve_model(settings, task_profile, route, model_override)?; + let api_key = match route.provider { + AgentProviderKind::Remote => { + let api_key = settings.remote.api_key.trim().to_string(); + if api_key.is_empty() { + return Err(AppError::RemoteApiKeyMissing); + } + Some(api_key) + } + AgentProviderKind::Ollama => Some(DEFAULT_OLLAMA_COMPAT_API_KEY.to_string()), + }; Ok(AgentRuntimeConfig { - provider_kind: settings.remote.provider_kind, - base_url: settings.remote.base_url.clone(), - model: resolve_model(settings, task_profile, route, model_override)?, - api_key: Some(match settings.remote.provider_kind { - AgentProviderKind::Remote => api_key, - AgentProviderKind::OllamaOpenAI | AgentProviderKind::OllamaAnthropic => { - if api_key.is_empty() { - DEFAULT_OLLAMA_COMPAT_API_KEY.to_string() - } else { - api_key - } - } - }), + provider_kind: route.provider, + base_url: provider.base_url().to_string(), + model, + api_key, }) } 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.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; + let provider = provider_settings(settings, route.provider); - if model.trim().is_empty() { - return Err(AppError::ModelMissing(task)); + if !provider.enabled() { + return Err(AppError::InvalidSettings(format!( + "{} is routed to disabled provider {}", + task_label(task), + provider.label() + ))); + } + + if provider.default_model().trim().is_empty() && route.model.trim().is_empty() { + return Err(AppError::InvalidSettings(format!( + "{} is missing a model. Set a task model or a default model for {}.", + task_label(task), + provider.label() + ))); + } + + if route.provider == AgentProviderKind::Remote && settings.remote.api_key.trim().is_empty() { + return Err(AppError::InvalidSettings(format!( + "{} is routed to Remote, but the Remote API key is missing.", + task_label(task) + ))); } } @@ -69,29 +75,46 @@ pub fn validate_settings(settings: &AgentStoredSettings) -> Result<(), AppError> } pub fn normalize_routes(settings: &mut AgentStoredSettings) -> Result<(), AppError> { + if settings.task_defaults.is_empty() { + settings.task_defaults = crate::agent::default_task_defaults(); + } + 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); + .entry(task) + .or_insert_with(|| AgentTaskRoute { + provider: AgentProviderKind::Remote, + model: String::new(), + }); + route.model = route.model.trim().to_string(); } + settings.remote.base_url = settings.remote.base_url.trim().to_string(); + settings.remote.default_model = settings.remote.default_model.trim().to_string(); + settings.ollama.base_url = settings.ollama.base_url.trim().to_string(); + settings.ollama.default_model = settings.ollama.default_model.trim().to_string(); + Ok(()) } -pub fn compute_remote_configured(settings: &AgentStoredSettings) -> bool { - settings.remote.enabled - && !settings.remote.base_url.trim().is_empty() - && !settings.default_remote_model.trim().is_empty() - && (!settings.remote.provider_kind.uses_api_key() - || !settings.remote.api_key.trim().is_empty()) +pub fn compute_provider_configured( + settings: &AgentStoredSettings, + provider: AgentProviderKind, +) -> bool { + let provider = provider_settings(settings, provider); + + provider.enabled() + && !provider.base_url().trim().is_empty() + && !provider.default_model().trim().is_empty() + && (!provider.uses_api_key() || !settings.remote.api_key.trim().is_empty()) } pub fn compute_overall_configured(settings: &AgentStoredSettings) -> bool { - validate_settings(settings).is_ok() && compute_remote_configured(settings) + TaskProfile::all() + .iter() + .copied() + .all(|task| resolve_runtime(settings, task, None).is_ok()) } fn resolve_model( @@ -108,27 +131,78 @@ fn resolve_model( return Ok(trimmed.to_string()); } - Ok(normalize_route_model(settings, task_profile, route.clone())?.model) -} - -fn normalize_route_model( - settings: &AgentStoredSettings, - task_profile: TaskProfile, - route: AgentTaskRoute, -) -> Result { - let trimmed = route.model.trim(); - - if trimmed.is_empty() { - if settings.default_remote_model.trim().is_empty() { - return Err(AppError::ModelMissing(task_profile)); - } - - return Ok(AgentTaskRoute { - model: settings.default_remote_model.clone(), - }); + let route_model = route.model.trim(); + if !route_model.is_empty() { + return Ok(route_model.to_string()); } - Ok(AgentTaskRoute { - model: trimmed.to_string(), - }) + let provider = provider_settings(settings, route.provider); + let provider_model = provider.default_model().trim(); + if provider_model.is_empty() { + return Err(AppError::ModelMissing(task_profile)); + } + + Ok(provider_model.to_string()) +} + +fn task_label(task: TaskProfile) -> &'static str { + match task { + TaskProfile::InteractiveChat => "Interactive Chat", + TaskProfile::Analysis => "Analysis", + TaskProfile::Summarization => "Summarization", + TaskProfile::ToolUse => "Tool Use", + TaskProfile::NoteEnrichment => "Note Enrichment", + TaskProfile::RelationshipInference => "Relationship Inference", + TaskProfile::GhostSynthesis => "Ghost Synthesis", + TaskProfile::MemoStructuring => "Memo Structuring", + } +} + +enum ProviderSettingsRef<'a> { + Remote(&'a crate::agent::RemoteProviderSettings), + Ollama(&'a crate::agent::OllamaProviderSettings), +} + +impl ProviderSettingsRef<'_> { + fn enabled(&self) -> bool { + match self { + Self::Remote(settings) => settings.enabled, + Self::Ollama(settings) => settings.enabled, + } + } + + fn base_url(&self) -> &str { + match self { + Self::Remote(settings) => &settings.base_url, + Self::Ollama(settings) => &settings.base_url, + } + } + + fn default_model(&self) -> &str { + match self { + Self::Remote(settings) => &settings.default_model, + Self::Ollama(settings) => &settings.default_model, + } + } + + fn uses_api_key(&self) -> bool { + matches!(self, Self::Remote(_)) + } + + fn label(&self) -> &'static str { + match self { + Self::Remote(_) => "Remote", + Self::Ollama(_) => "Local (Ollama)", + } + } +} + +fn provider_settings<'a>( + settings: &'a AgentStoredSettings, + provider: AgentProviderKind, +) -> ProviderSettingsRef<'a> { + match provider { + AgentProviderKind::Remote => ProviderSettingsRef::Remote(&settings.remote), + AgentProviderKind::Ollama => ProviderSettingsRef::Ollama(&settings.ollama), + } } diff --git a/MosaicIQ/src-tauri/src/agent/service.rs b/MosaicIQ/src-tauri/src/agent/service.rs index b13e83c..82586b9 100644 --- a/MosaicIQ/src-tauri/src/agent/service.rs +++ b/MosaicIQ/src-tauri/src/agent/service.rs @@ -6,15 +6,16 @@ use rig::completion::Message; use tauri::{AppHandle, Runtime}; use crate::agent::{ - panel_context::build_panel_context_message, AgentConfigStatus, AgentRuntimeConfig, - AgentStoredSettings, ChatPromptRequest, PreparedChatTurn, RemoteProviderSettings, + panel_context::build_panel_context_message, AgentConfigStatus, AgentProviderKind, + AgentProviderStatuses, AgentRuntimeConfig, AgentStoredSettings, ChatPromptRequest, + OllamaProviderSettings, PreparedChatTurn, ProviderConfigStatus, RemoteProviderSettings, RigChatGateway, SaveAgentRuntimeConfigRequest, TaskProfile, UpdateRemoteApiKeyRequest, }; use crate::error::AppError; use super::gateway::ChatGateway; use super::routing::{ - compute_overall_configured, compute_remote_configured, normalize_routes, resolve_runtime, + compute_overall_configured, compute_provider_configured, normalize_routes, resolve_runtime, validate_settings, }; use super::settings::AgentSettingsService; @@ -291,12 +292,16 @@ impl AgentService { ) -> Result { let mut settings = self.settings.load()?; settings.remote = RemoteProviderSettings { - enabled: request.remote_enabled, - provider_kind: request.provider_kind, - base_url: request.remote_base_url.trim().to_string(), + enabled: request.remote.enabled, + base_url: request.remote.base_url.trim().to_string(), api_key: settings.remote.api_key, + default_model: request.remote.default_model.trim().to_string(), + }; + settings.ollama = OllamaProviderSettings { + enabled: request.ollama.enabled, + base_url: request.ollama.base_url.trim().to_string(), + default_model: request.ollama.default_model.trim().to_string(), }; - settings.default_remote_model = request.default_remote_model.trim().to_string(); settings.task_defaults = request.task_defaults; settings.sec_edgar_user_agent = request.sec_edgar_user_agent.trim().to_string(); normalize_routes(&mut settings)?; @@ -327,14 +332,24 @@ impl AgentService { fn build_status(&self, settings: AgentStoredSettings) -> AgentConfigStatus { AgentConfigStatus { configured: compute_overall_configured(&settings), - remote_configured: compute_remote_configured(&settings), - remote_enabled: settings.remote.enabled, - provider_kind: settings.remote.provider_kind, - has_remote_api_key: !settings.remote.api_key.trim().is_empty(), - has_sec_edgar_user_agent: !settings.sec_edgar_user_agent.trim().is_empty(), - remote_base_url: settings.remote.base_url, - default_remote_model: settings.default_remote_model, + providers: AgentProviderStatuses { + remote: ProviderConfigStatus { + enabled: settings.remote.enabled, + configured: compute_provider_configured(&settings, AgentProviderKind::Remote), + base_url: settings.remote.base_url.clone(), + default_model: settings.remote.default_model.clone(), + has_api_key: !settings.remote.api_key.trim().is_empty(), + }, + ollama: ProviderConfigStatus { + enabled: settings.ollama.enabled, + configured: compute_provider_configured(&settings, AgentProviderKind::Ollama), + base_url: settings.ollama.base_url.clone(), + default_model: settings.ollama.default_model.clone(), + has_api_key: false, + }, + }, task_defaults: settings.task_defaults, + has_sec_edgar_user_agent: !settings.sec_edgar_user_agent.trim().is_empty(), sec_edgar_user_agent: settings.sec_edgar_user_agent, } } @@ -345,10 +360,6 @@ impl AgentService { model_override: Option, ) -> Result { let settings = self.settings.load()?; - if !compute_overall_configured(&settings) { - return Err(AppError::AgentNotConfigured); - } - resolve_runtime( &settings, task_profile.unwrap_or(TaskProfile::InteractiveChat), diff --git a/MosaicIQ/src-tauri/src/agent/settings.rs b/MosaicIQ/src-tauri/src/agent/settings.rs index bc055a5..e1f97ad 100644 --- a/MosaicIQ/src-tauri/src/agent/settings.rs +++ b/MosaicIQ/src-tauri/src/agent/settings.rs @@ -1,20 +1,29 @@ +use std::collections::HashMap; + +use serde::Deserialize; use serde_json::json; use tauri::{AppHandle, Runtime}; use tauri_plugin_store::StoreExt; use crate::agent::{ - default_task_defaults, AgentProviderKind, AgentStoredSettings, RemoteProviderSettings, - AGENT_SETTINGS_STORE_PATH, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL, + default_task_defaults, AgentProviderKind, AgentStoredSettings, AgentTaskRoute, + OllamaProviderSettings, RemoteProviderSettings, AGENT_SETTINGS_STORE_PATH, + DEFAULT_OLLAMA_BASE_URL, DEFAULT_REMOTE_BASE_URL, DEFAULT_REMOTE_MODEL, }; use crate::error::AppError; const REMOTE_ENABLED_KEY: &str = "remoteEnabled"; -const PROVIDER_KIND_KEY: &str = "providerKind"; const REMOTE_BASE_URL_KEY: &str = "remoteBaseUrl"; const REMOTE_API_KEY_KEY: &str = "remoteApiKey"; -const DEFAULT_REMOTE_MODEL_KEY: &str = "defaultRemoteModel"; +const REMOTE_DEFAULT_MODEL_KEY: &str = "remoteDefaultModel"; +const OLLAMA_ENABLED_KEY: &str = "ollamaEnabled"; +const OLLAMA_BASE_URL_KEY: &str = "ollamaBaseUrl"; +const OLLAMA_DEFAULT_MODEL_KEY: &str = "ollamaDefaultModel"; const TASK_DEFAULTS_KEY: &str = "taskDefaults"; const SEC_EDGAR_USER_AGENT_KEY: &str = "secEdgarUserAgent"; + +const LEGACY_PROVIDER_KIND_KEY: &str = "providerKind"; +const LEGACY_DEFAULT_REMOTE_MODEL_KEY: &str = "defaultRemoteModel"; const LEGACY_BASE_URL_KEY: &str = "baseUrl"; const LEGACY_MODEL_KEY: &str = "model"; const LEGACY_API_KEY_KEY: &str = "apiKey"; @@ -22,6 +31,12 @@ const LOCAL_ENABLED_KEY: &str = "localEnabled"; const LOCAL_BASE_URL_KEY: &str = "localBaseUrl"; const LOCAL_AVAILABLE_MODELS_KEY: &str = "localAvailableModels"; +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyAgentTaskRoute { + model: String, +} + /// Manages the provider settings and plaintext API key stored through the Tauri store plugin. #[derive(Debug)] pub struct AgentSettingsService { @@ -49,51 +64,120 @@ 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) + let has_new_schema = store.get(REMOTE_DEFAULT_MODEL_KEY).is_some() + || store.get(OLLAMA_ENABLED_KEY).is_some() + || store.get(OLLAMA_BASE_URL_KEY).is_some() + || store.get(OLLAMA_DEFAULT_MODEL_KEY).is_some(); + + let legacy_provider = store + .get(LEGACY_PROVIDER_KIND_KEY) + .and_then(|value| serde_json::from_value(value.clone()).ok()) + .unwrap_or(AgentProviderKind::Remote); + + let remote_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(); + + let legacy_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)) + }); + + let legacy_default_model = store + .get(LEGACY_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 { - remote: RemoteProviderSettings { + let (remote, ollama, migrated_provider) = if has_new_schema { + let remote = RemoteProviderSettings { enabled: store .get(REMOTE_ENABLED_KEY) .and_then(|value| value.as_bool()) .unwrap_or(true), - provider_kind: store - .get(PROVIDER_KIND_KEY) - .and_then(|value| serde_json::from_value(value.clone()).ok()) - .unwrap_or(AgentProviderKind::Remote), 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) + api_key: remote_api_key, + default_model: store + .get(REMOTE_DEFAULT_MODEL_KEY) + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| DEFAULT_REMOTE_MODEL.to_string()), + }; + let ollama = OllamaProviderSettings { + enabled: store + .get(OLLAMA_ENABLED_KEY) + .and_then(|value| value.as_bool()) + .unwrap_or(false), + base_url: store + .get(OLLAMA_BASE_URL_KEY) + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| DEFAULT_OLLAMA_BASE_URL.to_string()), + default_model: store + .get(OLLAMA_DEFAULT_MODEL_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(), - }, - default_remote_model, + }; + (remote, ollama, AgentProviderKind::Remote) + } else { + let legacy_enabled = store + .get(REMOTE_ENABLED_KEY) + .and_then(|value| value.as_bool()) + .unwrap_or(true); + let legacy_base_url = + legacy_base_url.unwrap_or_else(|| DEFAULT_REMOTE_BASE_URL.to_string()); + let legacy_default_model = + legacy_default_model.unwrap_or_else(|| DEFAULT_REMOTE_MODEL.to_string()); + + match legacy_provider { + AgentProviderKind::Remote => ( + RemoteProviderSettings { + enabled: legacy_enabled, + base_url: legacy_base_url, + api_key: remote_api_key, + default_model: legacy_default_model, + }, + OllamaProviderSettings::default(), + AgentProviderKind::Remote, + ), + AgentProviderKind::Ollama => ( + RemoteProviderSettings { + api_key: remote_api_key, + ..RemoteProviderSettings::default() + }, + OllamaProviderSettings { + enabled: legacy_enabled, + base_url: normalize_legacy_ollama_base_url(&legacy_base_url), + default_model: legacy_default_model, + }, + AgentProviderKind::Ollama, + ), + } + }; + + let task_defaults = load_task_defaults( + store.get(TASK_DEFAULTS_KEY).as_ref(), + migrated_provider, + has_new_schema, + ); + + Ok(AgentStoredSettings { + remote, + ollama, task_defaults, sec_edgar_user_agent: store .get(SEC_EDGAR_USER_AGENT_KEY) @@ -120,41 +204,107 @@ impl AgentSettingsService { .store(AGENT_SETTINGS_STORE_PATH) .map_err(|error| AppError::SettingsStore(error.to_string()))?; + 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( - REMOTE_ENABLED_KEY.to_string(), - json!(settings.remote.enabled), + REMOTE_DEFAULT_MODEL_KEY.to_string(), + json!(settings.remote.default_model), ); + + store.set(OLLAMA_ENABLED_KEY.to_string(), json!(settings.ollama.enabled)); + store.set(OLLAMA_BASE_URL_KEY.to_string(), json!(settings.ollama.base_url)); store.set( - PROVIDER_KIND_KEY.to_string(), - json!(settings.remote.provider_kind), - ); - 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), + OLLAMA_DEFAULT_MODEL_KEY.to_string(), + json!(settings.ollama.default_model), ); + store.set(TASK_DEFAULTS_KEY.to_string(), json!(settings.task_defaults)); store.set( SEC_EDGAR_USER_AGENT_KEY.to_string(), json!(settings.sec_edgar_user_agent), ); - store.delete(LOCAL_ENABLED_KEY); - store.delete(LOCAL_BASE_URL_KEY); - store.delete(LOCAL_AVAILABLE_MODELS_KEY); + store.delete(LEGACY_PROVIDER_KIND_KEY); store.delete(LEGACY_BASE_URL_KEY); store.delete(LEGACY_MODEL_KEY); store.delete(LEGACY_API_KEY_KEY); + store.delete(LEGACY_DEFAULT_REMOTE_MODEL_KEY); + store.delete(LOCAL_ENABLED_KEY); + store.delete(LOCAL_BASE_URL_KEY); + store.delete(LOCAL_AVAILABLE_MODELS_KEY); store .save() .map_err(|error| AppError::SettingsStore(error.to_string())) } } + +fn load_task_defaults( + raw_value: Option<&serde_json::Value>, + migrated_provider: AgentProviderKind, + has_new_schema: bool, +) -> HashMap { + let Some(value) = raw_value else { + return if has_new_schema { + default_task_defaults() + } else { + super::types::default_task_defaults_for(migrated_provider) + }; + }; + + if let Ok(routes) = + serde_json::from_value::>(value.clone()) + { + return merge_task_defaults(default_task_defaults(), routes); + } + + if let Ok(legacy_routes) = serde_json::from_value::< + HashMap, + >(value.clone()) + { + let routes = legacy_routes + .into_iter() + .map(|(task, route)| { + ( + task, + AgentTaskRoute { + provider: migrated_provider, + model: route.model, + }, + ) + }) + .collect::>(); + return merge_task_defaults( + super::types::default_task_defaults_for(migrated_provider), + routes, + ); + } + + if has_new_schema { + default_task_defaults() + } else { + super::types::default_task_defaults_for(migrated_provider) + } +} + +fn merge_task_defaults( + mut defaults: HashMap, + overrides: HashMap, +) -> HashMap { + defaults.extend(overrides); + defaults +} + +fn normalize_legacy_ollama_base_url(base_url: &str) -> String { + let trimmed = base_url.trim(); + if trimmed.is_empty() { + return DEFAULT_OLLAMA_BASE_URL.to_string(); + } + + if trimmed.ends_with("/v1") { + trimmed.to_string() + } else { + format!("{}/v1", trimmed.trim_end_matches('/')) + } +} diff --git a/MosaicIQ/src-tauri/src/agent/types.rs b/MosaicIQ/src-tauri/src/agent/types.rs index 520d8db..5cf7c5e 100644 --- a/MosaicIQ/src-tauri/src/agent/types.rs +++ b/MosaicIQ/src-tauri/src/agent/types.rs @@ -6,14 +6,10 @@ use crate::terminal::{PanelPayload, TerminalCommandResponse}; /// Default Z.AI coding plan endpoint used by the app. pub const DEFAULT_REMOTE_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4"; -/// Default model used for plain-text terminal chat. +/// Default remote model used for plain-text terminal chat. pub const DEFAULT_REMOTE_MODEL: &str = "glm-5.1"; /// Default Ollama OpenAI compatibility endpoint used by the app. -#[cfg_attr(not(test), allow(dead_code))] -pub const DEFAULT_OLLAMA_OPENAI_BASE_URL: &str = "http://localhost:11434/v1"; -/// Default Ollama Anthropic compatibility endpoint used by the app. -#[allow(dead_code)] -pub const DEFAULT_OLLAMA_ANTHROPIC_BASE_URL: &str = "http://localhost:11434"; +pub const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1"; /// Placeholder token required by Ollama compatibility endpoints but ignored by Ollama. pub const DEFAULT_OLLAMA_COMPAT_API_KEY: &str = "ollama"; /// Store file used for agent settings and plaintext API key storage. @@ -23,8 +19,7 @@ pub const AGENT_SETTINGS_STORE_PATH: &str = "agent-settings.json"; #[serde(rename_all = "snake_case")] pub enum AgentProviderKind { Remote, - OllamaOpenAI, - OllamaAnthropic, + Ollama, } impl AgentProviderKind { @@ -145,7 +140,7 @@ pub struct ResolveAgentToolApprovalRequest { #[serde(rename_all = "camelCase")] pub struct AgentStoredSettings { pub remote: RemoteProviderSettings, - pub default_remote_model: String, + pub ollama: OllamaProviderSettings, pub task_defaults: HashMap, pub sec_edgar_user_agent: String, } @@ -154,26 +149,40 @@ impl Default for AgentStoredSettings { fn default() -> Self { Self { remote: RemoteProviderSettings::default(), - default_remote_model: DEFAULT_REMOTE_MODEL.to_string(), - task_defaults: default_task_defaults(DEFAULT_REMOTE_MODEL), + ollama: OllamaProviderSettings::default(), + task_defaults: default_task_defaults(), sec_edgar_user_agent: String::new(), } } } +/// Public provider configuration status returned to the webview. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderConfigStatus { + pub enabled: bool, + pub configured: bool, + pub base_url: String, + pub default_model: String, + pub has_api_key: bool, +} + +/// Public AI provider status returned to the webview. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentProviderStatuses { + pub remote: ProviderConfigStatus, + pub ollama: ProviderConfigStatus, +} + /// Public configuration status returned to the webview. #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AgentConfigStatus { pub configured: bool, - pub remote_configured: bool, - pub remote_enabled: bool, - pub provider_kind: AgentProviderKind, - pub has_remote_api_key: bool, - pub has_sec_edgar_user_agent: bool, - pub remote_base_url: String, - pub default_remote_model: String, + pub providers: AgentProviderStatuses, pub task_defaults: HashMap, + pub has_sec_edgar_user_agent: bool, pub sec_edgar_user_agent: String, } @@ -181,14 +190,28 @@ pub struct AgentConfigStatus { #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SaveAgentRuntimeConfigRequest { - pub remote_enabled: bool, - pub provider_kind: AgentProviderKind, - pub remote_base_url: String, - pub default_remote_model: String, + pub remote: SaveRemoteProviderSettings, + pub ollama: SaveOllamaProviderSettings, pub task_defaults: HashMap, pub sec_edgar_user_agent: String, } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveRemoteProviderSettings { + pub enabled: bool, + pub base_url: String, + pub default_model: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveOllamaProviderSettings { + pub enabled: bool, + pub base_url: String, + pub default_model: String, +} + /// Request payload for rotating the stored remote API key. #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] @@ -201,18 +224,37 @@ pub struct UpdateRemoteApiKeyRequest { #[serde(rename_all = "camelCase")] pub struct RemoteProviderSettings { pub enabled: bool, - pub provider_kind: AgentProviderKind, pub base_url: String, pub api_key: String, + pub default_model: String, } impl Default for RemoteProviderSettings { fn default() -> Self { Self { enabled: true, - provider_kind: AgentProviderKind::Remote, base_url: DEFAULT_REMOTE_BASE_URL.to_string(), api_key: String::new(), + default_model: DEFAULT_REMOTE_MODEL.to_string(), + } + } +} + +/// Local Ollama provider settings persisted in the application store. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct OllamaProviderSettings { + pub enabled: bool, + pub base_url: String, + pub default_model: String, +} + +impl Default for OllamaProviderSettings { + fn default() -> Self { + Self { + enabled: false, + base_url: DEFAULT_OLLAMA_BASE_URL.to_string(), + default_model: String::new(), } } } @@ -221,16 +263,24 @@ impl Default for RemoteProviderSettings { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct AgentTaskRoute { + pub provider: AgentProviderKind, pub model: String, } -pub fn default_task_defaults(default_remote_model: &str) -> HashMap { +pub fn default_task_defaults() -> HashMap { + default_task_defaults_for(AgentProviderKind::Remote) +} + +pub(crate) fn default_task_defaults_for( + provider: AgentProviderKind, +) -> HashMap { let mut defaults = HashMap::new(); for task in TaskProfile::all() { defaults.insert( task, AgentTaskRoute { - model: default_remote_model.to_string(), + provider, + model: String::new(), }, ); } diff --git a/MosaicIQ/src-tauri/src/research/ai.rs b/MosaicIQ/src-tauri/src/research/ai.rs index 2372609..67fbf61 100644 --- a/MosaicIQ/src-tauri/src/research/ai.rs +++ b/MosaicIQ/src-tauri/src/research/ai.rs @@ -77,7 +77,7 @@ fn build_annotation(note: &ResearchNote) -> String { } } -pub(crate) fn build_model_info(model: &str, task_profile: &str) -> Option { +pub(crate) fn build_model_info(model: &str, task_profile: &str, provider: &str) -> Option { if model.trim().is_empty() { return None; } @@ -85,7 +85,7 @@ pub(crate) fn build_model_info(model: &str, task_profile: &str) -> Option ResearchService { return None; }; let defaults = if settings.task_defaults.is_empty() { - default_task_defaults(&settings.default_remote_model) + default_task_defaults() } else { settings.task_defaults }; - defaults - .get(&task_profile) - .map(|route| build_model_info(&route.model, &format!("{task_profile:?}"))) - .flatten() + defaults.get(&task_profile).and_then(|route| { + let model = if route.model.trim().is_empty() { + match route.provider { + crate::agent::AgentProviderKind::Remote => settings.remote.default_model.as_str(), + crate::agent::AgentProviderKind::Ollama => settings.ollama.default_model.as_str(), + } + } else { + route.model.as_str() + }; + let provider = match route.provider { + crate::agent::AgentProviderKind::Remote => "remote", + crate::agent::AgentProviderKind::Ollama => "ollama", + }; + + build_model_info(model, &format!("{task_profile:?}"), provider) + }) } async fn record_audit( diff --git a/MosaicIQ/src/components/Settings/AgentSettingsForm.tsx b/MosaicIQ/src/components/Settings/AgentSettingsForm.tsx index fff2c99..c9d9fb3 100644 --- a/MosaicIQ/src/components/Settings/AgentSettingsForm.tsx +++ b/MosaicIQ/src/components/Settings/AgentSettingsForm.tsx @@ -1,109 +1,79 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { + AlertCircle, + AlertTriangle, + CheckCircle, Eye, EyeOff, - Loader2, - CheckCircle, - AlertTriangle, - XCircle, - Save, - KeyRound, Globe, - AlertCircle, + KeyRound, + Loader2, + Save, + XCircle, } from 'lucide-react'; import { agentSettingsBridge } from '../../lib/agentSettingsBridge'; import { AGENT_PROVIDER_LABELS, - AgentProviderKind, AgentConfigStatus, + AgentProviderKind, AgentTaskRoute, + SaveAgentRuntimeConfigRequest, TASK_LABELS, TASK_PROFILES, TaskProfile, } from '../../types/agentSettings'; import { ConfirmDialog } from './ConfirmDialog'; import { DEFAULT_MODEL_OPTIONS, ModelSelector, OLLAMA_MODEL_OPTIONS } from './ModelSelector'; -import { ValidatedInput, ValidationStatus } from './ValidatedInput'; import { HelpIcon } from './Tooltip'; +import { ValidatedInput, ValidationStatus } from './ValidatedInput'; interface AgentSettingsFormProps { status: AgentConfigStatus | null; onStatusChange: (status: AgentConfigStatus) => void; } +interface ProviderFormState { + enabled: boolean; + baseUrl: string; + defaultModel: string; +} + interface FormState { - remoteEnabled: boolean; - providerKind: AgentProviderKind; - remoteBaseUrl: string; - defaultRemoteModel: string; + remote: ProviderFormState; + ollama: ProviderFormState; taskDefaults: Record; - secEdgarUserAgent: string; remoteApiKey: string; } interface ValidationState { - baseUrl: ValidationStatus; - baseUrlError?: string; - defaultModel: ValidationStatus; - secEdgarUserAgent: ValidationStatus; - secEdgarUserAgentError?: string; + remoteBaseUrl: ValidationStatus; + remoteBaseUrlError?: string; + ollamaBaseUrl: ValidationStatus; + ollamaBaseUrlError?: string; apiKey: ValidationStatus; } +const REMOTE_DEFAULT_BASE_URL = 'https://api.z.ai/api/coding/paas/v4'; +const OLLAMA_DEFAULT_BASE_URL = 'http://localhost:11434/v1'; + +const PROVIDER_MODEL_OPTIONS: Record = { + remote: DEFAULT_MODEL_OPTIONS, + ollama: OLLAMA_MODEL_OPTIONS, +}; + const mergeTaskDefaults = ( taskDefaults: Partial>, - defaultRemoteModel: string, ): Record => TASK_PROFILES.reduce((acc, profile) => { - acc[profile] = taskDefaults[profile] ?? { model: defaultRemoteModel }; + acc[profile] = taskDefaults[profile] ?? { provider: 'remote', model: '' }; return acc; }, {} as Record); -const REMOTE_DEFAULT_BASE_URL = 'https://api.z.ai/api/coding/paas/v4'; -const OLLAMA_OPENAI_BASE_URL = 'http://localhost:11434/v1'; -const OLLAMA_ANTHROPIC_BASE_URL = 'http://localhost:11434'; - -const PROVIDER_BASE_URLS: Record = { - remote: REMOTE_DEFAULT_BASE_URL, - ollama_openai: OLLAMA_OPENAI_BASE_URL, - ollama_anthropic: OLLAMA_ANTHROPIC_BASE_URL, -}; - -const PROVIDER_MODEL_OPTIONS = { - remote: DEFAULT_MODEL_OPTIONS, - ollama_openai: OLLAMA_MODEL_OPTIONS, - ollama_anthropic: OLLAMA_MODEL_OPTIONS, -} satisfies Record; - -const PROVIDER_HELPERS: Record< - AgentProviderKind, - { description: string; baseUrlHelper: string; baseUrlPlaceholder: string } -> = { - remote: { - description: 'Connect to an external OpenAI-compatible AI service.', - baseUrlHelper: 'The API endpoint URL for your AI provider.', - baseUrlPlaceholder: 'https://api.example.com/v1', - }, - ollama_openai: { - description: 'Use Ollama through its OpenAI-compatible `/v1/chat/completions` interface.', - baseUrlHelper: 'Default Ollama OpenAI compatibility URL: `http://localhost:11434/v1`.', - baseUrlPlaceholder: OLLAMA_OPENAI_BASE_URL, - }, - ollama_anthropic: { - description: 'Use Ollama through its Anthropic-compatible `/v1/messages` interface.', - baseUrlHelper: 'Default Ollama Anthropic compatibility URL: `http://localhost:11434`.', - baseUrlPlaceholder: OLLAMA_ANTHROPIC_BASE_URL, - }, -}; - -const providerUsesApiKey = (providerKind: AgentProviderKind) => providerKind === 'remote'; -const shouldReplaceBaseUrl = (currentBaseUrl: string) => - !currentBaseUrl.trim() || Object.values(PROVIDER_BASE_URLS).includes(currentBaseUrl.trim()); - const validateUrl = (url: string): { valid: boolean; error?: string } => { if (!url.trim()) { return { valid: false, error: 'Base URL is required' }; } + try { new URL(url); return { valid: true }; @@ -112,42 +82,33 @@ const validateUrl = (url: string): { valid: boolean; error?: string } => { } }; -const validateSecEdgarUserAgent = ( - value: string, -): { valid: boolean; error?: string } => { - const trimmed = value.trim(); - if (!trimmed) { - return { valid: true }; - } - - if (!trimmed.includes(' ') || !trimmed.includes('@')) { - return { - valid: false, - error: 'Use a format like `MosaicIQ admin@example.com`', - }; - } - - return { valid: true }; -}; +const providerDefaultModel = ( + formState: FormState, + provider: AgentProviderKind, +) => (provider === 'remote' ? formState.remote.defaultModel : formState.ollama.defaultModel); export const AgentSettingsForm: React.FC = ({ status, onStatusChange, }) => { const [formState, setFormState] = useState({ - remoteEnabled: true, - providerKind: 'remote', - remoteBaseUrl: '', - defaultRemoteModel: '', - taskDefaults: mergeTaskDefaults({}, ''), - secEdgarUserAgent: '', + remote: { + enabled: true, + baseUrl: REMOTE_DEFAULT_BASE_URL, + defaultModel: '', + }, + ollama: { + enabled: false, + baseUrl: OLLAMA_DEFAULT_BASE_URL, + defaultModel: '', + }, + taskDefaults: mergeTaskDefaults({}), remoteApiKey: '', }); const [initialState, setInitialState] = useState(null); const [validation, setValidation] = useState({ - baseUrl: 'idle', - defaultModel: 'idle', - secEdgarUserAgent: 'idle', + remoteBaseUrl: 'idle', + ollamaBaseUrl: 'idle', apiKey: 'idle', }); const [showApiKey, setShowApiKey] = useState(false); @@ -159,142 +120,135 @@ export const AgentSettingsForm: React.FC = ({ const [dontAskAgain, setDontAskAgain] = useState(false); const saveButtonRef = useRef(null); - // Initialize form state from props useEffect(() => { - if (!status) return; + if (!status) { + return; + } - const newState: FormState = { - remoteEnabled: status.remoteEnabled, - providerKind: status.providerKind, - remoteBaseUrl: status.remoteBaseUrl, - defaultRemoteModel: status.defaultRemoteModel, - taskDefaults: mergeTaskDefaults(status.taskDefaults, status.defaultRemoteModel), - secEdgarUserAgent: status.secEdgarUserAgent, + const nextState: FormState = { + remote: { + enabled: status.providers.remote.enabled, + baseUrl: status.providers.remote.baseUrl, + defaultModel: status.providers.remote.defaultModel, + }, + ollama: { + enabled: status.providers.ollama.enabled, + baseUrl: status.providers.ollama.baseUrl, + defaultModel: status.providers.ollama.defaultModel, + }, + taskDefaults: mergeTaskDefaults(status.taskDefaults), remoteApiKey: '', }; - setFormState(newState); - setInitialState(newState); + setFormState(nextState); + setInitialState(nextState); setError(null); setSuccess(null); setHasUnsavedChanges(false); setValidation({ - baseUrl: 'idle', - defaultModel: 'idle', - secEdgarUserAgent: 'idle', + remoteBaseUrl: nextState.remote.baseUrl ? 'valid' : 'idle', + ollamaBaseUrl: nextState.ollama.baseUrl ? 'valid' : 'idle', apiKey: 'idle', }); }, [status]); - // Track unsaved changes useEffect(() => { - if (!initialState) return; + if (!initialState) { + return; + } const hasChanges = - formState.remoteEnabled !== initialState.remoteEnabled || - formState.providerKind !== initialState.providerKind || - formState.remoteBaseUrl !== initialState.remoteBaseUrl || - formState.defaultRemoteModel !== initialState.defaultRemoteModel || - JSON.stringify(formState.taskDefaults) !== JSON.stringify(initialState.taskDefaults) || - formState.secEdgarUserAgent !== initialState.secEdgarUserAgent || - Boolean(formState.remoteApiKey && formState.remoteApiKey.length > 0); + JSON.stringify({ + remote: formState.remote, + ollama: formState.ollama, + taskDefaults: formState.taskDefaults, + }) !== + JSON.stringify({ + remote: initialState.remote, + ollama: initialState.ollama, + taskDefaults: initialState.taskDefaults, + }) || Boolean(formState.remoteApiKey.trim()); setHasUnsavedChanges(hasChanges); }, [formState, initialState]); - // Validate base URL on change useEffect(() => { - if (formState.remoteBaseUrl) { - const result = validateUrl(formState.remoteBaseUrl); + if (!formState.remote.baseUrl.trim()) { setValidation((prev) => ({ ...prev, - baseUrl: result.valid ? 'valid' : 'invalid', - baseUrlError: result.error, + remoteBaseUrl: 'idle', + remoteBaseUrlError: undefined, })); - } else { - setValidation((prev) => ({ ...prev, baseUrl: 'idle', baseUrlError: undefined })); + return; } - }, [formState.remoteBaseUrl]); - // Validate default model - useEffect(() => { - if (formState.defaultRemoteModel) { - setValidation((prev) => ({ ...prev, defaultModel: 'valid' })); - } else { - setValidation((prev) => ({ ...prev, defaultModel: 'idle' })); - } - }, [formState.defaultRemoteModel]); + const result = validateUrl(formState.remote.baseUrl); + setValidation((prev) => ({ + ...prev, + remoteBaseUrl: result.valid ? 'valid' : 'invalid', + remoteBaseUrlError: result.error, + })); + }, [formState.remote.baseUrl]); useEffect(() => { - if (formState.secEdgarUserAgent.trim()) { - const result = validateSecEdgarUserAgent(formState.secEdgarUserAgent); + if (!formState.ollama.baseUrl.trim()) { setValidation((prev) => ({ ...prev, - secEdgarUserAgent: result.valid ? 'valid' : 'invalid', - secEdgarUserAgentError: result.error, - })); - } else { - setValidation((prev) => ({ - ...prev, - secEdgarUserAgent: 'idle', - secEdgarUserAgentError: undefined, + ollamaBaseUrl: 'idle', + ollamaBaseUrlError: undefined, })); + return; } - }, [formState.secEdgarUserAgent]); - // Validate API key + const result = validateUrl(formState.ollama.baseUrl); + setValidation((prev) => ({ + ...prev, + ollamaBaseUrl: result.valid ? 'valid' : 'invalid', + ollamaBaseUrlError: result.error, + })); + }, [formState.ollama.baseUrl]); + useEffect(() => { - if (formState.remoteApiKey) { - setValidation((prev) => ({ ...prev, apiKey: 'valid' })); - } else { - setValidation((prev) => ({ ...prev, apiKey: 'idle' })); - } + setValidation((prev) => ({ + ...prev, + apiKey: formState.remoteApiKey.trim() ? 'valid' : 'idle', + })); }, [formState.remoteApiKey]); - if (!status) { - return ( -
-
- - Loading AI settings... -
-
- ); - } + const runtimeRequest = useMemo(() => { + if (!status) { + return null; + } - const runtimeRequest = { - remoteEnabled: formState.remoteEnabled, - providerKind: formState.providerKind, - remoteBaseUrl: formState.remoteBaseUrl, - defaultRemoteModel: formState.defaultRemoteModel, - taskDefaults: formState.taskDefaults, - secEdgarUserAgent: formState.secEdgarUserAgent, - }; + return { + remote: { + enabled: formState.remote.enabled, + baseUrl: formState.remote.baseUrl, + defaultModel: formState.remote.defaultModel, + }, + ollama: { + enabled: formState.ollama.enabled, + baseUrl: formState.ollama.baseUrl, + defaultModel: formState.ollama.defaultModel, + }, + taskDefaults: formState.taskDefaults, + secEdgarUserAgent: status.secEdgarUserAgent, + }; + }, [formState, status]); - const setTaskRoute = useCallback( - (task: TaskProfile, updater: (route: AgentTaskRoute) => AgentTaskRoute) => { - setFormState((current) => ({ - ...current, - taskDefaults: { - ...current.taskDefaults, - [task]: updater(current.taskDefaults[task]), - }, - })); - }, - [], - ); + const saveRuntimeConfig = useCallback(async () => { + if (!runtimeRequest) { + throw new Error('Settings are not loaded yet'); + } - const saveRuntimeConfig = async () => { const nextStatus = await agentSettingsBridge.saveRuntimeConfig(runtimeRequest); onStatusChange(nextStatus); return nextStatus; - }; + }, [onStatusChange, runtimeRequest]); const handleSaveRuntime = async () => { - // Validate before save - const urlValidation = validateUrl(formState.remoteBaseUrl); - if (!urlValidation.valid) { - setError(urlValidation.error || 'Please fix validation errors before saving'); + if (validation.remoteBaseUrl === 'invalid' || validation.ollamaBaseUrl === 'invalid') { + setError('Please fix validation errors before saving'); return; } @@ -306,9 +260,11 @@ export const AgentSettingsForm: React.FC = ({ await saveRuntimeConfig(); setSuccess('Settings saved successfully'); setInitialState({ ...formState, remoteApiKey: '' }); - setFormState((prev) => ({ ...prev, remoteApiKey: '' })); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save settings. Please try again.'); + setFormState((current) => ({ ...current, remoteApiKey: '' })); + } catch (saveError) { + setError( + saveError instanceof Error ? saveError.message : 'Failed to save settings. Please try again.', + ); } finally { setIsBusy(false); } @@ -325,29 +281,25 @@ export const AgentSettingsForm: React.FC = ({ setSuccess(null); try { - const savedStatus = await saveRuntimeConfig(); + await saveRuntimeConfig(); const nextStatus = await agentSettingsBridge.updateRemoteApiKey({ apiKey: formState.remoteApiKey, }); - onStatusChange({ ...savedStatus, ...nextStatus }); - setFormState((prev) => ({ ...prev, remoteApiKey: '' })); - setSuccess(status.hasRemoteApiKey ? 'API key updated successfully' : 'API key saved successfully'); + onStatusChange(nextStatus); + setFormState((current) => ({ ...current, remoteApiKey: '' })); setValidation((prev) => ({ ...prev, apiKey: 'idle' })); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save API key. Please try again.'); + setSuccess( + status?.providers.remote.hasApiKey ? 'API key updated successfully' : 'API key saved successfully', + ); + } catch (saveError) { + setError( + saveError instanceof Error ? saveError.message : 'Failed to save API key. Please try again.', + ); } finally { setIsBusy(false); } }; - const handleClearApiKeyClick = () => { - if (dontAskAgain) { - handleClearRemoteApiKey(); - } else { - setShowClearConfirm(true); - } - }; - const handleClearRemoteApiKey = async () => { setIsBusy(true); setError(null); @@ -355,85 +307,77 @@ export const AgentSettingsForm: React.FC = ({ setShowClearConfirm(false); try { - const savedStatus = await saveRuntimeConfig(); + await saveRuntimeConfig(); const nextStatus = await agentSettingsBridge.clearRemoteApiKey(); - onStatusChange({ ...savedStatus, ...nextStatus }); + onStatusChange(nextStatus); setSuccess('API key cleared successfully'); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to clear API key. Please try again.'); + } catch (clearError) { + setError( + clearError instanceof Error ? clearError.message : 'Failed to clear API key. Please try again.', + ); } finally { setIsBusy(false); } }; - const handleDefaultRemoteModelChange = (nextValue: string) => { - const previousValue = formState.defaultRemoteModel; - setFormState((current) => { - const nextTaskDefaults = { ...current.taskDefaults }; - for (const profile of TASK_PROFILES) { - if (nextTaskDefaults[profile].model.trim() === previousValue.trim()) { - nextTaskDefaults[profile] = { model: nextValue }; - } - } - return { + const setTaskRoute = useCallback( + (task: TaskProfile, updates: Partial) => { + setFormState((current) => ({ ...current, - defaultRemoteModel: nextValue, - taskDefaults: nextTaskDefaults, - }; - }); - }; + taskDefaults: { + ...current.taskDefaults, + [task]: { + ...current.taskDefaults[task], + ...updates, + }, + }, + })); + }, + [], + ); - const handleProviderKindChange = (nextProviderKind: AgentProviderKind) => { - setFormState((current) => ({ - ...current, - providerKind: nextProviderKind, - remoteBaseUrl: shouldReplaceBaseUrl(current.remoteBaseUrl) - ? PROVIDER_BASE_URLS[nextProviderKind] - : current.remoteBaseUrl, - })); - }; + if (!status) { + return ( +
+
+ + Loading AI settings... +
+
+ ); + } + const remoteReadyLabel = status.providers.remote.configured ? 'Ready' : 'Needs setup'; + const ollamaReadyLabel = status.providers.ollama.configured ? 'Ready' : 'Needs setup'; const isFormValid = - (validation.baseUrl === 'valid' || validation.baseUrl === 'idle') && - (validation.secEdgarUserAgent === 'valid' || validation.secEdgarUserAgent === 'idle'); - const providerHelpers = PROVIDER_HELPERS[formState.providerKind]; - const providerModelOptions = PROVIDER_MODEL_OPTIONS[formState.providerKind]; - const usesApiKey = providerUsesApiKey(formState.providerKind); - const apiKeyStatusLabel = usesApiKey - ? status.hasRemoteApiKey - ? 'Stored' - : 'Not set' - : status.hasRemoteApiKey - ? 'Unused stored key' - : 'Not required'; + validation.remoteBaseUrl !== 'invalid' && validation.ollamaBaseUrl !== 'invalid'; return (
- {/* Runtime Status Section */}

Runtime Status

- {status.configured ? 'All systems operational' : 'Configuration required'} + {status.configured ? 'All configured task routes are available' : 'Some flows still need configuration'}

-
-
+
+
- - Provider: {AGENT_PROVIDER_LABELS[status.providerKind]} - {status.remoteConfigured ? ' / Ready' : ' / Needs setup'} - + Remote: {remoteReadyLabel}
-
+
+ + Local (Ollama): {ollamaReadyLabel} +
+
- API key: {apiKeyStatusLabel} + Remote API key: {status.providers.remote.hasApiKey ? 'Stored' : 'Missing'}
- {/* Configuration badge */}
= ({
- {/* Remote Provider Section */}
-
-
-
-

AI Provider

- -
-

- {providerHelpers.description} -

+
+
+

Remote Provider

+
- +

+ Use this provider for hosted models that require an API key. +

-
+ +
+
+
+

Remote API Key

+ +
+

+ {status.providers.remote.hasApiKey + ? 'A Remote API key is already stored.' + : 'Required for any task route that uses the Remote provider.'} +

+
+ +
+ + setFormState((current) => ({ ...current, remoteApiKey: event.target.value })) + } + placeholder="Enter your API key" + validationStatus={validation.apiKey} + helperText="Only the Remote provider uses an API key." + disabled={isBusy} + className="pr-20" + /> + +
+ +
+
+ {status.providers.remote.hasApiKey && ( + + )} +
+ +
+
+
+ +
+
+
+

Local Provider

+ +
+

+ Local (Ollama) uses `http://localhost:11434/v1` by default and does not need an API key. +

+
+ +
+ + + + setFormState((current) => ({ + ...current, + ollama: { ...current.ollama, baseUrl: event.target.value }, + })) + } + placeholder={OLLAMA_DEFAULT_BASE_URL} + validationStatus={validation.ollamaBaseUrl} + errorMessage={validation.ollamaBaseUrlError} + helperText="Endpoint for the local Ollama OpenAI-compatible API." + disabled={isBusy} + /> + +
+ + + setFormState((current) => ({ + ...current, + ollama: { ...current.ollama, defaultModel: value }, + })) + } + options={OLLAMA_MODEL_OPTIONS} + placeholder="Select or enter an Ollama model" + disabled={isBusy} + /> +

+ Used when a task route points to Local (Ollama) and leaves its model blank.

- {/* Task Models Section */}
-

Task-Specific Models

- +

Task Routing

+

- Customize which model handles each type of task. Inherits from default if not specified. + Each flow can independently use Remote or Local (Ollama). Leave a route model blank to inherit the selected provider default model.

- {TASK_PROFILES.map((task) => ( -
-
-
{TASK_LABELS[task]}
-
-
- ))} + ); + })}
@@ -593,7 +696,7 @@ export const AgentSettingsForm: React.FC = ({ type="button" onClick={handleSaveRuntime} disabled={isBusy || !hasUnsavedChanges || !isFormValid} - className="flex items-center gap-2 rounded border border-info bg-[var(--term-status-info)] px-4 py-2 text-xs font-mono text-info transition-colors hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-[var(--term-status-info)]" + className="flex items-center gap-2 rounded border border-info bg-[var(--term-status-info)] px-4 py-2 text-xs font-mono text-info transition-colors hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50" aria-describedby="save-hint" > {isBusy ? ( @@ -614,114 +717,6 @@ export const AgentSettingsForm: React.FC = ({
- {/* API Key Section */} -
-
-
-

API Key

- -
-

- {usesApiKey - ? 'Authentication credential for your AI provider.' - : 'Optional for Ollama. The documented placeholder token is supplied automatically.'} -

-
- -
- {usesApiKey ? ( - <> -
- setFormState((prev) => ({ ...prev, remoteApiKey: e.target.value }))} - placeholder="Enter your API key" - validationStatus={validation.apiKey} - helperText={ - status.hasRemoteApiKey && !formState.remoteApiKey - ? 'A remote API key is currently stored.' - : 'Required for API requests.' - } - disabled={isBusy} - fullWidth - className="pr-20" - /> - -
- -
-
- {status.hasRemoteApiKey && ( - - )} -
- -
- - ) : ( -
- - Ollama compatibility mode ignores stored API keys and uses the placeholder token - documented by Ollama. - - {status.hasRemoteApiKey && ( - - )} -
- )} -
-
- - {/* Success Message */} {success && (
= ({
)} - {/* Error Message */} {error && (
= ({
)} - {/* Confirmation Dialog */} { + void handleClearRemoteApiKey(); + }} onCancel={() => setShowClearConfirm(false)} showDontAskAgain onDontAskAgainChange={setDontAskAgain} diff --git a/MosaicIQ/src/components/Settings/ModelSelector.tsx b/MosaicIQ/src/components/Settings/ModelSelector.tsx index e86bdd2..ed3bfd6 100644 --- a/MosaicIQ/src/components/Settings/ModelSelector.tsx +++ b/MosaicIQ/src/components/Settings/ModelSelector.tsx @@ -62,6 +62,15 @@ export const ModelSelector: React.FC = ({ }); const selectedOption = options.find((opt) => opt.value === value); + const displayOption = + selectedOption ?? + (value.trim() + ? { + value, + label: value, + provider: 'Custom', + } + : null); const handleSelect = (optionValue: string) => { onChange(optionValue); @@ -116,11 +125,11 @@ export const ModelSelector: React.FC = ({ >
- {selectedOption ? ( + {displayOption ? ( - {selectedOption.label} - {selectedOption.provider && ( - {selectedOption.provider} + {displayOption.label} + {displayOption.provider && ( + {displayOption.provider} )} ) : ( diff --git a/MosaicIQ/src/components/Settings/SecEdgarSettingsCard.tsx b/MosaicIQ/src/components/Settings/SecEdgarSettingsCard.tsx index d559e7a..367cfc1 100644 --- a/MosaicIQ/src/components/Settings/SecEdgarSettingsCard.tsx +++ b/MosaicIQ/src/components/Settings/SecEdgarSettingsCard.tsx @@ -90,10 +90,16 @@ export const SecEdgarSettingsCard: React.FC = ({ try { const nextStatus = await agentSettingsBridge.saveRuntimeConfig({ - remoteEnabled: status.remoteEnabled, - providerKind: status.providerKind, - remoteBaseUrl: status.remoteBaseUrl, - defaultRemoteModel: status.defaultRemoteModel, + remote: { + enabled: status.providers.remote.enabled, + baseUrl: status.providers.remote.baseUrl, + defaultModel: status.providers.remote.defaultModel, + }, + ollama: { + enabled: status.providers.ollama.enabled, + baseUrl: status.providers.ollama.baseUrl, + defaultModel: status.providers.ollama.defaultModel, + }, taskDefaults: status.taskDefaults, secEdgarUserAgent: value, }); diff --git a/MosaicIQ/src/components/Settings/SettingsPage.tsx b/MosaicIQ/src/components/Settings/SettingsPage.tsx index 3eb1149..162f35c 100644 --- a/MosaicIQ/src/components/Settings/SettingsPage.tsx +++ b/MosaicIQ/src/components/Settings/SettingsPage.tsx @@ -132,13 +132,26 @@ export const SettingsPage: React.FC = ({ ), }, { - label: "AI provider", - value: status?.remoteEnabled - ? status - ? AGENT_PROVIDER_LABELS[status.providerKind] - : "Enabled" + label: "Remote provider", + value: status?.providers.remote.enabled + ? status.providers.remote.configured + ? "Ready" + : "Needs setup" : "Disabled", - icon: status?.remoteEnabled ? ( + icon: status?.providers.remote.enabled ? ( + + ) : ( + + ), + }, + { + label: "Local provider", + value: status?.providers.ollama.enabled + ? status.providers.ollama.configured + ? "Ready" + : "Needs setup" + : "Disabled", + icon: status?.providers.ollama.enabled ? ( ) : ( @@ -147,15 +160,11 @@ export const SettingsPage: React.FC = ({ { label: "API key", value: status - ? status.providerKind === "remote" - ? status.hasRemoteApiKey - ? "Stored" - : "Missing" - : status.hasRemoteApiKey - ? "Unused" - : "Optional" + ? status.providers.remote.hasApiKey + ? "Stored" + : "Missing" : "Missing", - icon: status?.hasRemoteApiKey ? ( + icon: status?.providers.remote.hasApiKey ? ( ) : ( diff --git a/MosaicIQ/src/types/agentSettings.ts b/MosaicIQ/src/types/agentSettings.ts index 9f8dfcb..36fe21a 100644 --- a/MosaicIQ/src/types/agentSettings.ts +++ b/MosaicIQ/src/types/agentSettings.ts @@ -1,4 +1,4 @@ -export type AgentProviderKind = 'remote' | 'ollama_openai' | 'ollama_anthropic'; +export type AgentProviderKind = 'remote' | 'ollama'; export type TaskProfile = | 'interactiveChat' @@ -11,27 +11,46 @@ export type TaskProfile = | 'memoStructuring'; export interface AgentTaskRoute { + provider: AgentProviderKind; model: string; } +export interface ProviderConfigStatus { + enabled: boolean; + configured: boolean; + baseUrl: string; + defaultModel: string; + hasApiKey: boolean; +} + +export interface AgentProviderStatuses { + remote: ProviderConfigStatus; + ollama: ProviderConfigStatus; +} + export interface AgentConfigStatus { configured: boolean; - remoteConfigured: boolean; - remoteEnabled: boolean; - providerKind: AgentProviderKind; - hasRemoteApiKey: boolean; - hasSecEdgarUserAgent: boolean; - remoteBaseUrl: string; - defaultRemoteModel: string; + providers: AgentProviderStatuses; taskDefaults: Record; + hasSecEdgarUserAgent: boolean; secEdgarUserAgent: string; } +export interface SaveRemoteProviderSettings { + enabled: boolean; + baseUrl: string; + defaultModel: string; +} + +export interface SaveOllamaProviderSettings { + enabled: boolean; + baseUrl: string; + defaultModel: string; +} + export interface SaveAgentRuntimeConfigRequest { - remoteEnabled: boolean; - providerKind: AgentProviderKind; - remoteBaseUrl: string; - defaultRemoteModel: string; + remote: SaveRemoteProviderSettings; + ollama: SaveOllamaProviderSettings; taskDefaults: Record; secEdgarUserAgent: string; } @@ -53,8 +72,7 @@ export const TASK_PROFILES: TaskProfile[] = [ export const AGENT_PROVIDER_LABELS: Record = { remote: 'Remote', - ollama_openai: 'Ollama (OpenAI Compat)', - ollama_anthropic: 'Ollama (Anthropic Compat)', + ollama: 'Local (Ollama)', }; export const TASK_LABELS: Record = {