diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index cdc65627..18eea939 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -381,6 +381,8 @@ impl RpcHandler { "mesh.deadman-status" => self.handle_mesh_deadman_status().await, "mesh.deadman-configure" => self.handle_mesh_deadman_configure(params).await, "mesh.deadman-checkin" => self.handle_mesh_deadman_checkin().await, + "mesh.assistant-status" => self.handle_mesh_assistant_status().await, + "mesh.assistant-configure" => self.handle_mesh_assistant_configure(params).await, "mesh.test-send" => self.handle_mesh_test_send(params).await, // Transport layer (unified routing) diff --git a/core/archipelago/src/api/rpc/mesh/assistant.rs b/core/archipelago/src/api/rpc/mesh/assistant.rs new file mode 100644 index 00000000..5b266434 --- /dev/null +++ b/core/archipelago/src/api/rpc/mesh/assistant.rs @@ -0,0 +1,95 @@ +//! Mesh-AI assistant RPCs (issue #50): read/update the local assistant config +//! and report whether a local Ollama is available (for the install deep-link). + +use super::super::RpcHandler; +use anyhow::Result; +use std::time::Duration; + +/// Default model when the node hasn't picked one (kept in sync with the mesh +/// assistant handler's `DEFAULT_MODEL`). +const DEFAULT_MODEL: &str = "qwen2.5-coder"; + +impl RpcHandler { + /// mesh.assistant-status — current settings + local Ollama availability. + pub(in crate::api::rpc) async fn handle_mesh_assistant_status( + &self, + ) -> Result { + let cfg = { + let service = self.mesh_service.read().await; + let svc = service + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?; + svc.assistant_config().await + }; + + let (ollama_detected, models) = detect_ollama().await; + Ok(serde_json::json!({ + "enabled": cfg.enabled, + "model": cfg.model, + "trusted_only": cfg.trusted_only, + "default_model": DEFAULT_MODEL, + "ollama_detected": ollama_detected, + "models": models, + })) + } + + /// mesh.assistant-configure — update assistant settings live. + /// Params: `enabled?: bool`, `trusted_only?: bool`, + /// `model?: string|null` (string sets, null clears to default, absent leaves). + pub(in crate::api::rpc) async fn handle_mesh_assistant_configure( + &self, + params: Option, + ) -> Result { + let params = params.unwrap_or_default(); + let service = self.mesh_service.read().await; + let svc = service + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?; + + let enabled = params.get("enabled").and_then(|v| v.as_bool()); + let trusted_only = params.get("trusted_only").and_then(|v| v.as_bool()); + // model: key present + string => set; present + null => clear; absent => leave + let model = if let Some(v) = params.get("model") { + Some(v.as_str().map(|s| s.to_string())) + } else { + None + }; + + svc.configure_assistant(enabled, model, trusted_only).await?; + let cfg = svc.assistant_config().await; + Ok(serde_json::json!({ + "enabled": cfg.enabled, + "model": cfg.model, + "trusted_only": cfg.trusted_only, + })) + } +} + +/// Probe the local Ollama HTTP API; return (detected, model_names). +async fn detect_ollama() -> (bool, Vec) { + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + { + Ok(c) => c, + Err(_) => return (false, Vec::new()), + }; + match client.get("http://localhost:11434/api/tags").send().await { + Ok(resp) if resp.status().is_success() => { + let json: serde_json::Value = resp.json().await.unwrap_or_default(); + let models = json + .get("models") + .and_then(|m| m.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|m| { + m.get("name").and_then(|n| n.as_str()).map(|s| s.to_string()) + }) + .collect() + }) + .unwrap_or_default(); + (true, models) + } + _ => (false, Vec::new()), + } +} diff --git a/core/archipelago/src/api/rpc/mesh/mod.rs b/core/archipelago/src/api/rpc/mesh/mod.rs index bd5b1405..fe497876 100644 --- a/core/archipelago/src/api/rpc/mesh/mod.rs +++ b/core/archipelago/src/api/rpc/mesh/mod.rs @@ -1,3 +1,4 @@ +mod assistant; mod bitcoin_ops; mod messaging; mod safety; diff --git a/core/archipelago/src/mesh/listener/assist.rs b/core/archipelago/src/mesh/listener/assist.rs index f1b25f64..e5f817b2 100644 --- a/core/archipelago/src/mesh/listener/assist.rs +++ b/core/archipelago/src/mesh/listener/assist.rs @@ -82,8 +82,9 @@ pub(super) async fn run_assist( prompt: prompt.clone(), }); + let configured_model = state.assistant.read().await.model.clone(); let model = model_override - .or_else(|| state.assistant.model.clone()) + .or(configured_model) .unwrap_or_else(|| DEFAULT_MODEL.to_string()); info!(from = asker, req_id, model = %model, "Answering AI query over mesh"); @@ -139,7 +140,7 @@ async fn is_sender_allowed(state: &Arc, sender_contact_id: u32) -> bo } } - if !state.assistant.trusted_only { + if !state.assistant.read().await.trusted_only { return true; } diff --git a/core/archipelago/src/mesh/listener/decode.rs b/core/archipelago/src/mesh/listener/decode.rs index 4bdf6d9f..cafa6ddf 100644 --- a/core/archipelago/src/mesh/listener/decode.rs +++ b/core/archipelago/src/mesh/listener/decode.rs @@ -357,7 +357,7 @@ pub(super) async fn store_plain_message( // channel is answered by this node's local model when the assistant is on. // Reply goes back as plain channel text so bare (non-archipelago) clients // see it. The trust/rate gate lives in run_assist. - if state.assistant.enabled { + if state.assistant.read().await.enabled { if let Some(prompt) = strip_ai_trigger(text) { if !prompt.is_empty() { let req_id = state.next_id().await; diff --git a/core/archipelago/src/mesh/listener/dispatch.rs b/core/archipelago/src/mesh/listener/dispatch.rs index 5a558ae0..21cf4be0 100644 --- a/core/archipelago/src/mesh/listener/dispatch.rs +++ b/core/archipelago/src/mesh/listener/dispatch.rs @@ -684,7 +684,7 @@ pub(crate) async fn handle_typed_envelope_direct( Some(MeshMessageType::AssistQuery) => { match message_types::decode_payload::(&envelope.v) { Ok(query) => { - if !state.assistant.enabled { + if !state.assistant.read().await.enabled { debug!( from = sender_contact_id, "AssistQuery ignored — assistant disabled on this node" diff --git a/core/archipelago/src/mesh/listener/mod.rs b/core/archipelago/src/mesh/listener/mod.rs index 384f8abb..3e02cbe2 100644 --- a/core/archipelago/src/mesh/listener/mod.rs +++ b/core/archipelago/src/mesh/listener/mod.rs @@ -123,8 +123,9 @@ pub struct MeshState { /// wiped. Persisted to `mesh-ignored-radio-contacts.json`. pub radio_contact_blocklist: RwLock>, /// Mesh-AI assistant settings (issue #50): whether this node answers - /// AssistQuery messages with its local LLM, and who may ask. - pub assistant: AssistantConfig, + /// AssistQuery messages with its local LLM, and who may ask. Live-updatable + /// so the UI toggle applies without restarting the listener. + pub assistant: RwLock, /// Data dir — lets dispatch handlers reach disk-backed stores (e.g. the /// federation trust list used to gate AI queries) without threading a path /// through every call. @@ -216,7 +217,7 @@ impl MeshState { our_ed_pubkey_hex, blob_store: RwLock::new(None), radio_contact_blocklist: RwLock::new(HashSet::new()), - assistant, + assistant: RwLock::new(assistant), data_dir, assist_inflight: RwLock::new(HashSet::new()), }); diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 5452da06..48efca4f 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -1346,6 +1346,46 @@ impl MeshService { Ok(()) } + /// Current mesh-AI assistant settings (issue #50). + pub async fn assistant_config(&self) -> listener::AssistantConfig { + self.state.assistant.read().await.clone() + } + + /// Update the mesh-AI assistant settings live (no listener restart) and + /// persist them to the mesh config. `model: Some(None)` clears the override + /// (falls back to the built-in default); `None` leaves a field unchanged. + pub async fn configure_assistant( + &self, + enabled: Option, + model: Option>, + trusted_only: Option, + ) -> Result<()> { + { + let mut a = self.state.assistant.write().await; + if let Some(e) = enabled { + a.enabled = e; + } + if let Some(m) = model { + a.model = m; + } + if let Some(t) = trusted_only { + a.trusted_only = t; + } + } + // Persist by updating the on-disk config (the in-memory `self.config` + // snapshot stays as-is; the live `state.assistant` is the runtime + // source of truth and is re-seeded from disk on the next start). + let mut cfg = load_config(&self.data_dir).await.unwrap_or_default(); + { + let a = self.state.assistant.read().await; + cfg.assistant_enabled = a.enabled; + cfg.assistant_model = a.model.clone(); + cfg.assistant_trusted_only = a.trusted_only; + } + save_config(&self.data_dir, &cfg).await?; + Ok(()) + } + /// Update mesh configuration. pub async fn configure(&mut self, config: MeshConfig) -> Result<()> { save_config(&self.data_dir, &config).await?;