From 1948ac1284910d3b3f79ca28db5f51cde8132c74 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 17 Oct 2025 03:58:25 +0200 Subject: [PATCH] fix(providers/ollama): strengthen model cache + scope status UI --- crates/owlen-core/src/providers/ollama.rs | 91 ++++++++- crates/owlen-tui/src/chat_app.rs | 234 ++++++++++++++++++---- 2 files changed, 277 insertions(+), 48 deletions(-) diff --git a/crates/owlen-core/src/providers/ollama.rs b/crates/owlen-core/src/providers/ollama.rs index 9756ac4..4733992 100644 --- a/crates/owlen-core/src/providers/ollama.rs +++ b/crates/owlen-core/src/providers/ollama.rs @@ -88,6 +88,7 @@ struct ScopeSnapshot { availability: ScopeAvailability, last_error: Option, last_checked: Option, + last_success_at: Option, } impl Default for ScopeSnapshot { @@ -98,10 +99,29 @@ impl Default for ScopeSnapshot { availability: ScopeAvailability::Unknown, last_error: None, last_checked: None, + last_success_at: None, } } } +impl ScopeSnapshot { + fn is_stale(&self, ttl: Duration) -> bool { + match self.fetched_at { + Some(ts) => ts.elapsed() >= ttl, + None => !self.models.is_empty(), + } + } + + fn last_checked_age_secs(&self) -> Option { + self.last_checked.map(|instant| instant.elapsed().as_secs()) + } + + fn last_success_age_secs(&self) -> Option { + self.last_success_at + .map(|instant| instant.elapsed().as_secs()) + } +} + #[derive(Debug)] struct OllamaOptions { mode: OllamaMode, @@ -410,22 +430,29 @@ impl OllamaProvider { return None; } - entry.fetched_at.and_then(|ts| { + if entry.models.is_empty() { + return None; + } + + if let Some(ts) = entry.fetched_at { if ts.elapsed() < self.model_cache_ttl { - Some(entry.models.clone()) - } else { - None + return Some(entry.models.clone()); } - }) + } + + // Fallback to last good models even if stale; UI will mark as degraded + Some(entry.models.clone()) }) } async fn update_scope_success(&self, scope: OllamaMode, models: &[ModelInfo]) { let mut cache = self.scope_cache.write().await; let entry = cache.entry(scope).or_default(); + let now = Instant::now(); entry.models = models.to_vec(); - entry.fetched_at = Some(Instant::now()); - entry.last_checked = Some(Instant::now()); + entry.fetched_at = Some(now); + entry.last_checked = Some(now); + entry.last_success_at = Some(now); entry.availability = ScopeAvailability::Available; entry.last_error = None; } @@ -461,6 +488,45 @@ impl OllamaProvider { } } + let stale = snapshot.is_stale(self.model_cache_ttl); + let stale_capability = format!( + "scope-status-stale:{}:{}", + scope_key, + if stale { "1" } else { "0" } + ); + for model in models.iter_mut() { + if !model + .capabilities + .iter() + .any(|cap| cap == &stale_capability) + { + model.capabilities.push(stale_capability.clone()); + } + } + + if let Some(age) = snapshot.last_checked_age_secs() { + let age_capability = format!("scope-status-age:{}:{}", scope_key, age); + for model in models.iter_mut() { + if !model.capabilities.iter().any(|cap| cap == &age_capability) { + model.capabilities.push(age_capability.clone()); + } + } + } + + if let Some(success_age) = snapshot.last_success_age_secs() { + let success_capability = + format!("scope-status-success-age:{}:{}", scope_key, success_age); + for model in models.iter_mut() { + if !model + .capabilities + .iter() + .any(|cap| cap == &success_capability) + { + model.capabilities.push(success_capability.clone()); + } + } + } + if let Some(raw_reason) = snapshot.last_error.as_ref() { let cleaned = raw_reason.replace('\n', " ").trim().to_string(); if !cleaned.is_empty() { @@ -1658,6 +1724,7 @@ fn annotate_scope_status_adds_capabilities_for_unavailable_scopes() { let entry = cache.entry(OllamaMode::Cloud).or_default(); entry.availability = ScopeAvailability::Unavailable; entry.last_error = Some("Cloud endpoint unreachable".to_string()); + entry.last_checked = Some(Instant::now()); } provider.annotate_scope_status(&mut models).await; @@ -1674,4 +1741,14 @@ fn annotate_scope_status_adds_capabilities_for_unavailable_scopes() { .iter() .any(|cap| cap.starts_with("scope-status-message:cloud:")) ); + assert!( + capabilities + .iter() + .any(|cap| cap.starts_with("scope-status-age:cloud:")) + ); + assert!( + capabilities + .iter() + .any(|cap| cap == "scope-status-stale:cloud:0") + ); } diff --git a/crates/owlen-tui/src/chat_app.rs b/crates/owlen-tui/src/chat_app.rs index 2b761bf..ceb09ac 100644 --- a/crates/owlen-tui/src/chat_app.rs +++ b/crates/owlen-tui/src/chat_app.rs @@ -242,6 +242,9 @@ impl Default for ModelAvailabilityState { pub(crate) struct ScopeStatusEntry { pub state: ModelAvailabilityState, pub message: Option, + pub last_checked_secs: Option, + pub last_success_secs: Option, + pub is_stale: bool, } pub(crate) type ProviderScopeStatus = BTreeMap; @@ -7785,6 +7788,31 @@ impl ChatApp { if entry.message.is_none() && !message.trim().is_empty() { entry.message = Some(message.trim().to_string()); } + } else if let Some(rest) = capability.strip_prefix("scope-status-age:") { + let mut parts = rest.split(':'); + let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase(); + let value = parts.next().unwrap_or_default(); + if let Ok(age) = value.parse::() { + let scope = Self::scope_from_keyword(&scope_key); + let entry = statuses.entry(scope).or_default(); + entry.last_checked_secs = Some(age); + } + } else if let Some(rest) = capability.strip_prefix("scope-status-success-age:") { + let mut parts = rest.split(':'); + let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase(); + let value = parts.next().unwrap_or_default(); + if let Ok(age) = value.parse::() { + let scope = Self::scope_from_keyword(&scope_key); + let entry = statuses.entry(scope).or_default(); + entry.last_success_secs = Some(age); + } + } else if let Some(rest) = capability.strip_prefix("scope-status-stale:") { + let mut parts = rest.split(':'); + let scope_key = parts.next().unwrap_or_default().to_ascii_lowercase(); + let value = parts.next().unwrap_or_default(); + let scope = Self::scope_from_keyword(&scope_key); + let entry = statuses.entry(scope).or_default(); + entry.is_stale = matches!(value, "1" | "true" | "True" | "TRUE"); } } } @@ -7800,11 +7828,16 @@ impl ChatApp { for (scope, entry) in statuses { if entry.state == ModelAvailabilityState::Unavailable { let scope_name = Self::scope_display_name(scope); - if let Some(reason) = entry.message.as_ref() { - errors.push(format!("{provider}: {scope_name} unavailable ({reason})")); + if let Some(summary) = Self::scope_status_summary(entry) { + errors.push(format!("{provider}: {scope_name} {summary}")); } else { errors.push(format!("{provider}: {scope_name} unavailable")); } + } else if entry.state == ModelAvailabilityState::Available && entry.is_stale { + let scope_name = Self::scope_display_name(scope); + let summary = Self::scope_status_summary(entry) + .unwrap_or_else(|| "using cached results".to_string()); + errors.push(format!("{provider}: {scope_name} degraded ({summary})")); } } } @@ -7839,26 +7872,94 @@ impl ChatApp { } } + fn format_duration_short(seconds: u64) -> String { + const MINUTE: u64 = 60; + const HOUR: u64 = 60 * MINUTE; + const DAY: u64 = 24 * HOUR; + + if seconds < MINUTE { + format!("{seconds}s") + } else if seconds < HOUR { + format!("{}m", seconds / MINUTE) + } else if seconds < DAY { + let hours = seconds / HOUR; + let minutes = (seconds % HOUR) / MINUTE; + if minutes == 0 { + format!("{hours}h") + } else { + format!("{hours}h{minutes}m") + } + } else { + format!("{}d", seconds / DAY) + } + } + + fn scope_status_summary(status: &ScopeStatusEntry) -> Option { + let mut segments: Vec = Vec::new(); + + if let Some(message) = status.message.as_ref() { + if !message.is_empty() { + segments.push(message.clone()); + } + } else if status.state == ModelAvailabilityState::Unavailable { + segments.push("Unavailable".to_string()); + } else if status.state == ModelAvailabilityState::Available && status.is_stale { + segments.push("Using cached results".to_string()); + } + + if let Some(age) = status.last_checked_secs { + segments.push(format!("checked {} ago", Self::format_duration_short(age))); + } + + if let Some(success_age) = status.last_success_secs { + if status.state == ModelAvailabilityState::Unavailable { + segments.push(format!( + "last ok {} ago", + Self::format_duration_short(success_age) + )); + } else if status.state == ModelAvailabilityState::Available && status.is_stale { + segments.push(format!( + "last refresh {} ago", + Self::format_duration_short(success_age) + )); + } + } + + if segments.is_empty() { + None + } else { + Some(segments.join(" · ")) + } + } + fn scope_header_label( - _provider: &str, scope: &ModelScope, - status: Option, + status: &ScopeStatusEntry, filter: FilterMode, ) -> String { let icon = Self::scope_icon(scope); let scope_name = Self::scope_display_name(scope); let mut label = format!("{icon} {scope_name}"); - if let Some(state) = status { - match state { - ModelAvailabilityState::Available => { - if matches!(filter, FilterMode::Available) { - label.push_str(" · ✓"); - } + match status.state { + ModelAvailabilityState::Available => { + label.push_str(" · ✓"); + if status.is_stale { + label.push_str(" · ⚠"); } - ModelAvailabilityState::Unavailable => label.push_str(" · ✗"), - ModelAvailabilityState::Unknown => label.push_str(" · ⚙"), } + ModelAvailabilityState::Unavailable => { + if status.last_success_secs.is_some() { + label.push_str(" · ⚠"); + } else { + label.push_str(" · ✗"); + } + } + ModelAvailabilityState::Unknown => label.push_str(" · ⚙"), + } + + if let Some(age) = status.last_checked_secs { + label.push_str(&format!(" · {}", Self::format_duration_short(age))); } if matches!(filter, FilterMode::Available) { @@ -8088,12 +8189,8 @@ impl ChatApp { .and_then(|map| map.get(&scope)) .cloned() .unwrap_or_default(); - let label = Self::scope_header_label( - provider, - &scope, - Some(status_entry.state), - self.model_filter_mode, - ); + let label = + Self::scope_header_label(&scope, &status_entry, self.model_filter_mode); items.push(ModelSelectorItem::scope( provider.clone(), @@ -8102,11 +8199,25 @@ impl ChatApp { status_entry.state, )); - let scope_allowed = self.filter_scope_allows_models(&scope, status_entry.state); + if status_entry.state != ModelAvailabilityState::Available + || status_entry.is_stale + || status_entry.message.is_some() + { + if let Some(summary) = Self::scope_status_summary(&status_entry) { + rendered_body = true; + items.push(ModelSelectorItem::empty( + provider.clone(), + Some(summary), + Some(status_entry.state), + )); + } + } + + let scope_allowed = self.filter_scope_allows_models(&scope, &status_entry); if deduped.is_empty() { if !scope_allowed { - let message = self.scope_filter_message(&scope, status_entry.state); + let message = self.scope_filter_message(&scope, &status_entry); if let Some(msg) = message { rendered_body = true; items.push(ModelSelectorItem::empty( @@ -8141,7 +8252,7 @@ impl ChatApp { } if !scope_allowed { - let message = self.scope_filter_message(&scope, status_entry.state); + let message = self.scope_filter_message(&scope, &status_entry); if let Some(msg) = message { rendered_body = true; items.push(ModelSelectorItem::empty( @@ -8184,7 +8295,11 @@ impl ChatApp { match entry.state { ModelAvailabilityState::Unavailable => return ProviderStatus::Unavailable, ModelAvailabilityState::Unknown => saw_unknown = true, - ModelAvailabilityState::Available => {} + ModelAvailabilityState::Available => { + if entry.is_stale { + saw_unknown = true; + } + } } } if saw_unknown { @@ -8224,13 +8339,11 @@ impl ChatApp { } } - fn filter_scope_allows_models( - &self, - scope: &ModelScope, - status: ModelAvailabilityState, - ) -> bool { + fn filter_scope_allows_models(&self, scope: &ModelScope, status: &ScopeStatusEntry) -> bool { match self.model_filter_mode { - FilterMode::Available => status == ModelAvailabilityState::Available, + FilterMode::Available => { + status.state == ModelAvailabilityState::Available && !status.is_stale + } FilterMode::LocalOnly => matches!(scope, ModelScope::Local), FilterMode::CloudOnly => matches!(scope, ModelScope::Cloud), FilterMode::All => true, @@ -8240,22 +8353,32 @@ impl ChatApp { fn scope_filter_message( &self, scope: &ModelScope, - status: ModelAvailabilityState, + status: &ScopeStatusEntry, ) -> Option { match self.model_filter_mode { - FilterMode::Available => match status { - ModelAvailabilityState::Available => None, - ModelAvailabilityState::Unavailable => { - Some(format!("{} unavailable", Self::scope_display_name(scope))) + FilterMode::Available => { + if status.state == ModelAvailabilityState::Available && !status.is_stale { + return None; } - ModelAvailabilityState::Unknown => Some(format!( - "{} setup required", - Self::scope_display_name(scope) - )), - }, + Self::scope_status_summary(status).or_else(|| match status.state { + ModelAvailabilityState::Unavailable => { + Some(format!("{} unavailable", Self::scope_display_name(scope))) + } + ModelAvailabilityState::Unknown => Some(format!( + "{} setup required", + Self::scope_display_name(scope) + )), + ModelAvailabilityState::Available => Some(format!( + "{} cached results", + Self::scope_display_name(scope) + )), + }) + } FilterMode::LocalOnly | FilterMode::CloudOnly => { - if status == ModelAvailabilityState::Unavailable { - Some(format!("{} unavailable", Self::scope_display_name(scope))) + if status.state == ModelAvailabilityState::Unavailable { + Self::scope_status_summary(status).or_else(|| { + Some(format!("{} unavailable", Self::scope_display_name(scope))) + }) } else { None } @@ -11245,7 +11368,7 @@ fn normalize_cloud_endpoint(endpoint: &str) -> String { #[cfg(test)] mod tests { - use super::{ChatApp, render_markdown_lines, wrap_unicode}; + use super::{ChatApp, ModelAvailabilityState, ModelScope, render_markdown_lines, wrap_unicode}; use crate::app::UiRuntime; use futures_util::{future, stream}; use owlen_core::{ @@ -11337,6 +11460,35 @@ mod tests { assert!(wrapped.is_empty()); } + #[test] + fn extract_scope_status_includes_extended_metadata() { + let models = vec![ModelInfo { + id: "demo".to_string(), + name: "Demo".to_string(), + description: None, + provider: "demo".to_string(), + context_window: None, + capabilities: vec![ + "scope:local".to_string(), + "scope-status:local:available".to_string(), + "scope-status-age:local:30".to_string(), + "scope-status-success-age:local:5".to_string(), + "scope-status-stale:local:1".to_string(), + "scope-status-message:local:Cached copy".to_string(), + ], + supports_tools: false, + }]; + + let statuses = ChatApp::extract_scope_status(&models); + let entry = statuses.get(&ModelScope::Local).expect("local scope entry"); + + assert_eq!(entry.state, ModelAvailabilityState::Available); + assert_eq!(entry.last_checked_secs, Some(30)); + assert_eq!(entry.last_success_secs, Some(5)); + assert!(entry.is_stale); + assert_eq!(entry.message.as_deref(), Some("Cached copy")); + } + struct StubProvider; impl LlmProvider for StubProvider {