From c5ec624ebcef1cd22c04205fb86efbe07da25d0d Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 19 May 2026 17:06:52 -0400 Subject: [PATCH] mesh-llm: B6 kind:31990 publisher + settings-card publish-on-save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit desktop/src-tauri/src/commands/mesh_llm.rs: new mesh_publish_offer command. Reads persisted prefs and the local iroh endpoint id; on enabled=true, builds a kind:31990 event with the JSON-serialised MeshLlmOffer envelope and a 'd' tag matching prefs.d_tag, then signs + POSTs via the existing submit_event pipeline (NIP-98 to /events). On enabled=false, publishes the *same address* with empty content — NIP-33's 'delete by replace' idiom — so consumers know the offer has been withdrawn. PublishOfferResult.published_offer reports which path was taken. desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx: persist() now follows save-prefs with a relay capability probe and (when the relay advertises iroh_relay_url) a publish call. If the relay doesn't support mesh-LLM, prefs are still saved locally and the UI surfaces a specific 'this relay does not advertise iroh_relay_url' message rather than a confusing 'publish failed'. The user-facing flow is now: open settings -> Share compute -> toggle on -> a kind:31990 event hits the relay, NIP-43-fanned-out to other members. Toggling off publishes the empty-content replacement. Tests: 208 sprout-relay, 174 sprout-core, 11 desktop mesh_llm — all unchanged-and-pass. desktop typecheck + biome clean. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- desktop/src-tauri/src/commands/mesh_llm.rs | 81 ++++++++++++++++++- desktop/src-tauri/src/lib.rs | 1 + .../settings/ui/MeshComputeSettingsCard.tsx | 17 ++++ 3 files changed, 96 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index cca4e6e24..321d77889 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -3,16 +3,20 @@ //! All commands deal with *the local user's own* mesh-LLM state: //! - the persisted iroh endpoint id, //! - the persisted compute-sharing preferences (the avatar-menu sliders), -//! - explicit toggle/save calls invoked when the user changes the prefs. +//! - explicit toggle/save calls invoked when the user changes the prefs, +//! - publishing / deleting the user's kind:31990 compute-offer event. //! -//! Discovering and connecting to *other* members' offers happens through the -//! existing relay WebSocket pipeline, not these commands. +//! Discovering *other* members' offers happens through the relay +//! WebSocket pipeline already exposed by `relayClientSession.ts`. +use nostr::{EventBuilder, Kind, Tag}; use serde::Serialize; +use sprout_core::kind::KIND_MESH_LLM_DISCOVERY; use tauri::{AppHandle, State}; use crate::app_state::AppState; use crate::mesh_llm; +use crate::relay::submit_event; /// Result type for mesh-LLM commands: errors are surfaced as user-facing /// strings by the frontend. @@ -70,3 +74,74 @@ pub async fn mesh_relay_iroh_url( .await .map_err(|e| e.to_string()) } + +// ── Publisher ────────────────────────────────────────────────────────────── + +/// Result of `mesh_publish_offer` — surface enough state so the frontend +/// can show the user *which* offer just went on the wire. +#[derive(Debug, Clone, Serialize)] +pub struct PublishOfferResult { + /// `event_id` returned by the relay on accept. + pub event_id: String, + /// `true` if compute-sharing is currently enabled. When false, the + /// command publishes an *empty-content* kind:31990 event at the same + /// `(pubkey, d_tag)` address, which under NIP-33 is the canonical way + /// to indicate "this offer is no longer active". Consumers that observe + /// the empty content drop the offer from their cache. + pub published_offer: bool, +} + +/// Publish (or revoke) the user's kind:31990 compute-offer event. +/// +/// Reads the current prefs from disk and the local iroh endpoint id. If +/// `enabled = true`, builds a kind:31990 with the offer envelope content +/// and the matching `d` tag; signs and POSTs via the existing +/// [`submit_event`] pipeline (NIP-98-authenticated to the configured relay). +/// If `enabled = false`, publishes the *same address* with empty content +/// to tell consumers the offer has been retired. +/// +/// `iroh_relay_url` should be the relay's NIP-11 `iroh_relay_url` (fetched +/// via [`mesh_relay_iroh_url`] at session start). The offer envelope +/// carries it so consumers know where to dial. +#[tauri::command] +pub async fn mesh_publish_offer( + app: AppHandle, + state: State<'_, AppState>, + iroh_relay_url: String, +) -> CmdResult { + // Load prefs + endpoint key. These are sync; complete before any await. + let prefs = mesh_llm::offer::load_prefs(&app).map_err(|e| e.to_string())?; + let endpoint_key = + mesh_llm::load_or_create_endpoint_key(&app).map_err(|e| e.to_string())?; + let endpoint_id_str = endpoint_key.public().to_string(); + + let d_tag = prefs.d_tag.clone(); + let d_tag_tag = Tag::parse(["d", &d_tag]).map_err(|e| format!("d tag: {e}"))?; + + let (content, published_offer) = if prefs.enabled { + let offer = prefs + .build_offer(&endpoint_id_str, &iroh_relay_url) + .ok_or_else(|| { + "build_offer returned None despite enabled=true (logic bug)".to_string() + })?; + if !offer.is_publishable() { + return Err("offer envelope failed publishable check".to_string()); + } + let json = serde_json::to_string(&offer).map_err(|e| format!("serialise: {e}"))?; + (json, true) + } else { + // NIP-33 "delete by replace": same (pubkey, kind, d) address, empty + // content. Consumers must treat an empty content as 'offer + // withdrawn'. + (String::new(), false) + }; + + let builder = EventBuilder::new(Kind::Custom(KIND_MESH_LLM_DISCOVERY as u16), content) + .tags(vec![d_tag_tag]); + + let res = submit_event(builder, &state).await?; + Ok(PublishOfferResult { + event_id: res.event_id, + published_offer, + }) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 96e618fba..8e98f90f4 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -459,6 +459,7 @@ pub fn run() { mesh_get_sharing_prefs, mesh_set_sharing_prefs, mesh_relay_iroh_url, + mesh_publish_offer, discover_acp_providers, discover_managed_agent_prereqs, sign_event, diff --git a/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx index 911cdc0d2..17f6510df 100644 --- a/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx +++ b/desktop/src/features/settings/ui/MeshComputeSettingsCard.tsx @@ -85,8 +85,25 @@ export function MeshComputeSettingsCard() { setSaving(true); setError(null); try { + // Save first so a failed publish leaves the prefs in a sane state. await invoke("mesh_set_sharing_prefs", { prefs: next }); setPrefs(next); + + // Probe the connected relay for its iroh_relay_url. If it doesn't + // advertise mesh-LLM at all, the offer can't be published — but the + // local prefs are still saved (the user might re-connect to a + // mesh-capable relay later). + const relayWsUrl = await invoke("get_relay_ws_url"); + const irohUrl = await invoke("mesh_relay_iroh_url", { + relayWsUrl, + }); + if (irohUrl) { + await invoke("mesh_publish_offer", { irohRelayUrl: irohUrl }); + } else if (next.enabled) { + setError( + "Saved locally, but this relay does not advertise iroh_relay_url — your offer will not be visible to other members until the relay is configured for mesh-LLM.", + ); + } } catch (e) { setError(String(e)); } finally {