From 27dfe629fe9bbfaa53fdfa73a9d776b64bb99ec1 Mon Sep 17 00:00:00 2001 From: francy51 Date: Sat, 11 Apr 2026 21:03:05 -0400 Subject: [PATCH] Fix local model setup UX and clean Rust warnings --- MosaicIQ/src-tauri/src/agent/gateway.rs | 30 +- MosaicIQ/src-tauri/src/agent/mod.rs | 4 +- MosaicIQ/src-tauri/src/agent/routing.rs | 14 +- MosaicIQ/src-tauri/src/agent/service.rs | 16 +- MosaicIQ/src-tauri/src/agent/settings.rs | 25 +- MosaicIQ/src-tauri/src/commands/research.rs | 3 +- MosaicIQ/src-tauri/src/error.rs | 13 + MosaicIQ/src-tauri/src/news/types.rs | 9 +- MosaicIQ/src-tauri/src/research/ai.rs | 6 +- MosaicIQ/src-tauri/src/research/ghosts.rs | 208 +++++++------ MosaicIQ/src-tauri/src/research/service.rs | 289 ++++++++++-------- .../components/Settings/AgentSettingsForm.tsx | 92 +++++- .../src/components/Settings/ModelSelector.tsx | 250 ++++++++------- .../src/components/Settings/SettingsPage.tsx | 1 - 14 files changed, 581 insertions(+), 379 deletions(-) diff --git a/MosaicIQ/src-tauri/src/agent/gateway.rs b/MosaicIQ/src-tauri/src/agent/gateway.rs index 907eb55..682caf7 100644 --- a/MosaicIQ/src-tauri/src/agent/gateway.rs +++ b/MosaicIQ/src-tauri/src/agent/gateway.rs @@ -12,7 +12,7 @@ use rig::{ use crate::agent::stream_events::AgentStreamEmitter; use crate::agent::tools::terminal_command::{AgentCommandExecutor, RunTerminalCommandTool}; -use crate::agent::AgentRuntimeConfig; +use crate::agent::{AgentProviderKind, AgentRuntimeConfig, DEFAULT_OLLAMA_COMPAT_API_KEY}; use crate::error::AppError; use crate::state::PendingAgentToolApprovals; @@ -120,8 +120,20 @@ impl ChatGateway for RigChatGateway { } } -fn build_openai_client(runtime: &AgentRuntimeConfig) -> Result { - let api_key = runtime.api_key.clone().unwrap_or_default(); +fn build_openai_client( + runtime: &AgentRuntimeConfig, +) -> Result { + let api_key = match runtime.provider_kind { + AgentProviderKind::Remote => runtime + .api_key + .clone() + .filter(|value| !value.trim().is_empty()) + .ok_or(AppError::RemoteApiKeyMissing)?, + AgentProviderKind::Ollama => runtime + .api_key + .clone() + .unwrap_or_else(|| DEFAULT_OLLAMA_COMPAT_API_KEY.to_string()), + }; openai::CompletionsClient::builder() .api_key(api_key) @@ -152,9 +164,9 @@ where reply.push_str(&text.text); tool_runtime.stream_emitter.text_delta(text.text)?; } - Ok(MultiTurnStreamItem::StreamAssistantItem( - StreamedAssistantContent::Reasoning(reasoning), - )) => { + Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::Reasoning( + reasoning, + ))) => { if saw_reasoning_delta { continue; } @@ -172,9 +184,9 @@ where saw_reasoning_delta = true; tool_runtime.stream_emitter.reasoning_delta(reasoning)?; } - Ok(MultiTurnStreamItem::StreamAssistantItem( - StreamedAssistantContent::ToolCall { .. }, - )) => {} + Ok(MultiTurnStreamItem::StreamAssistantItem(StreamedAssistantContent::ToolCall { + .. + })) => {} Ok(MultiTurnStreamItem::StreamAssistantItem( StreamedAssistantContent::ToolCallDelta { .. }, )) => {} diff --git a/MosaicIQ/src-tauri/src/agent/mod.rs b/MosaicIQ/src-tauri/src/agent/mod.rs index ef9768f..99f9184 100644 --- a/MosaicIQ/src-tauri/src/agent/mod.rs +++ b/MosaicIQ/src-tauri/src/agent/mod.rs @@ -16,8 +16,8 @@ pub use stream_events::AgentStreamEmitter; pub use types::{ default_task_defaults, AgentConfigStatus, AgentProviderKind, AgentProviderStatuses, AgentRuntimeConfig, AgentStoredSettings, AgentStreamItemEvent, AgentStreamItemKind, - AgentTaskRoute, ChatPanelContext, ChatPromptRequest, ChatStreamStart, - OllamaProviderSettings, PreparedChatTurn, ProviderConfigStatus, RemoteProviderSettings, + 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 a6fd8cd..0e8163d 100644 --- a/MosaicIQ/src-tauri/src/agent/routing.rs +++ b/MosaicIQ/src-tauri/src/agent/routing.rs @@ -63,7 +63,8 @@ pub fn validate_settings(settings: &AgentStoredSettings) -> Result<(), AppError> ))); } - if route.provider == AgentProviderKind::Remote && settings.remote.api_key.trim().is_empty() { + 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) @@ -100,14 +101,14 @@ pub fn normalize_routes(settings: &mut AgentStoredSettings) -> Result<(), AppErr pub fn compute_provider_configured( settings: &AgentStoredSettings, - provider: AgentProviderKind, + provider_kind: AgentProviderKind, ) -> bool { - let provider = provider_settings(settings, provider); + let provider = provider_settings(settings, provider_kind); 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()) + && (!provider_kind.uses_api_key() || !settings.remote.api_key.trim().is_empty()) } pub fn compute_overall_configured(settings: &AgentStoredSettings) -> bool { @@ -184,11 +185,6 @@ impl ProviderSettingsRef<'_> { 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", diff --git a/MosaicIQ/src-tauri/src/agent/service.rs b/MosaicIQ/src-tauri/src/agent/service.rs index 82586b9..842ad20 100644 --- a/MosaicIQ/src-tauri/src/agent/service.rs +++ b/MosaicIQ/src-tauri/src/agent/service.rs @@ -365,6 +365,13 @@ impl AgentService { task_profile.unwrap_or(TaskProfile::InteractiveChat), model_override, ) + .map_err(|error| { + if error.is_configuration_issue() { + AppError::AgentNotConfigured + } else { + error + } + }) } } @@ -632,8 +639,8 @@ mod tests { .save_runtime_config(SaveAgentRuntimeConfigRequest { remote_enabled: true, provider_kind: AgentProviderKind::OllamaOpenAI, - remote_base_url: - super::super::types::DEFAULT_OLLAMA_OPENAI_BASE_URL.to_string(), + remote_base_url: super::super::types::DEFAULT_OLLAMA_OPENAI_BASE_URL + .to_string(), default_remote_model: "qwen3-coder".to_string(), task_defaults: default_task_defaults("qwen3-coder"), sec_edgar_user_agent: String::new(), @@ -645,7 +652,10 @@ mod tests { assert!(!saved.has_remote_api_key); let prepared = prepare_turn(&mut service, request("hello")).unwrap(); - assert_eq!(prepared.runtime.provider_kind, AgentProviderKind::OllamaOpenAI); + assert_eq!( + prepared.runtime.provider_kind, + AgentProviderKind::OllamaOpenAI + ); assert_eq!( prepared.runtime.api_key.as_deref(), Some(crate::agent::DEFAULT_OLLAMA_COMPAT_API_KEY) diff --git a/MosaicIQ/src-tauri/src/agent/settings.rs b/MosaicIQ/src-tauri/src/agent/settings.rs index e1f97ad..953221e 100644 --- a/MosaicIQ/src-tauri/src/agent/settings.rs +++ b/MosaicIQ/src-tauri/src/agent/settings.rs @@ -204,16 +204,31 @@ 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), + ); + 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_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( + OLLAMA_ENABLED_KEY.to_string(), + json!(settings.ollama.enabled), + ); + store.set( + OLLAMA_BASE_URL_KEY.to_string(), + json!(settings.ollama.base_url), + ); store.set( OLLAMA_DEFAULT_MODEL_KEY.to_string(), json!(settings.ollama.default_model), diff --git a/MosaicIQ/src-tauri/src/commands/research.rs b/MosaicIQ/src-tauri/src/commands/research.rs index 68f0c0d..0f1e482 100644 --- a/MosaicIQ/src-tauri/src/commands/research.rs +++ b/MosaicIQ/src-tauri/src/commands/research.rs @@ -4,7 +4,8 @@ use crate::research::{ GetWorkspaceProjectionRequest, GhostNote, ListNoteLinksRequest, ListResearchNotesRequest, ListWorkspaceGhostNotesRequest, NoteAuditTrail, NoteCaptureResult, NoteLink, PipelineJob, PromoteNoteToThesisRequest, ResearchBundleExport, ResearchNote, ResearchWorkspace, - RetryResearchJobsRequest, ReviewGhostNoteRequest, UpdateResearchNoteRequest, WorkspaceProjection, + RetryResearchJobsRequest, ReviewGhostNoteRequest, UpdateResearchNoteRequest, + WorkspaceProjection, }; use crate::state::AppState; diff --git a/MosaicIQ/src-tauri/src/error.rs b/MosaicIQ/src-tauri/src/error.rs index 2936c41..1c0011f 100644 --- a/MosaicIQ/src-tauri/src/error.rs +++ b/MosaicIQ/src-tauri/src/error.rs @@ -21,6 +21,19 @@ pub enum AppError { ModelMissing(TaskProfile), } +impl AppError { + pub const fn is_configuration_issue(&self) -> bool { + matches!( + self, + Self::AgentNotConfigured + | Self::RemoteApiKeyMissing + | Self::ProviderNotConfigured + | Self::TaskRouteMissing(_) + | Self::ModelMissing(_) + ) + } +} + impl Display for AppError { fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result { match self { diff --git a/MosaicIQ/src-tauri/src/news/types.rs b/MosaicIQ/src-tauri/src/news/types.rs index 26d441a..207b8ed 100644 --- a/MosaicIQ/src-tauri/src/news/types.rs +++ b/MosaicIQ/src-tauri/src/news/types.rs @@ -1,20 +1,15 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum NewsSentiment { Bull, Bear, + #[default] Neutral, } -impl Default for NewsSentiment { - fn default() -> Self { - Self::Neutral - } -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum HighlightReason { diff --git a/MosaicIQ/src-tauri/src/research/ai.rs b/MosaicIQ/src-tauri/src/research/ai.rs index 67fbf61..dfb08c4 100644 --- a/MosaicIQ/src-tauri/src/research/ai.rs +++ b/MosaicIQ/src-tauri/src/research/ai.rs @@ -77,7 +77,11 @@ fn build_annotation(note: &ResearchNote) -> String { } } -pub(crate) fn build_model_info(model: &str, task_profile: &str, provider: &str) -> Option { +pub(crate) fn build_model_info( + model: &str, + task_profile: &str, + provider: &str, +) -> Option { if model.trim().is_empty() { return None; } diff --git a/MosaicIQ/src-tauri/src/research/ghosts.rs b/MosaicIQ/src-tauri/src/research/ghosts.rs index 95c04c1..9353acb 100644 --- a/MosaicIQ/src-tauri/src/research/ghosts.rs +++ b/MosaicIQ/src-tauri/src/research/ghosts.rs @@ -27,6 +27,19 @@ pub(crate) fn generate_ghost_notes( ghosts } +struct GhostDraft { + ghost_class: GhostNoteClass, + supporting_note_ids: Vec, + contradicting_note_ids: Vec, + source_ids: Vec, + headline: String, + body: String, + confidence: f32, + evidence_threshold_met: bool, + visibility_state: GhostVisibilityState, + memo_section_hint: Option, +} + fn generate_missing_evidence_prompts( workspace: &ResearchWorkspace, notes: &[ResearchNote], @@ -39,21 +52,23 @@ fn generate_missing_evidence_prompts( link.from_note_id == note.id && matches!(link.link_type, LinkType::SourcedBy | LinkType::Supports) }) }) - .map(|note| ghost( - workspace, - GhostNoteClass::MissingEvidencePrompt, - vec![note.id.clone()], - Vec::new(), - note.source_id.iter().cloned().collect(), - "Missing evidence for claim".to_string(), - format!( - "Observed: this claim is currently unsupported by a linked source note. Inference: the argument may be directionally useful but should not be treated as evidence yet. What would confirm/refute: add a filing, transcript, article, or model-backed source." - ), - 0.42, - false, - GhostVisibilityState::Collapsed, - None, - )) + .map(|note| { + ghost( + workspace, + GhostDraft { + ghost_class: GhostNoteClass::MissingEvidencePrompt, + supporting_note_ids: vec![note.id.clone()], + contradicting_note_ids: Vec::new(), + source_ids: note.source_id.iter().cloned().collect(), + headline: "Missing evidence for claim".to_string(), + body: "Observed: this claim is currently unsupported by a linked source note. Inference: the argument may be directionally useful but should not be treated as evidence yet. What would confirm/refute: add a filing, transcript, article, or model-backed source.".to_string(), + confidence: 0.42, + evidence_threshold_met: false, + visibility_state: GhostVisibilityState::Collapsed, + memo_section_hint: None, + }, + ) + }) .collect() } @@ -73,19 +88,21 @@ fn generate_contradiction_alerts( let right = notes_by_id.get(link.to_note_id.as_str())?; Some(ghost( workspace, - GhostNoteClass::ContradictionAlert, - vec![left.id.clone(), right.id.clone()], - vec![right.id.clone()], - collect_source_ids([*left, *right]), - "Contradiction alert".to_string(), - format!( - "Observed: {}. Inference: this conflicts with {}. What would confirm/refute: reconcile the newer datapoint, source freshness, and management framing before treating either statement as settled.", - left.cleaned_text, right.cleaned_text - ), - 0.86, - true, - GhostVisibilityState::Visible, - Some(MemoSectionKind::RiskRegister), + GhostDraft { + ghost_class: GhostNoteClass::ContradictionAlert, + supporting_note_ids: vec![left.id.clone(), right.id.clone()], + contradicting_note_ids: vec![right.id.clone()], + source_ids: collect_source_ids([*left, *right]), + headline: "Contradiction alert".to_string(), + body: format!( + "Observed: {}. Inference: this conflicts with {}. What would confirm/refute: reconcile the newer datapoint, source freshness, and management framing before treating either statement as settled.", + left.cleaned_text, right.cleaned_text + ), + confidence: 0.86, + evidence_threshold_met: true, + visibility_state: GhostVisibilityState::Visible, + memo_section_hint: Some(MemoSectionKind::RiskRegister), + }, )) }) .collect() @@ -114,16 +131,18 @@ fn generate_candidate_risks( .collect::>(); vec![ghost( workspace, - GhostNoteClass::CandidateRisk, - supporting_ids, - Vec::new(), - collect_source_ids(risk_notes.into_iter()), - "Possible emerging risk cluster".to_string(), - "Observed: several notes point to downside pressure around the same operating area. Inference: this may represent an investable risk theme, but it should remain provisional until the source trail is tightened. What would confirm/refute: corroborate with fresh operating evidence or management disclosures.".to_string(), - 0.73, - true, - GhostVisibilityState::Visible, - Some(MemoSectionKind::RiskRegister), + GhostDraft { + ghost_class: GhostNoteClass::CandidateRisk, + supporting_note_ids: supporting_ids, + contradicting_note_ids: Vec::new(), + source_ids: collect_source_ids(risk_notes.into_iter()), + headline: "Possible emerging risk cluster".to_string(), + body: "Observed: several notes point to downside pressure around the same operating area. Inference: this may represent an investable risk theme, but it should remain provisional until the source trail is tightened. What would confirm/refute: corroborate with fresh operating evidence or management disclosures.".to_string(), + confidence: 0.73, + evidence_threshold_met: true, + visibility_state: GhostVisibilityState::Visible, + memo_section_hint: Some(MemoSectionKind::RiskRegister), + }, )] } @@ -150,16 +169,18 @@ fn generate_candidate_catalysts( .collect::>(); vec![ghost( workspace, - GhostNoteClass::CandidateCatalyst, - supporting_ids, - Vec::new(), - collect_source_ids(catalyst_notes.into_iter()), - "Possible catalyst cluster".to_string(), - "Observed: multiple notes point toward an identifiable event or operating trigger. Inference: this could matter for the next stock move if timing and evidence quality hold. What would confirm/refute: map the catalyst to dated milestones and an observable KPI.".to_string(), - 0.7, - true, - GhostVisibilityState::Visible, - Some(MemoSectionKind::CatalystCalendar), + GhostDraft { + ghost_class: GhostNoteClass::CandidateCatalyst, + supporting_note_ids: supporting_ids, + contradicting_note_ids: Vec::new(), + source_ids: collect_source_ids(catalyst_notes.into_iter()), + headline: "Possible catalyst cluster".to_string(), + body: "Observed: multiple notes point toward an identifiable event or operating trigger. Inference: this could matter for the next stock move if timing and evidence quality hold. What would confirm/refute: map the catalyst to dated milestones and an observable KPI.".to_string(), + confidence: 0.7, + evidence_threshold_met: true, + visibility_state: GhostVisibilityState::Visible, + memo_section_hint: Some(MemoSectionKind::CatalystCalendar), + }, )] } @@ -195,16 +216,18 @@ fn generate_valuation_bridges( support.extend(driver_notes.iter().take(2).map(|note| note.id.clone())); vec![ghost( workspace, - GhostNoteClass::ValuationBridge, - support, - Vec::new(), - collect_source_ids(valuation_notes.into_iter().chain(driver_notes.into_iter())), - "Possible valuation bridge".to_string(), - "Observed: valuation notes point to a discount while operating notes suggest a driver that could narrow that gap. Inference: there may be a rerating bridge if the operating evidence persists. What would confirm/refute: track the KPI that should transmit into multiple expansion.".to_string(), - 0.76, - true, - GhostVisibilityState::Visible, - Some(MemoSectionKind::ValuationWriteUp), + GhostDraft { + ghost_class: GhostNoteClass::ValuationBridge, + supporting_note_ids: support, + contradicting_note_ids: Vec::new(), + source_ids: collect_source_ids(valuation_notes.into_iter().chain(driver_notes.into_iter())), + headline: "Possible valuation bridge".to_string(), + body: "Observed: valuation notes point to a discount while operating notes suggest a driver that could narrow that gap. Inference: there may be a rerating bridge if the operating evidence persists. What would confirm/refute: track the KPI that should transmit into multiple expansion.".to_string(), + confidence: 0.76, + evidence_threshold_met: true, + visibility_state: GhostVisibilityState::Visible, + memo_section_hint: Some(MemoSectionKind::ValuationWriteUp), + }, )] } @@ -270,20 +293,22 @@ fn generate_candidate_thesis( Some(ghost( workspace, - GhostNoteClass::CandidateThesis, - notes.iter().take(6).map(|note| note.id.clone()).collect(), - Vec::new(), - collect_source_ids(notes.iter()), - headline.to_string(), - body.to_string(), - if has_unresolved_contradiction { - 0.68 - } else { - 0.82 + GhostDraft { + ghost_class: GhostNoteClass::CandidateThesis, + supporting_note_ids: notes.iter().take(6).map(|note| note.id.clone()).collect(), + contradicting_note_ids: Vec::new(), + source_ids: collect_source_ids(notes.iter()), + headline: headline.to_string(), + body: body.to_string(), + confidence: if has_unresolved_contradiction { + 0.68 + } else { + 0.82 + }, + evidence_threshold_met: !has_unresolved_contradiction, + visibility_state: GhostVisibilityState::Visible, + memo_section_hint: Some(MemoSectionKind::InvestmentMemo), }, - !has_unresolved_contradiction, - GhostVisibilityState::Visible, - Some(MemoSectionKind::InvestmentMemo), )) } @@ -306,47 +331,36 @@ fn rank_and_limit_visibility(ghosts: &mut [GhostNote]) { } } -fn ghost( - workspace: &ResearchWorkspace, - ghost_class: GhostNoteClass, - supporting_note_ids: Vec, - contradicting_note_ids: Vec, - source_ids: Vec, - headline: String, - body: String, - confidence: f32, - evidence_threshold_met: bool, - visibility_state: GhostVisibilityState, - memo_section_hint: Option, -) -> GhostNote { +fn ghost(workspace: &ResearchWorkspace, draft: GhostDraft) -> GhostNote { let now = now_rfc3339(); let mut key_parts = BTreeSet::new(); - key_parts.extend(supporting_note_ids.iter().cloned()); - key_parts.extend(contradicting_note_ids.iter().cloned()); + key_parts.extend(draft.supporting_note_ids.iter().cloned()); + key_parts.extend(draft.contradicting_note_ids.iter().cloned()); let ghost_key = format!( - "{ghost_class:?}-{}", + "{:?}-{}", + draft.ghost_class, key_parts.into_iter().collect::>().join(",") ); GhostNote { id: format!("ghost-{}", &sha256_hex(&ghost_key)[..16]), workspace_id: workspace.id.clone(), - ghost_class, - headline, - body, + ghost_class: draft.ghost_class, + headline: draft.headline, + body: draft.body, tone: GhostTone::Tentative, - confidence, - visibility_state, + confidence: draft.confidence, + visibility_state: draft.visibility_state, state: GhostLifecycleState::Generated, - supporting_note_ids, - contradicting_note_ids, - source_ids, - evidence_threshold_met, + supporting_note_ids: draft.supporting_note_ids, + contradicting_note_ids: draft.contradicting_note_ids, + source_ids: draft.source_ids, + evidence_threshold_met: draft.evidence_threshold_met, created_at: now.clone(), updated_at: now, superseded_by_ghost_id: None, promoted_note_id: None, - memo_section_hint, + memo_section_hint: draft.memo_section_hint, } } diff --git a/MosaicIQ/src-tauri/src/research/service.rs b/MosaicIQ/src-tauri/src/research/service.rs index becb331..c14bff8 100644 --- a/MosaicIQ/src-tauri/src/research/service.rs +++ b/MosaicIQ/src-tauri/src/research/service.rs @@ -25,12 +25,12 @@ use crate::research::types::{ ArchiveResearchNoteRequest, AuditActor, AuditEntityKind, AuditEvent, CaptureMethod, CaptureResearchNoteRequest, CreateResearchWorkspaceRequest, EvidenceStatus, ExportResearchBundleRequest, GetNoteAuditTrailRequest, GetNotesByTickerRequest, - GetWorkspaceProjectionRequest, GhostLifecycleState, GhostReviewAction, GhostStatus, - LinkOrigin, ListNoteLinksRequest, ListResearchNotesRequest, ListWorkspaceGhostNotesRequest, - NoteAuditTrail, NoteCaptureResult, NotePriority, NoteType, PromoteNoteToThesisRequest, - ProvenanceActor, ResearchBundleExport, ResearchNote, ResearchWorkspace, RetryResearchJobsRequest, - ReviewGhostNoteRequest, SourceKind, ThesisStatus, UpdateResearchNoteRequest, WorkspaceProjection, - WorkspaceScope, + GetWorkspaceProjectionRequest, GhostLifecycleState, GhostReviewAction, GhostStatus, LinkOrigin, + ListNoteLinksRequest, ListResearchNotesRequest, ListWorkspaceGhostNotesRequest, NoteAuditTrail, + NoteCaptureResult, NotePriority, NoteType, PromoteNoteToThesisRequest, ProvenanceActor, + ResearchBundleExport, ResearchNote, ResearchWorkspace, RetryResearchJobsRequest, + ReviewGhostNoteRequest, SourceKind, ThesisStatus, UpdateResearchNoteRequest, + WorkspaceProjection, WorkspaceScope, }; use crate::research::util::{generate_id, normalize_text, now_rfc3339, sha256_hex}; @@ -43,6 +43,17 @@ pub struct ResearchService { job_processor_lock: Arc>, } +struct AuditRecord<'a> { + entity_id: &'a str, + entity_kind: AuditEntityKind, + action: &'a str, + actor: AuditActor, + prior_revision: Option, + new_revision: Option, + job_id: Option, + source_ids: Vec, +} + impl Clone for ResearchService { fn clone(&self) -> Self { Self { @@ -101,14 +112,16 @@ impl ResearchService { let saved = self.repository.create_workspace(workspace.clone()).await?; self.record_audit( &saved.id, - &saved.id, - AuditEntityKind::Workspace, - "workspace_created", - AuditActor::Analyst, - None, - None, - None, - Vec::new(), + AuditRecord { + entity_id: &saved.id, + entity_kind: AuditEntityKind::Workspace, + action: "workspace_created", + actor: AuditActor::Analyst, + prior_revision: None, + new_revision: None, + job_id: None, + source_ids: Vec::new(), + }, ) .await?; self.emitter.workspace_updated(&saved); @@ -202,14 +215,16 @@ impl ResearchService { self.repository.create_note(source_note.clone()).await?; self.record_audit( &workspace.id, - &source_note.id, - AuditEntityKind::Note, - "source_reference_note_created", - AuditActor::System, - None, - Some(source_note.revision), - None, - source_note.source_id.iter().cloned().collect(), + AuditRecord { + entity_id: &source_note.id, + entity_kind: AuditEntityKind::Note, + action: "source_reference_note_created", + actor: AuditActor::System, + prior_revision: None, + new_revision: Some(source_note.revision), + job_id: None, + source_ids: source_note.source_id.iter().cloned().collect(), + }, ) .await?; self.emitter.note_updated(&source_note); @@ -279,27 +294,31 @@ impl ResearchService { .await?; self.record_audit( &workspace.id, - &saved.id, - AuditEntityKind::Note, - "note_captured", - AuditActor::Analyst, - None, - Some(saved.revision), - None, - source_id.iter().cloned().collect(), + AuditRecord { + entity_id: &saved.id, + entity_kind: AuditEntityKind::Note, + action: "note_captured", + actor: AuditActor::Analyst, + prior_revision: None, + new_revision: Some(saved.revision), + job_id: None, + source_ids: source_id.iter().cloned().collect(), + }, ) .await?; for job in &queued_jobs { self.record_audit( &workspace.id, - &job.id, - AuditEntityKind::Job, - "job_enqueued", - AuditActor::System, - None, - None, - Some(job.id.clone()), - Vec::new(), + AuditRecord { + entity_id: &job.id, + entity_kind: AuditEntityKind::Job, + action: "job_enqueued", + actor: AuditActor::System, + prior_revision: None, + new_revision: None, + job_id: Some(job.id.clone()), + source_ids: Vec::new(), + }, ) .await?; self.emitter.job_updated(job); @@ -377,14 +396,16 @@ impl ResearchService { .await?; self.record_audit( &saved.workspace_id, - &saved.id, - AuditEntityKind::Note, - "note_updated", - AuditActor::Analyst, - Some(prior_revision), - Some(saved.revision), - None, - saved.source_id.iter().cloned().collect(), + AuditRecord { + entity_id: &saved.id, + entity_kind: AuditEntityKind::Note, + action: "note_updated", + actor: AuditActor::Analyst, + prior_revision: Some(prior_revision), + new_revision: Some(saved.revision), + job_id: None, + source_ids: saved.source_id.iter().cloned().collect(), + }, ) .await?; self.emitter.note_updated(&saved); @@ -402,18 +423,20 @@ impl ResearchService { .await?; self.record_audit( ¬e.workspace_id, - ¬e.id, - AuditEntityKind::Note, - if request.archived { - "note_archived" - } else { - "note_unarchived" + AuditRecord { + entity_id: ¬e.id, + entity_kind: AuditEntityKind::Note, + action: if request.archived { + "note_archived" + } else { + "note_unarchived" + }, + actor: AuditActor::Analyst, + prior_revision: Some(note.revision), + new_revision: Some(note.revision), + job_id: None, + source_ids: note.source_id.iter().cloned().collect(), }, - AuditActor::Analyst, - Some(note.revision), - Some(note.revision), - None, - note.source_id.iter().cloned().collect(), ) .await?; self.emitter.note_updated(¬e); @@ -442,8 +465,7 @@ impl ResearchService { .into_iter() .filter(|ws| { ws.primary_ticker.to_uppercase() == normalized_ticker - || ws.primary_ticker.to_uppercase() - == format!("{}-US", normalized_ticker) + || ws.primary_ticker.to_uppercase() == format!("{}-US", normalized_ticker) }) .collect(); @@ -547,14 +569,16 @@ impl ResearchService { let saved = self.repository.save_ghost(ghost).await?; self.record_audit( &saved.workspace_id, - &saved.id, - AuditEntityKind::Ghost, - "ghost_reviewed", - AuditActor::Analyst, - None, - None, - None, - saved.source_ids.clone(), + AuditRecord { + entity_id: &saved.id, + entity_kind: AuditEntityKind::Ghost, + action: "ghost_reviewed", + actor: AuditActor::Analyst, + prior_revision: None, + new_revision: None, + job_id: None, + source_ids: saved.source_ids.clone(), + }, ) .await?; self.emitter.ghost_updated(&saved); @@ -575,14 +599,16 @@ impl ResearchService { let saved = self.repository.save_note(note).await?; self.record_audit( &saved.workspace_id, - &saved.id, - AuditEntityKind::Note, - "note_promoted_to_thesis", - AuditActor::Analyst, - Some(prior_revision), - Some(saved.revision), - None, - saved.source_id.iter().cloned().collect(), + AuditRecord { + entity_id: &saved.id, + entity_kind: AuditEntityKind::Note, + action: "note_promoted_to_thesis", + actor: AuditActor::Analyst, + prior_revision: Some(prior_revision), + new_revision: Some(saved.revision), + job_id: None, + source_ids: saved.source_id.iter().cloned().collect(), + }, ) .await?; self.emitter.note_updated(&saved); @@ -767,14 +793,16 @@ impl ResearchService { let saved = self.repository.save_note(note).await?; self.record_audit( &saved.workspace_id, - &saved.id, - AuditEntityKind::Note, - "note_enriched", - AuditActor::Ai, - Some(expected_revision), - Some(saved.revision), - Some(job.id.clone()), - saved.source_id.iter().cloned().collect(), + AuditRecord { + entity_id: &saved.id, + entity_kind: AuditEntityKind::Note, + action: "note_enriched", + actor: AuditActor::Ai, + prior_revision: Some(expected_revision), + new_revision: Some(saved.revision), + job_id: Some(job.id.clone()), + source_ids: saved.source_id.iter().cloned().collect(), + }, ) .await?; self.emitter.note_updated(&saved); @@ -821,17 +849,19 @@ impl ResearchService { for link in &saved_links { self.record_audit( &workspace_id, - &link.id, - AuditEntityKind::Link, - "link_inferred", - match link.created_by { - LinkOrigin::Ai => AuditActor::Ai, - _ => AuditActor::System, + AuditRecord { + entity_id: &link.id, + entity_kind: AuditEntityKind::Link, + action: "link_inferred", + actor: match link.created_by { + LinkOrigin::Ai => AuditActor::Ai, + _ => AuditActor::System, + }, + prior_revision: None, + new_revision: None, + job_id: Some(job.id.clone()), + source_ids: Vec::new(), }, - None, - None, - Some(job.id.clone()), - Vec::new(), ) .await?; } @@ -857,14 +887,16 @@ impl ResearchService { for ghost in &saved_ghosts { self.record_audit( &workspace_id, - &ghost.id, - AuditEntityKind::Ghost, - "ghost_generated", - AuditActor::Ai, - None, - None, - Some(job.id.clone()), - ghost.source_ids.clone(), + AuditRecord { + entity_id: &ghost.id, + entity_kind: AuditEntityKind::Ghost, + action: "ghost_generated", + actor: AuditActor::Ai, + prior_revision: None, + new_revision: None, + job_id: Some(job.id.clone()), + source_ids: ghost.source_ids.clone(), + }, ) .await?; self.emitter.ghost_updated(ghost); @@ -889,14 +921,16 @@ impl ResearchService { self.repository.save_source(refreshed.clone()).await?; self.record_audit( &refreshed.workspace_id, - &refreshed.id, - AuditEntityKind::Source, - "source_metadata_refreshed", - AuditActor::System, - None, - None, - Some(job.id.clone()), - vec![refreshed.id.clone()], + AuditRecord { + entity_id: &refreshed.id, + entity_kind: AuditEntityKind::Source, + action: "source_metadata_refreshed", + actor: AuditActor::System, + prior_revision: None, + new_revision: None, + job_id: Some(job.id.clone()), + source_ids: vec![refreshed.id.clone()], + }, ) .await?; Ok(()) @@ -917,8 +951,12 @@ impl ResearchService { 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(), + 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() @@ -932,30 +970,19 @@ impl ResearchService { }) } - async fn record_audit( - &self, - workspace_id: &str, - entity_id: &str, - entity_kind: AuditEntityKind, - action: &str, - actor: AuditActor, - prior_revision: Option, - new_revision: Option, - job_id: Option, - source_ids: Vec, - ) -> Result<()> { + async fn record_audit(&self, workspace_id: &str, record: AuditRecord<'_>) -> Result<()> { self.repository .append_audit_event(AuditEvent { id: generate_id("audit"), workspace_id: workspace_id.to_string(), - entity_id: entity_id.to_string(), - entity_kind, - action: action.to_string(), - actor, - prior_revision, - new_revision, - job_id, - source_ids, + entity_id: record.entity_id.to_string(), + entity_kind: record.entity_kind, + action: record.action.to_string(), + actor: record.actor, + prior_revision: record.prior_revision, + new_revision: record.new_revision, + job_id: record.job_id, + source_ids: record.source_ids, detail: None, created_at: now_rfc3339(), }) diff --git a/MosaicIQ/src/components/Settings/AgentSettingsForm.tsx b/MosaicIQ/src/components/Settings/AgentSettingsForm.tsx index c9d9fb3..1cd0b08 100644 --- a/MosaicIQ/src/components/Settings/AgentSettingsForm.tsx +++ b/MosaicIQ/src/components/Settings/AgentSettingsForm.tsx @@ -61,6 +61,8 @@ const PROVIDER_MODEL_OPTIONS: Record>, ): Record => @@ -336,6 +338,22 @@ export const AgentSettingsForm: React.FC = ({ [], ); + const applyProviderToAllTasks = useCallback((provider: AgentProviderKind) => { + setFormState((current) => ({ + ...current, + taskDefaults: TASK_PROFILES.reduce( + (nextRoutes, task) => { + nextRoutes[task] = { + provider, + model: '', + }; + return nextRoutes; + }, + {} as Record, + ), + })); + }, []); + if (!status) { return (
@@ -553,6 +571,31 @@ export const AgentSettingsForm: React.FC = ({

+
+
+
+

How to add a local model

+

+ 1. Run `ollama pull llama3.2` or another Ollama tag in your terminal. +

+

+ 2. Enter the exact model name below, like `llama3.2`, `qwen3-coder`, or `deepseek-r1:14b`. +

+

+ 3. Save settings, then switch task routing to Local if you want Ollama used everywhere. +

+
+ +
+
+
@@ -619,6 +684,29 @@ export const AgentSettingsForm: React.FC = ({

+
+ Quick apply: + + + + Clears per-task model overrides and makes every flow inherit the chosen provider default. + +
+
{TASK_PROFILES.map((task) => { const route = formState.taskDefaults[task]; diff --git a/MosaicIQ/src/components/Settings/ModelSelector.tsx b/MosaicIQ/src/components/Settings/ModelSelector.tsx index ed3bfd6..a0e9d1e 100644 --- a/MosaicIQ/src/components/Settings/ModelSelector.tsx +++ b/MosaicIQ/src/components/Settings/ModelSelector.tsx @@ -1,5 +1,5 @@ -import React, { KeyboardEvent, useRef, useState } from 'react'; -import { ChevronUp, ChevronDown, Search } from 'lucide-react'; +import React, { KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react'; +import { Check, ChevronDown, ChevronUp, PencilLine, Search } from 'lucide-react'; export interface ModelOption { value: string; @@ -47,139 +47,172 @@ export const ModelSelector: React.FC = ({ disabled = false, }) => { const [isOpen, setIsOpen] = useState(false); - const [searchQuery, setSearchQuery] = useState(''); - const [isCustomMode, setIsCustomMode] = useState(false); const containerRef = useRef(null); const inputRef = useRef(null); - const filteredOptions = options.filter((option) => { - const query = searchQuery.toLowerCase(); - return ( - option.label.toLowerCase().includes(query) || - option.value.toLowerCase().includes(query) || - option.provider?.toLowerCase().includes(query) - ); - }); + const filteredOptions = useMemo(() => { + const query = value.trim().toLowerCase(); + if (!query) { + return options; + } + + return options.filter((option) => { + const provider = option.provider?.toLowerCase() ?? ''; + return ( + option.label.toLowerCase().includes(query) || + option.value.toLowerCase().includes(query) || + provider.includes(query) + ); + }); + }, [options, value]); const selectedOption = options.find((opt) => opt.value === value); - const displayOption = - selectedOption ?? - (value.trim() - ? { - value, - label: value, - provider: 'Custom', - } - : null); + const trimmedValue = value.trim(); + const hasExactMatch = options.some( + (option) => option.value.toLowerCase() === trimmedValue.toLowerCase(), + ); + + useEffect(() => { + if (!isOpen) { + return; + } + + const handlePointerDown = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handlePointerDown); + return () => document.removeEventListener('mousedown', handlePointerDown); + }, [isOpen]); const handleSelect = (optionValue: string) => { onChange(optionValue); setIsOpen(false); - setSearchQuery(''); - setIsCustomMode(false); }; - const handleCustomMode = () => { - setIsCustomMode(true); + const handleUseCustomValue = () => { + if (!trimmedValue) { + return; + } + + onChange(trimmedValue); setIsOpen(false); - setTimeout(() => inputRef.current?.focus(), 0); + inputRef.current?.focus(); }; const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Enter' && isCustomMode) { + if (e.key === 'Enter') { e.preventDefault(); - if (value.trim()) { - setIsCustomMode(false); + if (filteredOptions.length === 1 && trimmedValue) { + handleSelect(filteredOptions[0].value); + return; + } + + if (allowCustom && trimmedValue) { + handleUseCustomValue(); + return; } } + + if (e.key === 'ArrowDown' && !isOpen) { + e.preventDefault(); + setIsOpen(true); + } + if (e.key === 'Escape') { setIsOpen(false); - setIsCustomMode(false); - } - }; - - const handleBlur = (e: React.FocusEvent) => { - if (!containerRef.current?.contains(e.relatedTarget)) { - setIsOpen(false); - if (!value.trim()) { - setIsCustomMode(false); - } } }; return ( -
- {!isCustomMode ? ( - - ) : ( +
+
+ onChange(e.target.value)} - onBlur={(e) => { - if (!e.target.value.trim()) { - setIsCustomMode(false); + onChange={(event) => { + onChange(event.target.value); + if (!disabled) { + setIsOpen(true); } }} + onFocus={() => !disabled && setIsOpen(true)} onKeyDown={handleKeyDown} - placeholder="Enter custom model name" - className="w-full border border-info bg-term-surface px-3 py-2 text-sm font-mono text-term-text outline-none focus:border-l-info border-l-2 transition-all" + placeholder={placeholder} + disabled={disabled} + className="w-full border border-term-border bg-term-surface py-2 pl-8 pr-11 text-sm font-mono text-term-text outline-none transition-all hover:border-term-border-focus focus:border-info disabled:cursor-not-allowed disabled:opacity-50 border-l-2 focus:border-l-info hover:border-l-term-border-focus" + aria-haspopup="listbox" + aria-expanded={isOpen} + aria-autocomplete="list" /> - )} + +
- {isOpen && !isCustomMode && ( +
+ + {selectedOption + ? `${selectedOption.label}${selectedOption.provider ? ` ยท ${selectedOption.provider}` : ''}` + : trimmedValue + ? 'Custom model name' + : 'Type an exact model name or pick a suggestion'} + + {allowCustom && trimmedValue && !hasExactMatch && ( + + )} +
+ + {isOpen && (
-
-
- - setSearchQuery(e.target.value)} - placeholder="Search models..." - className="w-full border-l-2 border-term-border-subtle bg-transparent py-1.5 pl-8 pr-2 text-sm font-mono text-term-text outline-none focus:border-l-info transition-colors" - autoFocus - /> -
-
    + {allowCustom && trimmedValue && !hasExactMatch && ( + <> +
  • event.preventDefault()} + onClick={handleUseCustomValue} + className="cursor-pointer px-3 py-2 text-sm font-mono text-info transition-colors hover:bg-term-highlight" + > +
    + Use "{trimmedValue}" as a custom model + +
    +
  • +
  • + + )} {filteredOptions.length === 0 ? (
  • - No models found + {trimmedValue ? 'No preset matches' : 'No models found'}
  • @@ -189,6 +222,7 @@ export const ModelSelector: React.FC = ({ key={option.value} role="option" aria-selected={value === option.value} + onMouseDown={(event) => event.preventDefault()} onClick={() => handleSelect(option.value)} className={`cursor-pointer px-3 py-2 text-sm font-mono transition-colors border-l-2 ${ value === option.value @@ -197,26 +231,20 @@ export const ModelSelector: React.FC = ({ }`} >
    - {option.label} - {option.provider && ( - {option.provider} - )} +
    +
    {option.label}
    +
    {option.value}
    +
    +
    + {option.provider && ( + {option.provider} + )} + {value === option.value && } +
    )) )} - {allowCustom && ( - <> -
  • -
  • - + Custom model... -
  • - - )}
)} diff --git a/MosaicIQ/src/components/Settings/SettingsPage.tsx b/MosaicIQ/src/components/Settings/SettingsPage.tsx index 162f35c..21adc7e 100644 --- a/MosaicIQ/src/components/Settings/SettingsPage.tsx +++ b/MosaicIQ/src/components/Settings/SettingsPage.tsx @@ -24,7 +24,6 @@ import { Clock, } from "lucide-react"; import { - AGENT_PROVIDER_LABELS, AgentConfigStatus, } from "../../types/agentSettings"; import { AgentSettingsForm } from "./AgentSettingsForm";