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:
parent
2a017623e9
commit
3a21243be7
@ -36,9 +36,12 @@ app:
|
|||||||
capabilities: []
|
capabilities: []
|
||||||
readonly_root: true
|
readonly_root: true
|
||||||
# NOT isolated: fmcd needs outbound UDP + Mainline DHT (port 6881) + iroh
|
# NOT isolated: fmcd needs outbound UDP + Mainline DHT (port 6881) + iroh
|
||||||
# relays to reach iroh-transport federations. Lock down once the default
|
# relays to reach iroh-transport federations. `bridge` gives NAT'd outbound
|
||||||
# federation's reachability model is finalized.
|
# (UDP/DHT/iroh hole-punch all work) plus the published 8178→8080 port the
|
||||||
network_policy: open
|
# 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:
|
ports:
|
||||||
# fmcd REST bound to 8080 in-container; 8080 collides with LND REST on the
|
# fmcd REST bound to 8080 in-container; 8080 collides with LND REST on the
|
||||||
|
|||||||
@ -32,6 +32,7 @@ impl RpcHandler {
|
|||||||
"model": cfg.model,
|
"model": cfg.model,
|
||||||
"trusted_only": cfg.trusted_only,
|
"trusted_only": cfg.trusted_only,
|
||||||
"backend": cfg.backend,
|
"backend": cfg.backend,
|
||||||
|
"allowed_contacts": cfg.allowed_contacts,
|
||||||
"default_model": DEFAULT_MODEL,
|
"default_model": DEFAULT_MODEL,
|
||||||
"ollama_detected": ollama_detected,
|
"ollama_detected": ollama_detected,
|
||||||
"claude_available": claude_available,
|
"claude_available": claude_available,
|
||||||
@ -64,8 +65,18 @@ impl RpcHandler {
|
|||||||
} else {
|
} else {
|
||||||
None
|
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?;
|
.await?;
|
||||||
let cfg = svc.assistant_config().await;
|
let cfg = svc.assistant_config().await;
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
@ -73,6 +84,7 @@ impl RpcHandler {
|
|||||||
"model": cfg.model,
|
"model": cfg.model,
|
||||||
"trusted_only": cfg.trusted_only,
|
"trusted_only": cfg.trusted_only,
|
||||||
"backend": cfg.backend,
|
"backend": cfg.backend,
|
||||||
|
"allowed_contacts": cfg.allowed_contacts,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
//! asker is limited to one in-flight query.
|
//! asker is limited to one in-flight query.
|
||||||
|
|
||||||
use super::super::message_types::{self, AssistResponsePayload, MeshMessageType};
|
use super::super::message_types::{self, AssistResponsePayload, MeshMessageType};
|
||||||
|
use super::super::types::MeshEvent;
|
||||||
use super::bitcoin::send_to_peer;
|
use super::bitcoin::send_to_peer;
|
||||||
use super::{MeshCommand, MeshState};
|
use super::{MeshCommand, MeshState};
|
||||||
use crate::federation::TrustLevel;
|
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
|
/// Plain-text broadcast on a mesh channel — the bare `!ai` path, so any
|
||||||
/// client (including non-archipelago meshcore/Meshtastic nodes) sees it.
|
/// client (including non-archipelago meshcore/Meshtastic nodes) sees it.
|
||||||
ChannelText { channel: u8 },
|
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
|
/// 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 {
|
if !state.assistant.read().await.trusted_only {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -205,6 +220,10 @@ async fn send_reply(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64, an
|
|||||||
let text = cap_channel(answer);
|
let text = cap_channel(answer);
|
||||||
send_channel_text(state, *channel, &text).await;
|
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 } => {
|
AssistReply::ChannelText { channel } => {
|
||||||
send_channel_text(state, *channel, &format!("AI: {msg}")).await;
|
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.
|
/// Broadcast a plain-text answer on a channel for bare `!ai` clients.
|
||||||
async fn send_channel_text(state: &Arc<MeshState>, channel: u8, text: &str) {
|
async fn send_channel_text(state: &Arc<MeshState>, channel: u8, text: &str) {
|
||||||
let _ = state
|
let _ = state
|
||||||
|
|||||||
@ -383,7 +383,7 @@ pub(super) async fn store_plain_message(
|
|||||||
|
|
||||||
/// Recognise a `!ai`/`!ask ` command prefix (case-insensitive) and return the
|
/// 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.
|
/// 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();
|
let t = text.trim_start();
|
||||||
for p in ["!ai ", "!ask "] {
|
for p in ["!ai ", "!ask "] {
|
||||||
if t.len() >= p.len() && t[..p.len()].eq_ignore_ascii_case(p) {
|
if t.len() >= p.len() && t[..p.len()].eq_ignore_ascii_case(p) {
|
||||||
|
|||||||
@ -679,6 +679,36 @@ pub(crate) async fn handle_typed_envelope_direct(
|
|||||||
Some(envelope.seq),
|
Some(envelope.seq),
|
||||||
)
|
)
|
||||||
.await;
|
.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) => {
|
Some(MeshMessageType::AssistQuery) => {
|
||||||
|
|||||||
@ -148,6 +148,10 @@ pub struct AssistantConfig {
|
|||||||
pub trusted_only: bool,
|
pub trusted_only: bool,
|
||||||
/// AI backend: "claude" (shared proxy token) or "ollama" (local model).
|
/// AI backend: "claude" (shared proxy token) or "ollama" (local model).
|
||||||
pub backend: String,
|
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
|
/// Contact metadata kept alongside MeshState.peers. Pinned contacts sort to
|
||||||
|
|||||||
@ -424,21 +424,16 @@ pub(super) async fn run_mesh_session(
|
|||||||
warn!("Failed to send initial advert: {}", e);
|
warn!("Failed to send initial advert: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archipelago identity advert (`ARCHY:2:{ed}:{x25519}`): broadcast as channel
|
// NOTE: Archipelago identity adverts (`ARCHY:2:{ed}:{x25519}`) are intentionally
|
||||||
// text so peers can bind our radio presence to our DID + keys. The firmware
|
// NOT broadcast on the shared public channel (channel 0). Doing so spams every
|
||||||
// advert alone carries the meshcore key (and nothing on Meshtastic), so this
|
// participant on that channel — including plain Meshtastic/meshcore users who
|
||||||
// is what makes trust-gating + encrypted DMs work across BOTH transports.
|
// just see raw `ARCHY:2:…` text — on startup and again on every advert tick.
|
||||||
let identity_advert = super::super::protocol::encode_identity_broadcast(
|
// The inbound parser in frames.rs still accepts these from any legacy peer that
|
||||||
our_did,
|
// sends them, so trust-binding keeps working when a peer advertises; we simply
|
||||||
our_ed_pubkey_hex,
|
// don't pollute the public channel ourselves. A dedicated control channel (or a
|
||||||
our_x25519_pubkey_hex,
|
// DM-targeted handshake) is the proper transport for this and is tracked
|
||||||
);
|
// separately. See encode_identity_broadcast / parse_identity_broadcast.
|
||||||
if let Err(e) = device
|
let _ = (our_did, our_ed_pubkey_hex, our_x25519_pubkey_hex);
|
||||||
.send_channel_text(0, identity_advert.as_bytes())
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
warn!("Failed to broadcast archipelago identity: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch existing contacts from the device
|
// Fetch existing contacts from the device
|
||||||
refresh_contacts(&mut device, state).await;
|
refresh_contacts(&mut device, state).await;
|
||||||
@ -507,11 +502,9 @@ pub(super) async fn run_mesh_session(
|
|||||||
} else {
|
} else {
|
||||||
consecutive_write_failures = 0;
|
consecutive_write_failures = 0;
|
||||||
}
|
}
|
||||||
// Re-broadcast archipelago identity so peers that joined since
|
// (Identity re-broadcast on the public channel intentionally
|
||||||
// startup (or missed it) can bind our DID/keys.
|
// removed — see the note at session startup. It spammed the
|
||||||
if let Err(e) = device.send_channel_text(0, identity_advert.as_bytes()).await {
|
// shared channel every advert tick.)
|
||||||
warn!("Failed to re-broadcast archipelago identity: {}", e);
|
|
||||||
}
|
|
||||||
refresh_contacts(&mut device, state).await;
|
refresh_contacts(&mut device, state).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -197,6 +197,10 @@ pub struct MeshConfig {
|
|||||||
/// local GPU) or "ollama" (a local model on this node).
|
/// local GPU) or "ollama" (a local model on this node).
|
||||||
#[serde(default = "default_assistant_backend")]
|
#[serde(default = "default_assistant_backend")]
|
||||||
pub assistant_backend: String,
|
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 {
|
fn default_assistant_backend() -> String {
|
||||||
@ -224,6 +228,7 @@ impl Default for MeshConfig {
|
|||||||
assistant_model: None,
|
assistant_model: None,
|
||||||
assistant_trusted_only: true,
|
assistant_trusted_only: true,
|
||||||
assistant_backend: default_assistant_backend(),
|
assistant_backend: default_assistant_backend(),
|
||||||
|
assistant_allowed_contacts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -401,6 +406,7 @@ impl MeshService {
|
|||||||
model: config.assistant_model.clone(),
|
model: config.assistant_model.clone(),
|
||||||
trusted_only: config.assistant_trusted_only,
|
trusted_only: config.assistant_trusted_only,
|
||||||
backend: config.assistant_backend.clone(),
|
backend: config.assistant_backend.clone(),
|
||||||
|
allowed_contacts: config.assistant_allowed_contacts.clone(),
|
||||||
},
|
},
|
||||||
data_dir.to_path_buf(),
|
data_dir.to_path_buf(),
|
||||||
);
|
);
|
||||||
@ -1401,6 +1407,7 @@ impl MeshService {
|
|||||||
model: Option<Option<String>>,
|
model: Option<Option<String>>,
|
||||||
trusted_only: Option<bool>,
|
trusted_only: Option<bool>,
|
||||||
backend: Option<String>,
|
backend: Option<String>,
|
||||||
|
allowed_contacts: Option<Vec<String>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
{
|
{
|
||||||
let mut a = self.state.assistant.write().await;
|
let mut a = self.state.assistant.write().await;
|
||||||
@ -1416,6 +1423,9 @@ impl MeshService {
|
|||||||
if let Some(b) = backend {
|
if let Some(b) = backend {
|
||||||
a.backend = b;
|
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`
|
// Persist by updating the on-disk config (the in-memory `self.config`
|
||||||
// snapshot stays as-is; the live `state.assistant` is the runtime
|
// 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_model = a.model.clone();
|
||||||
cfg.assistant_trusted_only = a.trusted_only;
|
cfg.assistant_trusted_only = a.trusted_only;
|
||||||
cfg.assistant_backend = a.backend.clone();
|
cfg.assistant_backend = a.backend.clone();
|
||||||
|
cfg.assistant_allowed_contacts = a.allowed_contacts.clone();
|
||||||
}
|
}
|
||||||
save_config(&self.data_dir, &cfg).await?;
|
save_config(&self.data_dir, &cfg).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@ -172,4 +172,13 @@ pub enum MeshEvent {
|
|||||||
to_contact_id: u32,
|
to_contact_id: u32,
|
||||||
error: Option<String>,
|
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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@ -305,6 +305,47 @@ impl Server {
|
|||||||
.rpc_handler()
|
.rpc_handler()
|
||||||
.set_mesh_service(mesh_service)
|
.set_mesh_service(mesh_service)
|
||||||
.await;
|
.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");
|
info!("📡 Mesh service initialized");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|||||||
@ -130,6 +130,7 @@ export interface AssistantStatus {
|
|||||||
model: string | null
|
model: string | null
|
||||||
trusted_only: boolean
|
trusted_only: boolean
|
||||||
backend: string
|
backend: string
|
||||||
|
allowed_contacts: string[]
|
||||||
default_model: string
|
default_model: string
|
||||||
ollama_detected: boolean
|
ollama_detected: boolean
|
||||||
claude_available: boolean
|
claude_available: boolean
|
||||||
@ -613,6 +614,7 @@ export const useMeshStore = defineStore('mesh', () => {
|
|||||||
model?: string | null
|
model?: string | null
|
||||||
trusted_only?: boolean
|
trusted_only?: boolean
|
||||||
backend?: string
|
backend?: string
|
||||||
|
allowed_contacts?: string[]
|
||||||
}) {
|
}) {
|
||||||
const res = await rpcClient.call<Partial<AssistantStatus>>({
|
const res = await rpcClient.call<Partial<AssistantStatus>>({
|
||||||
method: 'mesh.assistant-configure',
|
method: 'mesh.assistant-configure',
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AccountSection from '@/views/settings/AccountSection.vue'
|
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'
|
import SystemSection from '@/views/settings/SystemSection.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="pb-6">
|
<div class="pb-6">
|
||||||
<AccountSection />
|
<AccountSection />
|
||||||
|
<SystemUpdatesSection />
|
||||||
|
<AppRegistriesSection />
|
||||||
<SystemSection />
|
<SystemSection />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -11,6 +11,23 @@ const enabled = ref(false)
|
|||||||
const model = ref('') // '' = use the backend's default model
|
const model = ref('') // '' = use the backend's default model
|
||||||
const policy = ref<'trusted' | 'anyone'>('trusted')
|
const policy = ref<'trusted' | 'anyone'>('trusted')
|
||||||
const backend = ref<'claude' | 'ollama'>('claude')
|
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.
|
// Sync local controls from the fetched status.
|
||||||
watch(
|
watch(
|
||||||
@ -21,10 +38,30 @@ watch(
|
|||||||
model.value = s.model ?? ''
|
model.value = s.model ?? ''
|
||||||
policy.value = s.trusted_only ? 'trusted' : 'anyone'
|
policy.value = s.trusted_only ? 'trusted' : 'anyone'
|
||||||
backend.value = s.backend === 'ollama' ? 'ollama' : 'claude'
|
backend.value = s.backend === 'ollama' ? 'ollama' : 'claude'
|
||||||
|
allowedContacts.value = [...(s.allowed_contacts ?? [])]
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ 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(() => {
|
onMounted(() => {
|
||||||
mesh.fetchAssistantStatus()
|
mesh.fetchAssistantStatus()
|
||||||
})
|
})
|
||||||
@ -45,6 +82,7 @@ async function apply(partial: {
|
|||||||
model?: string | null
|
model?: string | null
|
||||||
trusted_only?: boolean
|
trusted_only?: boolean
|
||||||
backend?: string
|
backend?: string
|
||||||
|
allowed_contacts?: string[]
|
||||||
}) {
|
}) {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
@ -131,7 +169,9 @@ function onPolicy() {
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="mesh-assistant-field">
|
<div v-else class="mesh-assistant-field">
|
||||||
<label class="mesh-bitcoin-label">Model</label>
|
<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>
|
||||||
|
|
||||||
<div class="mesh-assistant-field">
|
<div class="mesh-assistant-field">
|
||||||
@ -147,6 +187,28 @@ function onPolicy() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<p class="text-xs text-white/50 mt-2">
|
||||||
Ask from any client by sending <code>!ai <question></code> on the mesh channel.
|
Ask from any client by sending <code>!ai <question></code> on the mesh channel.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -33,7 +33,7 @@
|
|||||||
.mesh-flasher-sep { margin: 0 8px; color: rgba(255, 255, 255, 0.2); }
|
.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-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-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-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-wrapper { display: contents; }
|
||||||
.mesh-tools-tab-bar { display: none; }
|
.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-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-status { flex-shrink: 0; }
|
||||||
.mesh-chat-header-time { font-size: 0.7rem; color: rgba(255, 255, 255, 0.3); }
|
.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-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 { display: flex; }
|
||||||
.mesh-chat-bubble-wrapper.sent { justify-content: flex-end; }
|
.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-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 { 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-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-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-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; }
|
.mesh-bitcoin-section { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
import InterfaceModeSection from '@/views/settings/InterfaceModeSection.vue'
|
import InterfaceModeSection from '@/views/settings/InterfaceModeSection.vue'
|
||||||
import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue'
|
import ClaudeAuthSection from '@/views/settings/ClaudeAuthSection.vue'
|
||||||
import AIDataAccessSection from '@/views/settings/AIDataAccessSection.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 WebhookSection from '@/views/settings/WebhookSection.vue'
|
||||||
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
|
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
|
||||||
import BackupSection from '@/views/settings/BackupSection.vue'
|
import BackupSection from '@/views/settings/BackupSection.vue'
|
||||||
@ -14,8 +12,6 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
|
|||||||
<InterfaceModeSection />
|
<InterfaceModeSection />
|
||||||
<ClaudeAuthSection />
|
<ClaudeAuthSection />
|
||||||
<AIDataAccessSection />
|
<AIDataAccessSection />
|
||||||
<SystemUpdatesSection />
|
|
||||||
<AppRegistriesSection />
|
|
||||||
<WebhookSection />
|
<WebhookSection />
|
||||||
<TelemetrySection />
|
<TelemetrySection />
|
||||||
<BackupSection />
|
<BackupSection />
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user