fix(mesh,ui,fedimint): mesh-AI chat trigger + transport-aware reply, stop ARCHY:2 public-channel spam, AI allowlist + model dropdown, Fedimint client manifest, settings reorder, chat scroll

- mesh: stop broadcasting ARCHY:2 identity on the public channel (startup + every advert tick); receive path still parses inbound. No more public-channel spam.
- mesh assistant: trigger on !ai/!ask typed in 1:1 chat (was only the dead AssistQuery path + bare channel text); route the reply transport-aware via MeshService::send_message (Tor for federation peers, LoRa for radio) through a new AssistChatReply event consumed at the server layer — fixes replies never reaching federation askers.
- mesh assistant: per-contact !ai allowlist (allowed_contacts) bypassing trusted_only; config + RPC + is_sender_allowed.
- fedimint-clientd manifest: network_policy open -> bridge (invalid value made the loader skip the whole manifest, so fmcd never ran and federations never joined/listed).
- ui: AI panel — Claude model dropdown (Haiku/Sonnet/Opus presets) + allowlist contact picker.
- ui: Settings — App Updates + App Registry moved under Account.
- ui: mesh chat — overscroll-behavior: contain so chat scroll no longer bleeds to the contacts panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-18 03:33:37 -04:00
parent 2a017623e9
commit 3a21243be7
15 changed files with 242 additions and 32 deletions

View File

@ -36,9 +36,12 @@ app:
capabilities: []
readonly_root: true
# NOT isolated: fmcd needs outbound UDP + Mainline DHT (port 6881) + iroh
# relays to reach iroh-transport federations. Lock down once the default
# federation's reachability model is finalized.
network_policy: open
# relays to reach iroh-transport federations. `bridge` gives NAT'd outbound
# (UDP/DHT/iroh hole-punch all work) plus the published 8178→8080 port the
# wallet bridge targets. ("open" is not a valid policy — it made the loader
# skip this whole manifest, so fmcd never ran and federations never joined.)
# Lock down once the default federation's reachability model is finalized.
network_policy: bridge
ports:
# fmcd REST bound to 8080 in-container; 8080 collides with LND REST on the

View File

@ -32,6 +32,7 @@ impl RpcHandler {
"model": cfg.model,
"trusted_only": cfg.trusted_only,
"backend": cfg.backend,
"allowed_contacts": cfg.allowed_contacts,
"default_model": DEFAULT_MODEL,
"ollama_detected": ollama_detected,
"claude_available": claude_available,
@ -64,8 +65,18 @@ impl RpcHandler {
} else {
None
};
// allowed_contacts: present + array => replace the allowlist (pubkey hex
// strings); absent => leave unchanged.
let allowed_contacts = params
.get("allowed_contacts")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|e| e.as_str().map(|s| s.to_string()))
.collect::<Vec<String>>()
});
svc.configure_assistant(enabled, model, trusted_only, backend)
svc.configure_assistant(enabled, model, trusted_only, backend, allowed_contacts)
.await?;
let cfg = svc.assistant_config().await;
Ok(serde_json::json!({
@ -73,6 +84,7 @@ impl RpcHandler {
"model": cfg.model,
"trusted_only": cfg.trusted_only,
"backend": cfg.backend,
"allowed_contacts": cfg.allowed_contacts,
}))
}

View File

@ -8,6 +8,7 @@
//! asker is limited to one in-flight query.
use super::super::message_types::{self, AssistResponsePayload, MeshMessageType};
use super::super::types::MeshEvent;
use super::bitcoin::send_to_peer;
use super::{MeshCommand, MeshState};
use crate::federation::TrustLevel;
@ -42,6 +43,11 @@ pub(super) enum AssistReply {
/// Plain-text broadcast on a mesh channel — the bare `!ai` path, so any
/// client (including non-archipelago meshcore/Meshtastic nodes) sees it.
ChannelText { channel: u8 },
/// Normal `Text` chat bubble sent back into the 1:1 thread — the
/// archipelago `!ai`-in-chat path. The asker typed `!ai …` as a regular
/// direct message, so the answer lands inline in that same conversation
/// (encrypted, peer-addressed) rather than as a separate widget.
ChatText { contact_id: u32 },
}
/// Entry point: gate the query, run the model, send the answer back via the
@ -169,6 +175,15 @@ async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bo
}
}
// Explicit per-contact allowlist: a listed pubkey may ask regardless of
// the trusted_only policy (block check above still wins).
if let Some(ref pk) = pubkey_hex {
let allowed = state.assistant.read().await.allowed_contacts.clone();
if allowed.iter().any(|a| a.eq_ignore_ascii_case(pk)) {
return true;
}
}
if !state.assistant.read().await.trusted_only {
return true;
}
@ -205,6 +220,10 @@ async fn send_reply(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64, an
let text = cap_channel(answer);
send_channel_text(state, *channel, &text).await;
}
AssistReply::ChatText { contact_id } => {
let (text, _) = cap_reply(answer);
send_chat_text(state, *contact_id, &text).await;
}
}
}
@ -224,6 +243,9 @@ async fn send_failure(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64,
AssistReply::ChannelText { channel } => {
send_channel_text(state, *channel, &format!("AI: {msg}")).await;
}
AssistReply::ChatText { contact_id } => {
send_chat_text(state, *contact_id, &format!("AI: {msg}")).await;
}
}
}
@ -272,6 +294,23 @@ async fn send_typed_response(
}
}
/// Send the answer back into the 1:1 chat thread as a normal chat bubble.
/// Used for the `!ai`-in-chat path. We emit an `AssistChatReply` event rather
/// than sending here, because the reply must be routed transport-aware:
/// `!ai` can arrive over LoRa OR over federation (Tor), and only
/// `MeshService::send_message` (which owns the signing key + Tor client) knows
/// to POST over the peer's onion for a federation-synthetic contact_id. The
/// radio-only path used to drop the reply for federation askers — the answer
/// showed on the answering node but never reached the asker. A server-layer
/// consumer fulfils this event via `send_message`, which also records the
/// Sent bubble and allocates the seq.
async fn send_chat_text(state: &Arc<MeshState>, contact_id: u32, text: &str) {
let _ = state.event_tx.send(MeshEvent::AssistChatReply {
contact_id,
text: text.to_string(),
});
}
/// Broadcast a plain-text answer on a channel for bare `!ai` clients.
async fn send_channel_text(state: &Arc<MeshState>, channel: u8, text: &str) {
let _ = state

View File

@ -383,7 +383,7 @@ pub(super) async fn store_plain_message(
/// Recognise a `!ai`/`!ask ` command prefix (case-insensitive) and return the
/// trimmed question after it, or `None` if the text isn't an AI command.
fn strip_ai_trigger(text: &str) -> Option<&str> {
pub(super) fn strip_ai_trigger(text: &str) -> Option<&str> {
let t = text.trim_start();
for p in ["!ai ", "!ask "] {
if t.len() >= p.len() && t[..p.len()].eq_ignore_ascii_case(p) {

View File

@ -679,6 +679,36 @@ pub(crate) async fn handle_typed_envelope_direct(
Some(envelope.seq),
)
.await;
// Mesh-AI assistant (issue #50): a `!ai`/`!ask <question>` typed in
// the normal 1:1 chat triggers this node's assistant, with the
// answer sent back as a chat bubble in the same thread. The typed
// DM carries the peer's federation identity (via sender_contact_id),
// so the `trusted_only` gate in run_assist resolves correctly —
// unlike the bare channel-text path, which only knows the radio key.
if state.assistant.read().await.enabled {
if let Some(prompt) = super::decode::strip_ai_trigger(&text) {
if !prompt.is_empty() {
let req_id = state.next_id().await;
let prompt = prompt.to_string();
let name = sender_name.to_string();
let cid = sender_contact_id;
let st = Arc::clone(state);
tokio::spawn(async move {
super::assist::run_assist(
prompt,
None,
req_id,
cid,
name,
super::assist::AssistReply::ChatText { contact_id: cid },
st,
)
.await;
});
}
}
}
}
Some(MeshMessageType::AssistQuery) => {

View File

@ -148,6 +148,10 @@ pub struct AssistantConfig {
pub trusted_only: bool,
/// AI backend: "claude" (shared proxy token) or "ollama" (local model).
pub backend: String,
/// Per-contact allowlist (ed25519 pubkey hex) permitted to use `!ai`
/// regardless of `trusted_only`. Empty → only the `trusted_only` policy
/// applies. A user-blocked contact is always denied even if listed here.
pub allowed_contacts: Vec<String>,
}
/// Contact metadata kept alongside MeshState.peers. Pinned contacts sort to

View File

@ -424,21 +424,16 @@ pub(super) async fn run_mesh_session(
warn!("Failed to send initial advert: {}", e);
}
// Archipelago identity advert (`ARCHY:2:{ed}:{x25519}`): broadcast as channel
// text so peers can bind our radio presence to our DID + keys. The firmware
// advert alone carries the meshcore key (and nothing on Meshtastic), so this
// is what makes trust-gating + encrypted DMs work across BOTH transports.
let identity_advert = super::super::protocol::encode_identity_broadcast(
our_did,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
);
if let Err(e) = device
.send_channel_text(0, identity_advert.as_bytes())
.await
{
warn!("Failed to broadcast archipelago identity: {}", e);
}
// NOTE: Archipelago identity adverts (`ARCHY:2:{ed}:{x25519}`) are intentionally
// NOT broadcast on the shared public channel (channel 0). Doing so spams every
// participant on that channel — including plain Meshtastic/meshcore users who
// just see raw `ARCHY:2:…` text — on startup and again on every advert tick.
// The inbound parser in frames.rs still accepts these from any legacy peer that
// sends them, so trust-binding keeps working when a peer advertises; we simply
// don't pollute the public channel ourselves. A dedicated control channel (or a
// DM-targeted handshake) is the proper transport for this and is tracked
// separately. See encode_identity_broadcast / parse_identity_broadcast.
let _ = (our_did, our_ed_pubkey_hex, our_x25519_pubkey_hex);
// Fetch existing contacts from the device
refresh_contacts(&mut device, state).await;
@ -507,11 +502,9 @@ pub(super) async fn run_mesh_session(
} else {
consecutive_write_failures = 0;
}
// Re-broadcast archipelago identity so peers that joined since
// startup (or missed it) can bind our DID/keys.
if let Err(e) = device.send_channel_text(0, identity_advert.as_bytes()).await {
warn!("Failed to re-broadcast archipelago identity: {}", e);
}
// (Identity re-broadcast on the public channel intentionally
// removed — see the note at session startup. It spammed the
// shared channel every advert tick.)
refresh_contacts(&mut device, state).await;
}

View File

@ -197,6 +197,10 @@ pub struct MeshConfig {
/// local GPU) or "ollama" (a local model on this node).
#[serde(default = "default_assistant_backend")]
pub assistant_backend: String,
/// Per-contact allowlist (ed25519 pubkey hex) permitted to use `!ai` even
/// when `assistant_trusted_only` is on and they aren't federation-Trusted.
#[serde(default)]
pub assistant_allowed_contacts: Vec<String>,
}
fn default_assistant_backend() -> String {
@ -224,6 +228,7 @@ impl Default for MeshConfig {
assistant_model: None,
assistant_trusted_only: true,
assistant_backend: default_assistant_backend(),
assistant_allowed_contacts: Vec::new(),
}
}
}
@ -401,6 +406,7 @@ impl MeshService {
model: config.assistant_model.clone(),
trusted_only: config.assistant_trusted_only,
backend: config.assistant_backend.clone(),
allowed_contacts: config.assistant_allowed_contacts.clone(),
},
data_dir.to_path_buf(),
);
@ -1401,6 +1407,7 @@ impl MeshService {
model: Option<Option<String>>,
trusted_only: Option<bool>,
backend: Option<String>,
allowed_contacts: Option<Vec<String>>,
) -> Result<()> {
{
let mut a = self.state.assistant.write().await;
@ -1416,6 +1423,9 @@ impl MeshService {
if let Some(b) = backend {
a.backend = b;
}
if let Some(list) = allowed_contacts {
a.allowed_contacts = list;
}
}
// Persist by updating the on-disk config (the in-memory `self.config`
// snapshot stays as-is; the live `state.assistant` is the runtime
@ -1427,6 +1437,7 @@ impl MeshService {
cfg.assistant_model = a.model.clone();
cfg.assistant_trusted_only = a.trusted_only;
cfg.assistant_backend = a.backend.clone();
cfg.assistant_allowed_contacts = a.allowed_contacts.clone();
}
save_config(&self.data_dir, &cfg).await?;
Ok(())

View File

@ -172,4 +172,13 @@ pub enum MeshEvent {
to_contact_id: u32,
error: Option<String>,
},
/// A local-AI answer to a `!ai`-in-chat query, to be delivered back into
/// the 1:1 thread via the transport-aware `MeshService::send_message`
/// (Tor for federation peers, LoRa for radio peers). The mesh listener
/// emits this because it can't route over federation itself — the signing
/// key and Tor client live on MeshService. Consumed at the server layer.
AssistChatReply {
contact_id: u32,
text: String,
},
}

View File

@ -305,6 +305,47 @@ impl Server {
.rpc_handler()
.set_mesh_service(mesh_service)
.await;
// Mesh-AI assistant (#50): deliver `!ai`-in-chat answers via
// the transport-aware send path. The listener can't route
// over federation itself (send_message needs the signing key
// + Tor client on MeshService), so it emits AssistChatReply
// and we fulfil it here through the shared MeshService —
// which POSTs over Tor for federation askers and falls back
// to LoRa for radio askers, recording the Sent bubble.
{
let mesh_arc = api_handler.rpc_handler().mesh_service_arc();
let mut reply_rx = {
let guard = mesh_arc.read().await;
guard.as_ref().map(|svc| svc.state().event_tx.subscribe())
};
if let Some(mut rx) = reply_rx.take() {
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(crate::mesh::MeshEvent::AssistChatReply {
contact_id,
text,
}) => {
let guard = mesh_arc.read().await;
if let Some(svc) = guard.as_ref() {
if let Err(e) =
svc.send_message(contact_id, &text).await
{
warn!("AI chat reply send failed: {}", e);
}
}
}
Ok(_) => {}
Err(tokio::sync::broadcast::error::RecvError::Lagged(
_,
)) => continue,
Err(_) => break, // sender dropped → mesh stopped
}
}
});
}
}
info!("📡 Mesh service initialized");
}
Err(e) => {

View File

@ -130,6 +130,7 @@ export interface AssistantStatus {
model: string | null
trusted_only: boolean
backend: string
allowed_contacts: string[]
default_model: string
ollama_detected: boolean
claude_available: boolean
@ -613,6 +614,7 @@ export const useMeshStore = defineStore('mesh', () => {
model?: string | null
trusted_only?: boolean
backend?: string
allowed_contacts?: string[]
}) {
const res = await rpcClient.call<Partial<AssistantStatus>>({
method: 'mesh.assistant-configure',

View File

@ -1,11 +1,15 @@
<script setup lang="ts">
import AccountSection from '@/views/settings/AccountSection.vue'
import SystemUpdatesSection from '@/views/settings/SystemUpdatesSection.vue'
import AppRegistriesSection from '@/views/settings/AppRegistriesSection.vue'
import SystemSection from '@/views/settings/SystemSection.vue'
</script>
<template>
<div class="pb-6">
<AccountSection />
<SystemUpdatesSection />
<AppRegistriesSection />
<SystemSection />
</div>
</template>

View File

@ -11,6 +11,23 @@ const enabled = ref(false)
const model = ref('') // '' = use the backend's default model
const policy = ref<'trusted' | 'anyone'>('trusted')
const backend = ref<'claude' | 'ollama'>('claude')
const allowedContacts = ref<string[]>([])
// Preset Claude models offered in the dropdown ('' = backend default = Haiku).
const CLAUDE_MODELS: { value: string; label: string }[] = [
{ value: '', label: 'Default (Claude Haiku 4.5)' },
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5 — fast & cheap' },
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 — balanced' },
{ value: 'claude-opus-4-8', label: 'Claude Opus 4.8 — most capable' },
]
// Include any non-preset value the node already has so it isn't silently lost.
const claudeModelOptions = computed(() => {
const opts = [...CLAUDE_MODELS]
if (model.value && !opts.some((o) => o.value === model.value)) {
opts.push({ value: model.value, label: `${model.value} (custom)` })
}
return opts
})
// Sync local controls from the fetched status.
watch(
@ -21,10 +38,30 @@ watch(
model.value = s.model ?? ''
policy.value = s.trusted_only ? 'trusted' : 'anyone'
backend.value = s.backend === 'ollama' ? 'ollama' : 'claude'
allowedContacts.value = [...(s.allowed_contacts ?? [])]
},
{ immediate: true },
)
// Addressable contacts (have an archipelago/radio pubkey) for the allowlist.
const contactOptions = computed(() =>
mesh.peers
.filter((p) => !!p.pubkey_hex)
.map((p) => ({ pubkey: p.pubkey_hex as string, name: p.advert_name || (p.pubkey_hex as string).slice(0, 10) })),
)
function isAllowed(pubkey: string) {
return allowedContacts.value.some((k) => k.toLowerCase() === pubkey.toLowerCase())
}
function toggleAllowed(pubkey: string) {
if (isAllowed(pubkey)) {
allowedContacts.value = allowedContacts.value.filter((k) => k.toLowerCase() !== pubkey.toLowerCase())
} else {
allowedContacts.value = [...allowedContacts.value, pubkey]
}
apply({ allowed_contacts: allowedContacts.value })
}
onMounted(() => {
mesh.fetchAssistantStatus()
})
@ -45,6 +82,7 @@ async function apply(partial: {
model?: string | null
trusted_only?: boolean
backend?: string
allowed_contacts?: string[]
}) {
saving.value = true
try {
@ -131,7 +169,9 @@ function onPolicy() {
</div>
<div v-else class="mesh-assistant-field">
<label class="mesh-bitcoin-label">Model</label>
<input v-model="model" class="mesh-bitcoin-input mesh-bitcoin-input-sm" :placeholder="defaultModel" @change="onModel" />
<select v-model="model" class="mesh-bitcoin-input mesh-bitcoin-input-sm" @change="onModel">
<option v-for="m in claudeModelOptions" :key="m.value" :value="m.value">{{ m.label }}</option>
</select>
</div>
<div class="mesh-assistant-field">
@ -147,6 +187,28 @@ function onPolicy() {
</p>
</div>
<!-- Per-contact allowlist: let specific contacts use !ai even when the
policy is "trusted only" and they aren't federation-trusted. -->
<div class="mesh-assistant-field">
<label class="mesh-bitcoin-label">Always allow these contacts</label>
<p class="text-xs text-white/40 mb-2">
Listed contacts can use <code>!ai</code> regardless of the policy above.
</p>
<div v-if="contactOptions.length === 0" class="text-xs text-white/40">
No contacts yet they appear here once you have mesh/federation contacts.
</div>
<div v-else class="mesh-assistant-allowlist">
<label
v-for="c in contactOptions"
:key="c.pubkey"
class="mesh-assistant-allow-row"
>
<input type="checkbox" :checked="isAllowed(c.pubkey)" @change="toggleAllowed(c.pubkey)" />
<span class="mesh-assistant-allow-name">{{ c.name }}</span>
</label>
</div>
</div>
<p class="text-xs text-white/50 mt-2">
Ask from any client by sending <code>!ai &lt;question&gt;</code> on the mesh channel.
</p>

View File

@ -33,7 +33,7 @@
.mesh-flasher-sep { margin: 0 8px; color: rgba(255, 255, 255, 0.2); }
.mesh-error { color: #ef4444; font-size: 0.85rem; padding: 8px 12px; background: rgba(239, 68, 68, 0.1); border-radius: 8px; border: 1px solid rgba(239, 68, 68, 0.2); flex-shrink: 0; }
.mesh-columns { display: flex; gap: 16px; flex: 1; min-height: 0; overflow: hidden; }
.mesh-left { width: 380px; flex-shrink: 0; display: flex; flex-direction: column; gap: 12px; min-height: 0; overflow-y: auto; }
.mesh-left { width: 380px; flex-shrink: 0; display: flex; flex-direction: column; gap: 12px; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
.mesh-right { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: hidden; }
.mesh-tools-wrapper { display: contents; }
.mesh-tools-tab-bar { display: none; }
@ -121,7 +121,7 @@
.mesh-chat-header-sub { font-size: 0.7rem; color: rgba(255, 255, 255, 0.3); font-family: monospace; }
.mesh-chat-header-status { flex-shrink: 0; }
.mesh-chat-header-time { font-size: 0.7rem; color: rgba(255, 255, 255, 0.3); }
.mesh-chat-messages { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
.mesh-chat-messages { flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 16px; display: flex; flex-direction: column; gap: 8px; min-height: 0; }
.mesh-chat-no-messages { flex: 1; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.25); font-size: 0.85rem; }
.mesh-chat-bubble-wrapper { display: flex; }
.mesh-chat-bubble-wrapper.sent { justify-content: flex-end; }
@ -232,6 +232,10 @@
.mesh-assistant-field { display: flex; flex-direction: column; gap: 4px; }
.mesh-assistant-install { padding: 12px; background: rgba(251,146,60,0.08); border: 1px solid rgba(251,146,60,0.25); border-radius: 10px; }
.mesh-assistant-install-btn { display: inline-block; text-align: center; padding: 8px 14px; font-size: 0.8rem; }
.mesh-assistant-allowlist { display: flex; flex-direction: column; gap: 2px; max-height: 180px; overflow-y: auto; overscroll-behavior: contain; border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 6px; background: rgba(0,0,0,0.2); }
.mesh-assistant-allow-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; color: rgba(255,255,255,0.85); }
.mesh-assistant-allow-row:hover { background: rgba(255,255,255,0.06); }
.mesh-assistant-allow-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mesh-panel-title { font-size: 1rem; font-weight: 700; color: rgba(255,255,255,0.95); margin: 0; }
.mesh-panel-sub { font-size: 0.8rem; color: rgba(255,255,255,0.45); margin: -4px 0 0; }
.mesh-bitcoin-section { display: flex; flex-direction: column; gap: 8px; }

View File

@ -2,8 +2,6 @@
import InterfaceModeSection from '@/views/settings/InterfaceModeSection.vue'
import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue'
import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
import SystemUpdatesSection from '@/views/settings/SystemUpdatesSection.vue'
import AppRegistriesSection from '@/views/settings/AppRegistriesSection.vue'
import WebhookSection from '@/views/settings/WebhookSection.vue'
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
import BackupSection from '@/views/settings/BackupSection.vue'
@ -14,8 +12,6 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
<InterfaceModeSection />
<ClaudeAuthSection />
<AIDataAccessSection />
<SystemUpdatesSection />
<AppRegistriesSection />
<WebhookSection />
<TelemetrySection />
<BackupSection />