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:
archipelago
2026-06-17 18:29:36 -04:00
co-authored by Claude Opus 4.8
parent ef601c6d26
commit 0947ecee11
8 changed files with 147 additions and 7 deletions
+3 -2
View File
@@ -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<MeshState>, sender_contact_id: u32) -> bo
}
}
if !state.assistant.trusted_only {
if !state.assistant.read().await.trusted_only {
return true;
}
+1 -1
View File
@@ -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;
@@ -684,7 +684,7 @@ pub(crate) async fn handle_typed_envelope_direct(
Some(MeshMessageType::AssistQuery) => {
match message_types::decode_payload::<message_types::AssistQueryPayload>(&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"
+4 -3
View File
@@ -123,8 +123,9 @@ pub struct MeshState {
/// wiped. Persisted to `mesh-ignored-radio-contacts.json`.
pub radio_contact_blocklist: RwLock<HashSet<String>>,
/// 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<AssistantConfig>,
/// 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()),
});
+40
View File
@@ -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<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.
pub async fn configure(&mut self, config: MeshConfig) -> Result<()> {
save_config(&self.data_dir, &config).await?;