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";