feat(mesh): assistant config RPCs + live toggle + Ollama detect (#50)
Phase 2 backend. AssistantConfig is now live-updatable (RwLock) so the UI
toggle applies without a listener restart. New RPCs:
- mesh.assistant-status -> {enabled, model, trusted_only, default_model,
ollama_detected, models[]} (probes local Ollama :11434/api/tags)
- mesh.assistant-configure -> set enabled/model/trusted_only live + persist
MeshService::assistant_config / configure_assistant. Compiles clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ef601c6d26
commit
0947ecee11
@ -381,6 +381,8 @@ impl RpcHandler {
|
|||||||
"mesh.deadman-status" => self.handle_mesh_deadman_status().await,
|
"mesh.deadman-status" => self.handle_mesh_deadman_status().await,
|
||||||
"mesh.deadman-configure" => self.handle_mesh_deadman_configure(params).await,
|
"mesh.deadman-configure" => self.handle_mesh_deadman_configure(params).await,
|
||||||
"mesh.deadman-checkin" => self.handle_mesh_deadman_checkin().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,
|
"mesh.test-send" => self.handle_mesh_test_send(params).await,
|
||||||
|
|
||||||
// Transport layer (unified routing)
|
// Transport layer (unified routing)
|
||||||
|
|||||||
95
core/archipelago/src/api/rpc/mesh/assistant.rs
Normal file
95
core/archipelago/src/api/rpc/mesh/assistant.rs
Normal file
@ -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<serde_json::Value> {
|
||||||
|
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<serde_json::Value>,
|
||||||
|
) -> Result<serde_json::Value> {
|
||||||
|
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<String>) {
|
||||||
|
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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
mod assistant;
|
||||||
mod bitcoin_ops;
|
mod bitcoin_ops;
|
||||||
mod messaging;
|
mod messaging;
|
||||||
mod safety;
|
mod safety;
|
||||||
|
|||||||
@ -82,8 +82,9 @@ pub(super) async fn run_assist(
|
|||||||
prompt: prompt.clone(),
|
prompt: prompt.clone(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let configured_model = state.assistant.read().await.model.clone();
|
||||||
let model = model_override
|
let model = model_override
|
||||||
.or_else(|| state.assistant.model.clone())
|
.or(configured_model)
|
||||||
.unwrap_or_else(|| DEFAULT_MODEL.to_string());
|
.unwrap_or_else(|| DEFAULT_MODEL.to_string());
|
||||||
|
|
||||||
info!(from = asker, req_id, model = %model, "Answering AI query over mesh");
|
info!(from = asker, req_id, model = %model, "Answering AI query over mesh");
|
||||||
@ -139,7 +140,7 @@ async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !state.assistant.trusted_only {
|
if !state.assistant.read().await.trusted_only {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
// 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
|
// Reply goes back as plain channel text so bare (non-archipelago) clients
|
||||||
// see it. The trust/rate gate lives in run_assist.
|
// 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 let Some(prompt) = strip_ai_trigger(text) {
|
||||||
if !prompt.is_empty() {
|
if !prompt.is_empty() {
|
||||||
let req_id = state.next_id().await;
|
let req_id = state.next_id().await;
|
||||||
|
|||||||
@ -684,7 +684,7 @@ pub(crate) async fn handle_typed_envelope_direct(
|
|||||||
Some(MeshMessageType::AssistQuery) => {
|
Some(MeshMessageType::AssistQuery) => {
|
||||||
match message_types::decode_payload::<message_types::AssistQueryPayload>(&envelope.v) {
|
match message_types::decode_payload::<message_types::AssistQueryPayload>(&envelope.v) {
|
||||||
Ok(query) => {
|
Ok(query) => {
|
||||||
if !state.assistant.enabled {
|
if !state.assistant.read().await.enabled {
|
||||||
debug!(
|
debug!(
|
||||||
from = sender_contact_id,
|
from = sender_contact_id,
|
||||||
"AssistQuery ignored — assistant disabled on this node"
|
"AssistQuery ignored — assistant disabled on this node"
|
||||||
|
|||||||
@ -123,8 +123,9 @@ pub struct MeshState {
|
|||||||
/// wiped. Persisted to `mesh-ignored-radio-contacts.json`.
|
/// wiped. Persisted to `mesh-ignored-radio-contacts.json`.
|
||||||
pub radio_contact_blocklist: RwLock<HashSet<String>>,
|
pub radio_contact_blocklist: RwLock<HashSet<String>>,
|
||||||
/// Mesh-AI assistant settings (issue #50): whether this node answers
|
/// Mesh-AI assistant settings (issue #50): whether this node answers
|
||||||
/// AssistQuery messages with its local LLM, and who may ask.
|
/// AssistQuery messages with its local LLM, and who may ask. Live-updatable
|
||||||
pub assistant: AssistantConfig,
|
/// so the UI toggle applies without restarting the listener.
|
||||||
|
pub assistant: RwLock<AssistantConfig>,
|
||||||
/// Data dir — lets dispatch handlers reach disk-backed stores (e.g. the
|
/// Data dir — lets dispatch handlers reach disk-backed stores (e.g. the
|
||||||
/// federation trust list used to gate AI queries) without threading a path
|
/// federation trust list used to gate AI queries) without threading a path
|
||||||
/// through every call.
|
/// through every call.
|
||||||
@ -216,7 +217,7 @@ impl MeshState {
|
|||||||
our_ed_pubkey_hex,
|
our_ed_pubkey_hex,
|
||||||
blob_store: RwLock::new(None),
|
blob_store: RwLock::new(None),
|
||||||
radio_contact_blocklist: RwLock::new(HashSet::new()),
|
radio_contact_blocklist: RwLock::new(HashSet::new()),
|
||||||
assistant,
|
assistant: RwLock::new(assistant),
|
||||||
data_dir,
|
data_dir,
|
||||||
assist_inflight: RwLock::new(HashSet::new()),
|
assist_inflight: RwLock::new(HashSet::new()),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1346,6 +1346,46 @@ impl MeshService {
|
|||||||
Ok(())
|
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<bool>,
|
||||||
|
model: Option<Option<String>>,
|
||||||
|
trusted_only: Option<bool>,
|
||||||
|
) -> 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.
|
/// Update mesh configuration.
|
||||||
pub async fn configure(&mut self, config: MeshConfig) -> Result<()> {
|
pub async fn configure(&mut self, config: MeshConfig) -> Result<()> {
|
||||||
save_config(&self.data_dir, &config).await?;
|
save_config(&self.data_dir, &config).await?;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user