use std::collections::{BTreeMap, BTreeSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use argonauts_core::app::{AppEvent, ModelChoice, ViewData};
use argonauts_core::model_picker::ModelSource;
use argonauts_core::names::{ModelRef, RoleName};
use argonauts_core::runtimes::runtime;
use argonauts_core::settings::endpoints::endpoint_settings;
use time::OffsetDateTime;
use super::memory_facts::{self, Reading};
use super::{Handled, SettingsState, models};
use crate::controller::{Ctx, Internal};
use crate::host::memory::{self, MeasuredRam, Ollama};
const TICK: Duration = Duration::from_secs(3);
const OPENCODE: &str = "opencode";
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct ModelsShown {
role: Option<RoleName>,
refreshed: Option<OffsetDateTime>,
}
#[derive(Debug, Default)]
pub(crate) struct Asking {
now: BTreeSet<String>,
quiet: BTreeSet<String>,
before: BTreeMap<String, Vec<ModelRef>>,
watch: Option<Arc<AtomicBool>>,
reading: Option<Reading>,
reading_now: bool,
}
pub(crate) fn start(ctx: &Ctx, settings: &mut SettingsState) {
if settings.refreshing || !ctx.settings().registry.discover {
return;
}
settings.refreshing = true;
let registry = Arc::clone(&ctx.services.registry);
let internal = ctx.internal.clone();
tokio::spawn(async move {
registry.discover().await;
let _ = internal.send(Internal::ModelsRefreshed);
});
}
pub(crate) fn ask_each(ctx: &Ctx, settings: &mut SettingsState) {
if !ctx.settings().registry.discover {
return;
}
for cli in ctx.services.registry.sources() {
ask(ctx, settings, &cli);
}
read_memory(ctx, settings);
}
fn ollama_url(ctx: &Ctx) -> Option<String> {
let settings = ctx.settings();
let yours = settings
.endpoints
.get("ollama")
.and_then(|endpoint| endpoint.base_url.clone());
let own = || {
settings
.registry
.local_servers
.then(|| runtime("ollama").map(|ollama| ollama.address().to_owned()))
.flatten()
};
yours.or_else(own)
}
fn read_memory(ctx: &Ctx, settings: &mut SettingsState) {
let runs_ollama = ctx.services.registry.list().iter().any(|entry| {
entry.id.agent == OPENCODE
&& entry
.id
.model
.as_deref()
.is_some_and(|model| model.starts_with("ollama/"))
});
if settings.sources.reading_now || !runs_ollama || !ctx.settings().registry.discover {
return;
}
let Some(url) = ollama_url(ctx) else {
return;
};
settings.sources.reading_now = true;
let measured = MeasuredRam::in_home(ctx.env.paths.home());
let internal = ctx.internal.clone();
tokio::spawn(async move {
let ollama = match memory::ask_ollama(&url).await {
Some(mut ollama) => {
measured.note(&url, &mut ollama);
ollama
}
None => Ollama::default(),
};
let reading = memory::machine(ollama.in_use())
.await
.map(|machine| Box::new(Reading { machine, ollama }));
let _ = internal.send(Internal::MemoryRead(reading));
});
}
pub(crate) fn memory_read(
ctx: &Ctx,
settings: &mut SettingsState,
reading: Option<Box<Reading>>,
) -> Handled {
settings.sources.reading_now = false;
if let Some(reading) = reading {
settings.sources.reading = Some(*reading);
}
send_update(ctx, settings);
Ok(Vec::new())
}
fn choices(ctx: &Ctx, settings: &SettingsState, role: Option<RoleName>) -> Vec<ModelChoice> {
let mut choices = models::choices(ctx, role);
if let Some(reading) = &settings.sources.reading {
memory_facts::annotate(&mut choices, reading, &ctx.services.registry.list());
}
choices
}
fn ask(ctx: &Ctx, settings: &mut SettingsState, cli: &str) {
if !settings.sources.now.insert(cli.to_owned()) {
return;
}
settings.sources.quiet.remove(cli);
settings
.sources
.before
.insert(cli.to_owned(), listed_models(ctx, cli));
let registry = Arc::clone(&ctx.services.registry);
let internal = ctx.internal.clone();
let cli = cli.to_owned();
tokio::spawn(async move {
registry.discover_source(&cli).await;
let _ = internal.send(Internal::ModelSourceListed { cli });
});
}
fn listed_models(ctx: &Ctx, cli: &str) -> Vec<ModelRef> {
ctx.services
.registry
.list()
.into_iter()
.filter(|entry| entry.id.agent == cli && entry.discovered)
.map(|entry| entry.id)
.collect()
}
pub(crate) fn source_listed(ctx: &Ctx, settings: &mut SettingsState, cli: &str) -> Handled {
settings.sources.now.remove(cli);
settings.sources.quiet.remove(cli);
let before = settings.sources.before.remove(cli).unwrap_or_default();
let changed = before != listed_models(ctx, cli);
let opencode_listed = ctx
.services
.registry
.sources()
.iter()
.any(|id| id == OPENCODE);
if changed && is_local(ctx, cli) && opencode_listed {
ask(ctx, settings, OPENCODE);
}
send_update(ctx, settings);
Ok(Vec::new())
}
fn is_local(ctx: &Ctx, cli: &str) -> bool {
endpoint_settings(ctx.settings(), cli).is_some_and(|endpoint| endpoint.local)
}
pub(crate) fn watch(ctx: &Ctx, settings: &mut SettingsState, watching: bool) {
if !watching {
if let Some(watch) = settings.sources.watch.take() {
watch.store(false, Ordering::Relaxed);
}
return;
}
if settings.sources.watch.is_some() {
return;
}
let watch = Arc::new(AtomicBool::new(true));
settings.sources.watch = Some(Arc::clone(&watch));
let internal = ctx.internal.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(TICK).await;
if !watch.load(Ordering::Relaxed) || internal.send(Internal::ModelsTick).is_err() {
break;
}
}
});
}
pub(crate) fn tick(ctx: &Ctx, settings: &mut SettingsState) -> Handled {
if settings.sources.watch.is_none() || !ctx.settings().registry.discover {
return Ok(Vec::new());
}
let local: Vec<String> = ctx
.services
.registry
.sources()
.into_iter()
.filter(|cli| is_local(ctx, cli))
.collect();
for cli in local {
if settings.sources.now.contains(&cli) {
continue;
}
ask(ctx, settings, &cli);
settings.sources.quiet.insert(cli);
}
read_memory(ctx, settings);
send_update(ctx, settings);
Ok(Vec::new())
}
fn asked_aloud(settings: &SettingsState) -> impl Iterator<Item = &String> {
settings
.sources
.now
.iter()
.filter(|cli| !settings.sources.quiet.contains(*cli))
}
fn sources(ctx: &Ctx, settings: &SettingsState) -> Vec<ModelSource> {
ctx.services
.registry
.sources()
.into_iter()
.map(|cli| {
let (listed, failed) = ctx.services.registry.source_listed(&cli);
ModelSource {
asking: asked_aloud(settings).any(|asked| *asked == cli),
note: ctx.services.registry.source_note(&cli),
cli,
listed,
failed,
}
})
.collect()
}
pub(crate) fn view(ctx: &Ctx, settings: &mut SettingsState, role: Option<RoleName>) -> ViewData {
let role = role.filter(|role| *role != RoleName::Main);
let refreshed = ctx.services.registry.refreshed();
settings.shown = Some(ModelsShown { role, refreshed });
ViewData::Models {
choices: choices(ctx, settings, role),
role,
refreshed,
refreshing: settings.refreshing || asked_aloud(settings).next().is_some(),
update: false,
sources: sources(ctx, settings),
memory: settings
.sources
.reading
.as_ref()
.map(|reading| reading.machine),
}
}
fn send_update(ctx: &Ctx, settings: &mut SettingsState) {
let refreshing = settings.refreshing || asked_aloud(settings).next().is_some();
let sources = sources(ctx, settings);
let memory = settings
.sources
.reading
.as_ref()
.map(|reading| reading.machine);
let Some(role) = settings.shown.as_ref().map(|shown| shown.role) else {
return;
};
let choices = choices(ctx, settings, role);
let Some(shown) = settings.shown.as_mut() else {
return;
};
let refreshed = ctx.services.registry.refreshed();
shown.refreshed = refreshed;
ctx.send(AppEvent::View {
data: Box::new(ViewData::Models {
choices,
role,
refreshed,
refreshing,
update: true,
sources,
memory,
}),
});
}
pub(crate) fn merged(ctx: &Ctx, settings: &mut SettingsState) -> Handled {
settings.refreshing = false;
let refreshed = ctx.services.registry.refreshed();
let Some(shown) = settings.shown.as_ref() else {
return Ok(Vec::new());
};
if shown.refreshed == refreshed {
return Ok(Vec::new());
}
send_update(ctx, settings);
Ok(Vec::new())
}