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: []
|
||||
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
|
||||
|
||||
@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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(())
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 <question></code> on the mesh channel.
|
||||
</p>
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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 />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user