From 3a21243be7c662c67bf1b6dd599a4529ad6e2855 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 18 Jun 2026 03:33:37 -0400 Subject: [PATCH] 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 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- apps/fedimint-clientd/manifest.yml | 9 ++- .../archipelago/src/api/rpc/mesh/assistant.rs | 14 +++- core/archipelago/src/mesh/listener/assist.rs | 39 +++++++++++ core/archipelago/src/mesh/listener/decode.rs | 2 +- .../archipelago/src/mesh/listener/dispatch.rs | 30 +++++++++ core/archipelago/src/mesh/listener/mod.rs | 4 ++ core/archipelago/src/mesh/listener/session.rs | 33 ++++------ core/archipelago/src/mesh/mod.rs | 11 ++++ core/archipelago/src/mesh/types.rs | 9 +++ core/archipelago/src/server.rs | 41 ++++++++++++ neode-ui/src/stores/mesh.ts | 2 + neode-ui/src/views/Settings.vue | 4 ++ .../src/views/mesh/MeshAssistantPanel.vue | 64 ++++++++++++++++++- neode-ui/src/views/mesh/mesh-styles.css | 8 ++- neode-ui/src/views/settings/SystemSection.vue | 4 -- 15 files changed, 242 insertions(+), 32 deletions(-) diff --git a/apps/fedimint-clientd/manifest.yml b/apps/fedimint-clientd/manifest.yml index 49de9e7b..12e4d335 100644 --- a/apps/fedimint-clientd/manifest.yml +++ b/apps/fedimint-clientd/manifest.yml @@ -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 diff --git a/core/archipelago/src/api/rpc/mesh/assistant.rs b/core/archipelago/src/api/rpc/mesh/assistant.rs index 0995298d..7187303f 100644 --- a/core/archipelago/src/api/rpc/mesh/assistant.rs +++ b/core/archipelago/src/api/rpc/mesh/assistant.rs @@ -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::>() + }); - 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, })) } diff --git a/core/archipelago/src/mesh/listener/assist.rs b/core/archipelago/src/mesh/listener/assist.rs index 0e54a46e..b6433eed 100644 --- a/core/archipelago/src/mesh/listener/assist.rs +++ b/core/archipelago/src/mesh/listener/assist.rs @@ -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, 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, 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, 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, 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, channel: u8, text: &str) { let _ = state diff --git a/core/archipelago/src/mesh/listener/decode.rs b/core/archipelago/src/mesh/listener/decode.rs index cafa6ddf..beca4951 100644 --- a/core/archipelago/src/mesh/listener/decode.rs +++ b/core/archipelago/src/mesh/listener/decode.rs @@ -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) { diff --git a/core/archipelago/src/mesh/listener/dispatch.rs b/core/archipelago/src/mesh/listener/dispatch.rs index 633162a3..735bc96d 100644 --- a/core/archipelago/src/mesh/listener/dispatch.rs +++ b/core/archipelago/src/mesh/listener/dispatch.rs @@ -679,6 +679,36 @@ pub(crate) async fn handle_typed_envelope_direct( Some(envelope.seq), ) .await; + + // Mesh-AI assistant (issue #50): a `!ai`/`!ask ` 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) => { diff --git a/core/archipelago/src/mesh/listener/mod.rs b/core/archipelago/src/mesh/listener/mod.rs index 7f65ccd0..528a5fdb 100644 --- a/core/archipelago/src/mesh/listener/mod.rs +++ b/core/archipelago/src/mesh/listener/mod.rs @@ -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, } /// Contact metadata kept alongside MeshState.peers. Pinned contacts sort to diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index 3f816467..ba3a393e 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -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; } diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index bda6efe3..1fb565f3 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -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, } 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>, trusted_only: Option, backend: Option, + allowed_contacts: Option>, ) -> 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(()) diff --git a/core/archipelago/src/mesh/types.rs b/core/archipelago/src/mesh/types.rs index f5eabdb2..6517874f 100644 --- a/core/archipelago/src/mesh/types.rs +++ b/core/archipelago/src/mesh/types.rs @@ -172,4 +172,13 @@ pub enum MeshEvent { to_contact_id: u32, error: Option, }, + /// 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, + }, } diff --git a/core/archipelago/src/server.rs b/core/archipelago/src/server.rs index b8a4d822..38b31a85 100644 --- a/core/archipelago/src/server.rs +++ b/core/archipelago/src/server.rs @@ -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) => { diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index 79d82fc6..416a24d3 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -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>({ method: 'mesh.assistant-configure', diff --git a/neode-ui/src/views/Settings.vue b/neode-ui/src/views/Settings.vue index f5506d86..9a1c0cd7 100644 --- a/neode-ui/src/views/Settings.vue +++ b/neode-ui/src/views/Settings.vue @@ -1,11 +1,15 @@ diff --git a/neode-ui/src/views/mesh/MeshAssistantPanel.vue b/neode-ui/src/views/mesh/MeshAssistantPanel.vue index c87ae8be..d32ed598 100644 --- a/neode-ui/src/views/mesh/MeshAssistantPanel.vue +++ b/neode-ui/src/views/mesh/MeshAssistantPanel.vue @@ -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([]) + +// 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() {
- +
@@ -147,6 +187,28 @@ function onPolicy() {

+ +
+ +

+ Listed contacts can use !ai regardless of the policy above. +

+
+ No contacts yet — they appear here once you have mesh/federation contacts. +
+
+ +
+
+

Ask from any client by sending !ai <question> on the mesh channel.

diff --git a/neode-ui/src/views/mesh/mesh-styles.css b/neode-ui/src/views/mesh/mesh-styles.css index 101fd465..477a5ca4 100644 --- a/neode-ui/src/views/mesh/mesh-styles.css +++ b/neode-ui/src/views/mesh/mesh-styles.css @@ -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; } diff --git a/neode-ui/src/views/settings/SystemSection.vue b/neode-ui/src/views/settings/SystemSection.vue index 78b5c2ac..a917e114 100644 --- a/neode-ui/src/views/settings/SystemSection.vue +++ b/neode-ui/src/views/settings/SystemSection.vue @@ -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' - -