diff --git a/desktop/src-tauri/src/commands/mesh_snapshot.rs b/desktop/src-tauri/src/commands/mesh_snapshot.rs new file mode 100644 index 000000000..f30c95f93 --- /dev/null +++ b/desktop/src-tauri/src/commands/mesh_snapshot.rs @@ -0,0 +1,47 @@ +//! `mesh_snapshot` — community-wide shared-compute snapshot for the UI. +//! +//! Separate file from `commands/mesh_llm.rs` deliberately: that module is at +//! the 1000-line file ceiling enforced by `desktop/scripts/check-file-sizes.mjs`. +//! +//! Read-only. Queries the same member-status + membership filters routing uses, +//! then projects them through [`mesh_llm::snapshot_from_events`], which applies +//! the membership/binding/freshness rules. This never selects a serve target — +//! routing stays with `availability_from_events`. + +use tauri::State; + +use crate::app_state::AppState; +use crate::mesh_llm; + +type CmdResult = Result; + +/// Snapshot of who is sharing compute in this community right now. +/// +/// An empty mesh is a normal state, not an error: the snapshot carries a +/// `reason` string instead of failing, so the card can render an honest empty +/// state. A genuine relay/transport failure still returns `Err`. +#[tauri::command] +pub async fn mesh_snapshot(state: State<'_, AppState>) -> CmdResult { + let events = crate::relay::query_relay( + &state, + &[ + mesh_llm::mesh_status_filter(), + mesh_llm::relay_membership_filter(), + ], + ) + .await + .map_err(|error| format!("Shared compute status query failed: {error}"))?; + + // Identify this member's own device so the card can say "including yours". + // A missing/locked identity is not fatal here — the snapshot is still + // useful, just without self-attribution. + let self_pubkey = state + .signing_keys() + .ok() + .map(|keys| keys.public_key().to_hex()); + + Ok(mesh_llm::snapshot_from_events( + events, + self_pubkey.as_deref(), + )) +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 66ef7ef17..e7c8783da 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -32,6 +32,8 @@ mod media_snapshot_png; mod media_transcode; #[cfg(feature = "mesh-llm")] pub(crate) mod mesh_llm; +#[cfg(feature = "mesh-llm")] +mod mesh_snapshot; mod messages; mod notifications; mod observer_archive; @@ -87,6 +89,8 @@ pub use media::*; pub use media_download::*; #[cfg(feature = "mesh-llm")] pub use mesh_llm::*; +#[cfg(feature = "mesh-llm")] +pub use mesh_snapshot::*; pub use messages::*; pub use notifications::*; pub use observer_archive::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a67264760..386c63577 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -773,6 +773,7 @@ pub fn run() { mesh_serving_usage, mesh_installed_models, mesh_model_catalog, + mesh_snapshot, update_managed_agent, discover_backend_providers, probe_backend_provider, diff --git a/desktop/src-tauri/src/mesh_llm/discovery.rs b/desktop/src-tauri/src/mesh_llm/discovery.rs index 2df4a7f8c..8629ed62c 100644 --- a/desktop/src-tauri/src/mesh_llm/discovery.rs +++ b/desktop/src-tauri/src/mesh_llm/discovery.rs @@ -12,7 +12,7 @@ use super::{dedupe_models, MeshAvailability, MeshModelOption, MeshServeTarget, M pub(super) const STATUS_FRESHNESS_SECS: u64 = 120; pub(crate) const MESH_STATUS_PAGE_SIZE: usize = 100; -fn status_is_fresh(event: &nostr::Event, now: u64) -> bool { +pub(super) fn status_is_fresh(event: &nostr::Event, now: u64) -> bool { event .created_at .as_secs() @@ -63,7 +63,7 @@ pub fn owner_ids_from_events(events: &[nostr::Event]) -> Vec { ids } -fn latest_membership_list(events: &[nostr::Event]) -> Option> { +pub(super) fn latest_membership_list(events: &[nostr::Event]) -> Option> { events .iter() .filter(|event| event.kind.as_u16() == 13_534) @@ -108,7 +108,7 @@ pub(crate) fn has_membership_snapshot(events: &[nostr::Event]) -> bool { events.iter().any(|event| event.kind.as_u16() == 13_534) } -fn owner_id_from_status_event(event: &nostr::Event) -> Option { +pub(super) fn owner_id_from_status_event(event: &nostr::Event) -> Option { let content = serde_json::from_str::(&event.content).ok()?; let owner_id = content .get("ownerId") @@ -136,7 +136,7 @@ fn owner_id_from_status_event(event: &nostr::Event) -> Option { Some(owner_id.to_string()) } -fn endpoint_binding_is_valid(event: &nostr::Event, content: &serde_json::Value) -> bool { +pub(super) fn endpoint_binding_is_valid(event: &nostr::Event, content: &serde_json::Value) -> bool { let Some(endpoint_tokens) = super::identity::advertised_endpoint_tokens(content) else { return false; }; diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs index e206c5388..743f8766d 100644 --- a/desktop/src-tauri/src/mesh_llm/mod.rs +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -13,6 +13,9 @@ pub(crate) use discovery::{ }; use discovery::{device_name_from_status, endpoint_id_from_status, enrich_status_payload_identity}; +mod snapshot; +pub use snapshot::{snapshot_from_events, MeshSnapshot}; + mod catalog; pub(crate) use catalog::canonical_curated_model_id; pub use catalog::{model_catalog, MeshModelCatalog}; diff --git a/desktop/src-tauri/src/mesh_llm/snapshot.rs b/desktop/src-tauri/src/mesh_llm/snapshot.rs new file mode 100644 index 000000000..5b824d6b0 --- /dev/null +++ b/desktop/src-tauri/src/mesh_llm/snapshot.rs @@ -0,0 +1,304 @@ +//! Community-wide mesh snapshot — an **allowlisted** projection of the member +//! status notes already on the relay. +//! +//! Every sharing member already publishes its whole SDK status payload as +//! kind:30003 content (see `mod.rs::status_report_payload`), but +//! `availability_from_events` reads only `serveTargets`/`models` and discards +//! the rest. This module projects the few extra fields the Share-compute UI +//! needs — per-device shared capacity, a coarse state, and a stable label — +//! and **nothing else**. +//! +//! Deliberately narrow, for two reasons: +//! 1. Routing must keep using [`super::availability_from_events`]. This +//! snapshot is presentation-only and must never select a serve target. +//! 2. The published payload also carries a hardware fingerprint, peer +//! topology, and operational counters. Naming each field we surface keeps +//! the UI from quietly growing into the rest of it. +//! +//! Capacity uses the payload's `my_vram_gb`, which mesh-llm derives from a +//! `vram_bytes` already clamped to the member's own `--max-vram` cap. Raw +//! `gpus[].vram_bytes` is *not* capped, so summing GPUs would advertise more +//! than the member agreed to share. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::discovery::{ + device_name_from_status, endpoint_binding_is_valid, endpoint_id_from_status, + latest_membership_list, owner_id_from_status_event, status_is_fresh, +}; +use super::{MeshServeTarget, MESH_STATUS_KIND}; + +/// Coarse per-device state. Intentionally not the SDK's `NodeState`: the UI +/// only needs "is this device giving compute, warming up, or just consuming?" +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MeshDeviceState { + /// Advertising at least one routable model. + Serving, + /// Serve-mode, model not ready yet. + Loading, + /// Serve-mode participant with no model work (contributes capacity only). + Standby, + /// Client-mode: consuming someone else's compute, not sharing. + Consuming, +} + +/// One device visible on the mesh. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct MeshSnapshotDevice { + /// Stable endpoint/device id, when the note carries one. + pub device_id: Option, + /// Human label: hostname when published, else a short endpoint id. + pub label: String, + /// Shared AI memory in GB, honoring the member's own cap. `None` when the + /// member is not currently running a node (a stopped member publishes only + /// its owner binding). + pub capacity_gb: Option, + /// Models this device is advertising right now. + pub models: Vec, + pub state: MeshDeviceState, + /// Whether this is the local member's own device. + pub is_self: bool, +} + +/// What the community's shared compute looks like right now. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct MeshSnapshot { + /// Devices advertising at least one routable model. + pub sharing_device_count: usize, + /// Summed shared capacity across sharing devices. `None` when no sharing + /// device published a figure — the UI then shows a count with no GB, never + /// a misleading `0 GB`. + pub shared_capacity_gb: Option, + /// Distinct models ready to use, deduped across devices. + pub models: Vec, + /// Per-device rows, for the topology view. Sharing devices first. + pub devices: Vec, + /// True when the local member's own device is among the sharing devices. + pub includes_self: bool, + /// Why the snapshot is empty, when it is. Never a hard error: an empty + /// mesh is a normal, expected state. + pub reason: Option, +} + +impl MeshSnapshot { + fn empty(reason: impl Into) -> Self { + Self { + reason: Some(reason.into()), + ..Default::default() + } + } +} + +fn f64_field(content: &serde_json::Value, key: &str) -> Option { + content + .get(key) + .and_then(serde_json::Value::as_f64) + .filter(|value| value.is_finite() && *value > 0.0) +} + +/// Coarse state from the published `node_state`, falling back to whether the +/// note actually advertises routable targets. +fn device_state(content: &serde_json::Value, has_targets: bool) -> MeshDeviceState { + let raw = content + .get("node_state") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .unwrap_or_default() + .to_ascii_lowercase(); + match raw.as_str() { + "serving" => MeshDeviceState::Serving, + "loading" => MeshDeviceState::Loading, + "client" => MeshDeviceState::Consuming, + "standby" => MeshDeviceState::Standby, + // Unknown/absent: trust the routable evidence rather than guessing. + _ if has_targets => MeshDeviceState::Serving, + _ => MeshDeviceState::Standby, + } +} + +/// Project validated member status notes into a presentation snapshot. +/// +/// Applies the same trust rules as routing — current NIP-43 membership, owner +/// binding, endpoint binding, and [`status_is_fresh`] — so a stale or +/// non-member note can never inflate the headline. Pure and total so it can be +/// unit-tested without a relay. +pub fn snapshot_from_events( + events: Vec, + self_member_pubkey: Option<&str>, +) -> MeshSnapshot { + if events.is_empty() { + return MeshSnapshot::empty("No shared compute has been published yet"); + } + let Some(members) = latest_membership_list(&events) else { + return MeshSnapshot::empty("Waiting for the current member roster"); + }; + + let self_pubkey = self_member_pubkey.map(str::to_ascii_lowercase); + let now = nostr::Timestamp::now().as_secs(); + // Status notes are replaceable per owner id, so key on that: two devices + // signed in as the same member each publish their own note. + let mut by_owner = BTreeMap::::new(); + + for event in events { + if event.kind.as_u16() as u64 != MESH_STATUS_KIND + || !status_is_fresh(&event, now) + || !members.contains(&event.pubkey.to_hex().to_ascii_lowercase()) + { + continue; + } + let Ok(content) = serde_json::from_str::(&event.content) else { + continue; + }; + let Some(owner_id) = owner_id_from_status_event(&event) else { + continue; + }; + if !endpoint_binding_is_valid(&event, &content) { + continue; + } + + // Reuse the routing-side target parse so "sharing" here means exactly + // what it means to the router: a validated, advertisable endpoint. + let targets = content + .get("serveTargets") + .or_else(|| content.get("serve_targets")) + .cloned() + .and_then(|value| serde_json::from_value::>(value).ok()) + .unwrap_or_default() + .into_iter() + .filter(|target| { + super::transport_policy::validate_advertised_endpoint(&target.endpoint_addr).is_ok() + }) + .collect::>(); + + let mut models = targets + .iter() + .map(|target| target.model_id.clone()) + .collect::>(); + models.sort(); + models.dedup(); + + let endpoint_id = endpoint_id_from_status(&content, None); + let is_self = self_pubkey + .as_deref() + .is_some_and(|pubkey| event.pubkey.to_hex().to_ascii_lowercase() == pubkey); + + by_owner.insert( + owner_id, + MeshSnapshotDevice { + label: device_name_from_status(&content, endpoint_id.as_deref()) + .unwrap_or_else(|| "Unknown device".to_string()), + device_id: endpoint_id, + capacity_gb: f64_field(&content, "my_vram_gb"), + state: device_state(&content, !models.is_empty()), + models, + is_self, + }, + ); + } + + let mut devices = by_owner.into_values().collect::>(); + // Sharing devices first, then the local device, then by label so the list + // is stable across polls (no jitter in the topology view). + devices.sort_by(|a, b| { + let sharing = |d: &MeshSnapshotDevice| d.state == MeshDeviceState::Serving; + sharing(b) + .cmp(&sharing(a)) + .then_with(|| b.is_self.cmp(&a.is_self)) + .then_with(|| a.label.cmp(&b.label)) + }); + + let sharing = devices + .iter() + .filter(|device| device.state == MeshDeviceState::Serving) + .collect::>(); + let sharing_device_count = sharing.len(); + let includes_self = sharing.iter().any(|device| device.is_self); + // Sum only reported figures; keep `None` when nobody reported one so the + // UI can drop the number instead of printing `0 GB`. + let shared_capacity_gb = sharing + .iter() + .filter_map(|device| device.capacity_gb) + .fold(None::, |acc, gb| Some(acc.unwrap_or(0.0) + gb)); + let mut models = sharing + .iter() + .flat_map(|device| device.models.iter().cloned()) + .collect::>(); + models.sort(); + models.dedup(); + + let reason = if sharing_device_count == 0 { + Some("No one is sharing compute yet".to_string()) + } else { + None + }; + + MeshSnapshot { + sharing_device_count, + shared_capacity_gb, + models, + devices, + includes_self, + reason, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_events_report_a_reason_not_an_error() { + let snapshot = snapshot_from_events(Vec::new(), None); + assert_eq!(snapshot.sharing_device_count, 0); + assert_eq!(snapshot.shared_capacity_gb, None); + assert!(snapshot.reason.is_some()); + } + + #[test] + fn missing_membership_snapshot_is_not_treated_as_an_empty_mesh() { + // A relay gap must not read as "nobody is sharing". + let event = nostr::EventBuilder::new(nostr::Kind::Custom(30003), "{}") + .sign_with_keys(&nostr::Keys::generate()) + .expect("test event signs"); + let snapshot = snapshot_from_events(vec![event], None); + assert_eq!( + snapshot.reason.as_deref(), + Some("Waiting for the current member roster") + ); + } + + #[test] + fn unknown_node_state_falls_back_to_routable_evidence() { + assert_eq!( + device_state(&serde_json::json!({}), true), + MeshDeviceState::Serving + ); + assert_eq!( + device_state(&serde_json::json!({}), false), + MeshDeviceState::Standby + ); + assert_eq!( + device_state(&serde_json::json!({"node_state": "client"}), false), + MeshDeviceState::Consuming + ); + } + + #[test] + fn capacity_ignores_zero_and_nonfinite_reports() { + // A node that has not surveyed yet publishes 0.0; that must not count + // as a real reported capacity. + assert_eq!( + f64_field(&serde_json::json!({"my_vram_gb": 0.0}), "my_vram_gb"), + None + ); + assert_eq!( + f64_field(&serde_json::json!({"my_vram_gb": 36.5}), "my_vram_gb"), + Some(36.5) + ); + } +} diff --git a/desktop/src-tauri/src/mesh_llm_stubs.rs b/desktop/src-tauri/src/mesh_llm_stubs.rs index e8c13f48e..5b93cf8c7 100644 --- a/desktop/src-tauri/src/mesh_llm_stubs.rs +++ b/desktop/src-tauri/src/mesh_llm_stubs.rs @@ -42,3 +42,8 @@ pub async fn mesh_installed_models( pub async fn mesh_model_catalog() -> CmdResult { Err("mesh-llm feature not enabled".to_string()) } + +#[tauri::command] +pub async fn mesh_snapshot(_state: State<'_, AppState>) -> CmdResult { + Err("mesh-llm feature not enabled".to_string()) +} diff --git a/desktop/src/features/mesh-compute/hooks/useMeshSnapshot.ts b/desktop/src/features/mesh-compute/hooks/useMeshSnapshot.ts new file mode 100644 index 000000000..85de3d794 --- /dev/null +++ b/desktop/src/features/mesh-compute/hooks/useMeshSnapshot.ts @@ -0,0 +1,62 @@ +import * as React from "react"; + +import { meshSnapshot } from "@/shared/api/tauriMesh"; +import type { MeshSnapshot } from "@/shared/api/tauriMesh"; + +/** + * Poll the community shared-compute snapshot. + * + * Deliberately slow compared to `useMeshNodeStatus` (750ms/4000ms): that hook + * watches a LOCAL lifecycle that must feel responsive, while this one reads + * RELAY state that only changes as fast as the 45s member heartbeat, with a + * 120s freshness window. Polling faster would just add relay queries without + * adding information. + * + * An empty mesh is not an error — the snapshot carries its own `reason`. `error` + * here means the query itself failed (offline relay, stub build). + */ +const SNAPSHOT_POLL_INTERVAL_MS = 30_000; + +export function useMeshSnapshot(options?: { refreshKey?: number }): { + snapshot: MeshSnapshot | null; + error: string | null; + refresh: () => void; +} { + const [snapshot, setSnapshot] = React.useState(null); + const [error, setError] = React.useState(null); + const refreshKey = options?.refreshKey ?? 0; + + // Guards against a slow in-flight response overwriting a newer one. + const requestSeq = React.useRef(0); + + const fetchOnce = React.useCallback(() => { + const seq = ++requestSeq.current; + (async () => { + try { + const value = await meshSnapshot(); + if (seq === requestSeq.current) { + setSnapshot(value); + setError(null); + } + } catch (err) { + if (seq === requestSeq.current) { + setError(err instanceof Error ? err.message : String(err)); + } + } + })(); + }, []); + + // Re-fetch immediately when the local node transitions (refreshKey), so + // turning sharing on updates the community view without waiting for a poll. + // biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey is an intentional trigger, not a value read in the body — bumping it re-queries the community snapshot right after a local node transition instead of waiting out the 30s poll + React.useEffect(() => { + fetchOnce(); + }, [fetchOnce, refreshKey]); + + React.useEffect(() => { + const handle = window.setInterval(fetchOnce, SNAPSHOT_POLL_INTERVAL_MS); + return () => window.clearInterval(handle); + }, [fetchOnce]); + + return { snapshot, error, refresh: fetchOnce }; +} diff --git a/desktop/src/features/mesh-compute/meshCardModel.test.mjs b/desktop/src/features/mesh-compute/meshCardModel.test.mjs new file mode 100644 index 000000000..2ad7aadbd --- /dev/null +++ b/desktop/src/features/mesh-compute/meshCardModel.test.mjs @@ -0,0 +1,274 @@ +/** + * Contract tests for the sidebar shared-compute card model. + * + * The card's whole job is to make "am I giving compute / taking compute / + * neither" unmistakable, so these tests pin the distinctions that are easy to + * regress: + * + * - consuming must never render as sharing (both report state:"running") + * - unknown capacity must never print "0 GB" + * - a serve node with no advertised model is warming up, not serving + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + describeMeshCapacity, + describeReadyModels, + deriveMeshCardModel, + formatCapacityGb, + shortModelLabel, +} from "./meshCardModel.ts"; + +const OFF_TOGGLE = { + isSharing: false, + isConsuming: false, + slotOccupied: false, +}; +const SHARING_TOGGLE = { + isSharing: true, + isConsuming: false, + slotOccupied: true, +}; +const CONSUMING_TOGGLE = { + isSharing: false, + isConsuming: true, + slotOccupied: true, +}; + +function device(overrides = {}) { + return { + deviceId: "endpoint-1", + label: "Studio", + capacityGb: 36, + models: ["unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M"], + state: "serving", + isSelf: false, + ...overrides, + }; +} + +function snapshot(overrides = {}) { + return { + sharingDeviceCount: 1, + sharedCapacityGb: 36, + models: ["unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M"], + devices: [device()], + includesSelf: false, + reason: null, + ...overrides, + }; +} + +function derive(overrides = {}) { + return deriveMeshCardModel({ + snapshot: snapshot(), + status: null, + toggle: OFF_TOGGLE, + pendingAction: null, + canShare: true, + activeConsumers: false, + ...overrides, + }); +} + +test("capacity headline counts devices and sums reported memory", () => { + assert.equal( + describeMeshCapacity( + snapshot({ sharingDeviceCount: 3, sharedCapacityGb: 42.3 }), + ), + "3 devices sharing · 42 GB", + ); + assert.equal( + describeMeshCapacity( + snapshot({ sharingDeviceCount: 1, sharedCapacityGb: 18 }), + ), + "1 device sharing · 18 GB", + ); +}); + +test("unknown capacity degrades to a device count, never 0 GB", () => { + const text = describeMeshCapacity( + snapshot({ sharingDeviceCount: 3, sharedCapacityGb: null }), + ); + assert.equal(text, "3 devices sharing"); + assert.ok(!text.includes("0 GB"), "must not claim zero capacity"); +}); + +test("an empty mesh reads as an honest empty state", () => { + assert.equal( + describeMeshCapacity( + snapshot({ sharingDeviceCount: 0, sharedCapacityGb: null }), + ), + "No compute shared yet", + ); + assert.equal(describeMeshCapacity(null), "No compute shared yet"); +}); + +test("capacity formatting keeps small figures meaningful", () => { + assert.equal(formatCapacityGb(42.3), "42 GB"); + assert.equal(formatCapacityGb(4.62), "4.6 GB"); +}); + +test("model labels shorten to fit a narrow sidebar", () => { + assert.equal( + shortModelLabel("unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M"), + "Gemma 4 26B A4B", + ); + assert.equal( + shortModelLabel("unsloth/gemma-4-E4B-it-GGUF:Q4_K_M"), + "Gemma 4 E4B", + ); +}); + +test("ready models collapse to a count past one", () => { + assert.equal(describeReadyModels(snapshot()), "Gemma 4 26B A4B ready"); + assert.equal( + describeReadyModels(snapshot({ models: ["a", "b"] })), + "2 models ready", + ); + assert.equal(describeReadyModels(snapshot({ models: [] })), null); +}); + +test("consuming never renders as sharing", () => { + const model = derive({ + toggle: CONSUMING_TOGGLE, + snapshot: snapshot({ devices: [device({ label: "Mac Mini" })] }), + }); + assert.equal(model.tone, "consuming"); + assert.equal(model.switchOn, false, "the Share switch must stay off"); + assert.match(model.headline, /Using shared compute/); + assert.match(model.detail, /Mac Mini/); + // A consuming client may be replaced by a serve runtime, so the switch stays + // usable rather than locked. + assert.equal(model.switchDisabled, false); +}); + +test("sharing states name the model and the community capacity", () => { + const model = derive({ + toggle: SHARING_TOGGLE, + status: { + state: "running", + mode: "serve", + health: { status: "ok", reason: null }, + modelId: "unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M", + modelName: null, + apiBaseUrl: null, + consoleUrl: null, + }, + snapshot: snapshot({ + devices: [device({ isSelf: true })], + includesSelf: true, + }), + }); + assert.equal(model.tone, "sharing"); + assert.equal(model.switchOn, true); + assert.match(model.detail, /Gemma 4 26B A4B/); +}); + +test("active consumers are called out in the headline", () => { + const model = derive({ + toggle: SHARING_TOGGLE, + status: { + state: "running", + mode: "serve", + health: { status: "ok", reason: null }, + modelId: "m", + modelName: null, + apiBaseUrl: null, + consoleUrl: null, + }, + activeConsumers: true, + }); + assert.match(model.headline, /in use now/); +}); + +test("a serve node with no advertised model is warming up, not serving", () => { + const model = derive({ + toggle: SHARING_TOGGLE, + status: { + state: "running", + mode: "serve", + health: { status: "ok", reason: null }, + modelId: "m", + modelName: null, + apiBaseUrl: null, + consoleUrl: null, + }, + snapshot: snapshot({ + sharingDeviceCount: 0, + devices: [device({ isSelf: true, state: "loading", models: [] })], + }), + }); + assert.equal(model.tone, "pending"); + assert.match(model.headline, /Starting to share/); +}); + +test("a failed runtime surfaces its reason instead of claiming to share", () => { + const model = derive({ + toggle: SHARING_TOGGLE, + status: { + state: "running", + mode: "serve", + health: { status: "degraded", reason: "llama runtime exited" }, + modelId: "m", + modelName: null, + apiBaseUrl: null, + consoleUrl: null, + }, + }); + assert.equal(model.tone, "failed"); + assert.match(model.detail, /llama runtime exited/); +}); + +test("the idle invitation leads with what the community already has", () => { + const model = derive({ + snapshot: snapshot({ sharingDeviceCount: 2, sharedCapacityGb: 54 }), + }); + assert.equal(model.tone, "idle"); + assert.equal(model.headline, "2 devices sharing · 54 GB"); +}); + +test("the switch is disabled until a model can be resolved", () => { + assert.equal(derive({ canShare: false }).switchDisabled, true); + assert.equal(derive({ canShare: true }).switchDisabled, false); +}); + +test("an unknown runtime occupant is not silently replaceable", () => { + const model = derive({ + toggle: { isSharing: false, isConsuming: false, slotOccupied: true }, + }); + assert.equal(model.switchDisabled, true); +}); + +test("a solo sharer gets the waiting hint; a populated mesh does not", () => { + const sharingStatus = { + state: "running", + mode: "serve", + health: { status: "ok", reason: null }, + modelId: "m", + modelName: null, + apiBaseUrl: null, + consoleUrl: null, + }; + const solo = derive({ + toggle: SHARING_TOGGLE, + status: sharingStatus, + snapshot: snapshot({ devices: [device({ isSelf: true })] }), + }); + assert.equal(solo.showSoloHint, true); + + const populated = derive({ + toggle: SHARING_TOGGLE, + status: sharingStatus, + snapshot: snapshot({ + sharingDeviceCount: 2, + devices: [ + device({ isSelf: true }), + device({ deviceId: "e2", label: "Studio 2" }), + ], + }), + }); + assert.equal(populated.showSoloHint, false); +}); diff --git a/desktop/src/features/mesh-compute/meshCardModel.ts b/desktop/src/features/mesh-compute/meshCardModel.ts new file mode 100644 index 000000000..1e30af551 --- /dev/null +++ b/desktop/src/features/mesh-compute/meshCardModel.ts @@ -0,0 +1,243 @@ +import type { + MeshNodeStatus, + MeshSnapshot, + MeshSnapshotDevice, +} from "@/shared/api/tauriMesh"; +import type { MeshShareToggleModel } from "./shareToggleState"; + +/** + * Pure projection for the sidebar shared-compute card. + * + * The card has one job: make it obvious, at a glance, whether **this machine** + * is giving compute, taking compute, or neither — and what the community has + * to offer. Copy lives here (not in the component) so the wording is unit + * tested and cannot drift between states. + * + * Two distinctions this model refuses to blur: + * + * 1. **Sharing vs consuming.** One mesh runtime slot serves both roles and both + * report `state:"running"`, so the role comes from `deriveMeshShareToggle`, + * never from `state` alone. Consuming must never read as sharing. + * 2. **Unknown capacity vs zero capacity.** `sharedCapacityGb: null` means + * nobody reported a figure, so the headline drops the number rather than + * claiming "0 GB". + */ + +/** Which role this machine is playing. Drives the card's accent + icon. */ +export type MeshCardTone = + | "idle" + | "sharing" + | "consuming" + | "pending" + | "failed"; + +export type MeshCardModel = { + tone: MeshCardTone; + /** Primary line — what is happening, from this machine's point of view. */ + headline: string; + /** Secondary line — the community context, or the reason for a problem. */ + detail: string | null; + /** Switch position. Reflects serve-mode occupancy only. */ + switchOn: boolean; + switchDisabled: boolean; + /** Accessible label for the switch, naming the consequence of flipping it. */ + switchLabel: string; + /** Devices for the topology strip, sharing first. */ + devices: MeshSnapshotDevice[]; + /** True when the mesh has exactly one participant (the solo case). */ + isSolo: boolean; + /** Whether to show the "waiting for another device" hint. */ + showSoloHint: boolean; +}; + +/** + * Format shared memory for display. Whole numbers at 10GB+ (a "42 GB" mesh + * reads better than "42.3 GB"); one decimal below that so a single small + * machine still shows a meaningful figure. + */ +export function formatCapacityGb(gb: number): string { + const rounded = gb >= 10 ? Math.round(gb) : Math.round(gb * 10) / 10; + return `${rounded} GB`; +} + +function plural(n: number, one: string, many = `${one}s`): string { + return n === 1 ? one : many; +} + +/** + * The community headline: how much compute is actually available right now. + * + * Counts only devices advertising a routable model — the same standard routing + * uses — so the number never promises capacity that cannot be reached. Never + * says "total": the underlying query is capped at 100 members. + */ +export function describeMeshCapacity(snapshot: MeshSnapshot | null): string { + if (!snapshot || snapshot.sharingDeviceCount === 0) { + return "No compute shared yet"; + } + const { sharingDeviceCount: count, sharedCapacityGb: gb } = snapshot; + const devices = `${count} ${plural(count, "device")} sharing`; + // Unknown capacity degrades to the device count rather than printing 0 GB. + return gb === null ? devices : `${devices} · ${formatCapacityGb(gb)}`; +} + +/** Short label for what is ready to run, or null when nothing is. */ +export function describeReadyModels( + snapshot: MeshSnapshot | null, +): string | null { + const models = snapshot?.models ?? []; + if (models.length === 0) { + return null; + } + if (models.length === 1) { + return `${shortModelLabel(models[0])} ready`; + } + return `${models.length} models ready`; +} + +/** + * Trim a model reference down to something that fits a 256px sidebar. + * `unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M` → `Gemma 4 26B A4B`. + */ +export function shortModelLabel(modelRef: string): string { + const basename = modelRef.split("/").at(-1) ?? modelRef; + const withoutQuant = basename.replace(/[:-](?:GGUF|UD)?[:-]?Q\d.*$/i, ""); + const cleaned = withoutQuant + .replace(/-GGUF.*$/i, "") + .replace(/-it$/i, "") + .replaceAll("-", " ") + .trim(); + if (cleaned === "") { + return basename; + } + return cleaned.charAt(0).toUpperCase() + cleaned.slice(1); +} + +/** + * Project everything the card needs into one view model. + * + * Total: every input may be null (nothing fetched yet) and the result is still + * renderable. + */ +export function deriveMeshCardModel({ + snapshot, + status, + toggle, + pendingAction, + canShare, + activeConsumers, +}: { + snapshot: MeshSnapshot | null; + status: MeshNodeStatus | null; + toggle: MeshShareToggleModel; + pendingAction: "start" | "stop" | null; + /** False when no model can be resolved yet (catalog still loading). */ + canShare: boolean; + /** Another member is actively using this machine's compute right now. */ + activeConsumers: boolean; +}): MeshCardModel { + const devices = snapshot?.devices ?? []; + const participantCount = devices.length; + const capacity = describeMeshCapacity(snapshot); + const ready = describeReadyModels(snapshot); + const isSolo = participantCount === 1; + + const base = { + devices, + isSolo, + switchOn: toggle.isSharing, + // A serve node can always be stopped. Anything else waits for a resolvable + // model, and never lets an unknown occupant be replaced silently. + switchDisabled: + pendingAction !== null || + (toggle.isSharing + ? false + : toggle.slotOccupied && !toggle.isConsuming + ? true + : !canShare), + switchLabel: toggle.isSharing + ? "Stop sharing this computer's compute" + : "Share this computer's compute", + }; + + if (pendingAction === "start") { + return { + ...base, + tone: "pending", + headline: "Starting to share…", + detail: "Loading the model. This can take a moment.", + showSoloHint: false, + }; + } + if (pendingAction === "stop") { + return { + ...base, + tone: "pending", + headline: "Stopping…", + detail: null, + showSoloHint: false, + }; + } + + // Consuming: this machine is TAKING compute, not giving it. Say so plainly — + // the switch is off here and that must not read as "nothing is happening". + if (toggle.isConsuming) { + const peer = devices.find( + (device) => !device.isSelf && device.state === "serving", + ); + return { + ...base, + tone: "consuming", + headline: "Using shared compute", + detail: peer + ? `Running on ${peer.label}. Turn on to share this computer too.` + : "Running on another member's computer.", + showSoloHint: false, + }; + } + + if (toggle.isSharing) { + const health = status?.health; + if (health && health.status !== "ok") { + return { + ...base, + tone: "failed", + headline: "Sharing needs attention", + detail: + health.reason ?? "The shared compute runtime reported a problem.", + showSoloHint: false, + }; + } + // A serve node with no advertised model yet is warming up, not serving. + const selfDevice = devices.find((device) => device.isSelf); + if (selfDevice?.state === "loading" || status?.state === "starting") { + return { + ...base, + tone: "pending", + headline: "Starting to share…", + detail: "Loading the model. This can take a moment.", + showSoloHint: false, + }; + } + const model = status?.modelName ?? status?.modelId; + return { + ...base, + tone: "sharing", + headline: activeConsumers + ? "Sharing · in use now" + : "Sharing this computer", + detail: model ? `${shortModelLabel(model)} · ${capacity}` : capacity, + showSoloHint: isSolo, + }; + } + + // Idle: the invitation. Lead with what the community already has, because + // that is the reason to join — not with a description of the mechanism. + return { + ...base, + tone: "idle", + headline: capacity, + detail: ready ?? "Turn on to let members run agents on this computer.", + showSoloHint: false, + }; +} diff --git a/desktop/src/features/mesh-compute/ui/MeshTopologyStrip.tsx b/desktop/src/features/mesh-compute/ui/MeshTopologyStrip.tsx new file mode 100644 index 000000000..b250a1764 --- /dev/null +++ b/desktop/src/features/mesh-compute/ui/MeshTopologyStrip.tsx @@ -0,0 +1,125 @@ +import { motion, useReducedMotion } from "motion/react"; + +import { cn } from "@/shared/lib/cn"; +import type { MeshSnapshotDevice } from "@/shared/api/tauriMesh"; +import { formatCapacityGb, shortModelLabel } from "../meshCardModel"; + +/** + * Compact contributor strip for the sidebar card. + * + * Deliberately NOT a network topology graph. The relay tells us which devices + * are participating and what each advertises, but the published activity + * counters are **node-local, not edge-attributed** — so drawing traffic moving + * along a particular link would be fiction. Each device gets a dot; a dot + * pulses only when that device is actually serving. + * + * Honest at every scale, including the common one: + * n=1 → a single dot plus "Waiting for another device" + * n=2+ → one dot per device, yours marked + * many → the first few dots plus a "+N" remainder + */ + +const MAX_VISIBLE_DOTS = 8; + +const STATE_DOT_CLASS: Record = { + serving: "bg-emerald-500 dark:bg-emerald-400", + loading: "bg-amber-500 dark:bg-amber-400", + standby: "bg-muted-foreground/40", + consuming: "bg-sky-500 dark:bg-sky-400", +}; + +function deviceTitle(device: MeshSnapshotDevice): string { + const parts = [ + device.isSelf ? `${device.label} (this computer)` : device.label, + ]; + if (device.capacityGb !== null) { + parts.push(formatCapacityGb(device.capacityGb)); + } + if (device.models.length > 0) { + parts.push(shortModelLabel(device.models[0])); + } else if (device.state === "standby") { + parts.push("standing by"); + } else if (device.state === "consuming") { + parts.push("using shared compute"); + } + return parts.join(" · "); +} + +export function MeshTopologyStrip({ + devices, + showSoloHint, +}: { + devices: MeshSnapshotDevice[]; + showSoloHint: boolean; +}) { + const shouldReduceMotion = useReducedMotion(); + + if (devices.length === 0) { + return null; + } + + const visible = devices.slice(0, MAX_VISIBLE_DOTS); + const remainder = devices.length - visible.length; + + return ( +
+
+ {visible.map((device, index) => { + // Pulse only for devices genuinely serving. Reduced motion gets a + // static dot rather than a slower animation. + const isPulsing = device.state === "serving" && !shouldReduceMotion; + return ( + + {isPulsing ? ( + + ) : null} + + + ); + })} + {remainder > 0 ? ( + + +{remainder} + + ) : null} +
+ + {showSoloHint ? ( + + Waiting for another device + + ) : null} +
+ ); +} diff --git a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx new file mode 100644 index 000000000..3698c3259 --- /dev/null +++ b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx @@ -0,0 +1,235 @@ +import * as React from "react"; +import { Cpu, Loader2, TriangleAlert, Download, Share2 } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; + +import { Switch } from "@/shared/ui/switch"; +import { cn } from "@/shared/lib/cn"; +import { meshStartNode, meshStopNode } from "@/shared/api/tauriMesh"; +import type { MeshModelCatalog } from "@/shared/api/tauriMesh"; +import { meshModelCatalog } from "@/shared/api/tauriMesh"; + +import { useMeshNodeStatus } from "../hooks/useMeshNodeStatus"; +import { useMeshServingUsage } from "../hooks/useMeshServingUsage"; +import { useMeshSnapshot } from "../hooks/useMeshSnapshot"; +import { deriveMeshShareToggle } from "../shareToggleState"; +import { deriveMeshCardModel, type MeshCardTone } from "../meshCardModel"; +import { MeshTopologyStrip } from "./MeshTopologyStrip"; + +/** + * Sidebar shared-compute card. + * + * Answers three questions at a glance, which is the whole reason it exists: + * - Am I sharing this computer? → switch on, "Sharing this computer" + * - Am I using someone else's? → switch off, "Using shared compute" + * - What does the community have? → "3 devices sharing · 42 GB" + * + * Deliberately NOT a model picker. Turning it on shares the hardware-appropriate + * curated recommendation (`catalog.recommended`); choosing a specific model, + * capping memory, and future split controls stay in Settings → Compute. + * + * Own file rather than living in `AppSidebar.tsx`, which sits at 999/1000 lines + * against `desktop/scripts/check-file-sizes.mjs`. + */ + +const TONE_ICON: Record = { + idle: , + sharing: , + consuming: , + pending: , + failed: , +}; + +// Accent per role. Sharing (giving) and consuming (taking) must be +// distinguishable at a glance, not just by wording. +const TONE_ICON_CLASS: Record = { + idle: "text-muted-foreground", + sharing: "text-emerald-600 dark:text-emerald-400", + consuming: "text-sky-600 dark:text-sky-400", + pending: "text-muted-foreground", + failed: "text-destructive", +}; + +const TONE_RING_CLASS: Record = { + idle: "border-border/70 bg-background/70 dark:bg-background/50", + sharing: + "border-emerald-500/30 bg-emerald-500/[0.06] dark:border-emerald-400/25 dark:bg-emerald-400/[0.07]", + consuming: + "border-sky-500/30 bg-sky-500/[0.06] dark:border-sky-400/25 dark:bg-sky-400/[0.07]", + pending: "border-border/70 bg-background/70 dark:bg-background/50", + failed: "border-destructive/40 bg-destructive/[0.06]", +}; + +export function SidebarMeshComputeCard({ + className, + onOpenComputeSettings, +}: { + className?: string; + onOpenComputeSettings?: () => void; +}) { + const shouldReduceMotion = useReducedMotion(); + const { status, refresh: refreshStatus } = useMeshNodeStatus(); + const [catalog, setCatalog] = React.useState(null); + const [pendingAction, setPendingAction] = React.useState< + "start" | "stop" | null + >(null); + const [actionError, setActionError] = React.useState(null); + // Bumping this re-queries the community snapshot right after a local + // transition, instead of waiting out the 30s poll. + const [snapshotNonce, setSnapshotNonce] = React.useState(0); + + const { snapshot, error: snapshotError } = useMeshSnapshot({ + refreshKey: snapshotNonce, + }); + + const toggle = deriveMeshShareToggle(status); + const usage = useMeshServingUsage(toggle.isSharing); + const activeConsumers = + (usage?.remoteAttempts ?? 0) > 0 || (usage?.endpointAttempts ?? 0) > 0; + + // One-shot catalog fetch: the card needs the hardware-appropriate + // recommendation so the switch can start sharing without a model picker. + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const value = await meshModelCatalog(); + if (!cancelled) setCatalog(value); + } catch { + // Non-fatal: the switch stays disabled and Settings still works. + } + })(); + return () => { + cancelled = true; + }; + }, []); + + // Re-check the community view whenever the local lifecycle settles. + // biome-ignore lint/correctness/useExhaustiveDependencies: the local lifecycle fields are intentional triggers, not values read in the body — a settled start/stop should refresh the community view immediately + React.useEffect(() => { + setSnapshotNonce((current) => current + 1); + }, [status?.state, status?.mode]); + + const recommended = catalog?.recommended ?? null; + const model = deriveMeshCardModel({ + snapshot, + status, + toggle, + pendingAction, + canShare: Boolean(recommended), + activeConsumers, + }); + + async function handleToggle(next: boolean) { + // Never let this switch tear down a consume session. The switch is already + // disabled while consuming, but status can be stale between polls. + if (!next && !toggle.isSharing) { + return; + } + setActionError(null); + setPendingAction(next ? "start" : "stop"); + try { + if (next) { + if (!recommended) { + throw new Error("No suitable model for this computer yet"); + } + await meshStartNode({ mode: "serve", modelId: recommended }); + } else { + await meshStopNode(); + } + refreshStatus(); + setSnapshotNonce((current) => current + 1); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } finally { + setPendingAction(null); + } + } + + // A transport failure is worth showing; an empty mesh is not an error and is + // already expressed by the headline ("No compute shared yet"). + const errorText = actionError ?? snapshotError; + + return ( + +
+
+ + {TONE_ICON[model.tone]} + + +
+

+ {model.headline} +

+ {model.detail ? ( +

+ {model.detail} +

+ ) : null} +
+ + +
+ + + + {errorText ? ( +

+ {errorText} +

+ ) : null} + + {onOpenComputeSettings ? ( + + ) : null} +
+
+ ); +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index d4ca4f286..ccd0e169f 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -50,14 +50,13 @@ import type { CollapsibleSidebarGroup, CreateChannelKind, } from "@/features/sidebar/ui/AppSidebar.types"; -import { SidebarRelayConnectionCard } from "@/features/sidebar/ui/SidebarRelayConnectionCard"; +import { SidebarFooterNotices } from "@/features/sidebar/ui/SidebarFooterNotices"; import type { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; import { SidebarLoadingContent, useSidebarLoadingShape, } from "@/features/sidebar/ui/sidebarLoadingSkeleton"; import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen"; -import { SidebarUpdateCard } from "@/features/settings/SidebarUpdateCard"; import { useUpdaterContext } from "@/features/settings/hooks/UpdaterProvider"; import { shouldShowSidebarUpdateCard } from "@/features/settings/sidebarUpdateCardVisibility"; import type { SettingsSection } from "@/features/settings/ui/SettingsPanels"; @@ -869,26 +868,17 @@ export function AppSidebar({ ) : null} - {relayConnectionCard.showSidebarRelayConnectionCard && - (isMobile ? openMobile : sidebarOpen) ? ( - - ) : null} - {showSidebarUpdateCard ? ( -
- setIsSidebarUpdateCardDismissed(true)} - /> -
- ) : null} + setIsSidebarUpdateCardDismissed(true)} + onOpenComputeSettings={() => onSelectSettings("compute")} + onReconnectRelay={relayConnectionCard.onReconnectRelay} + relayConnectionCard={relayConnectionCard} + showUpdateCard={showSidebarUpdateCard} + /> void; + onDismissUpdateCard: () => void; + onOpenComputeSettings: () => void; + onReconnectRelay: () => void; + relayConnectionCard: ReturnType; + showUpdateCard: boolean; +}) { + return ( + <> + {relayConnectionCard.showSidebarRelayConnectionCard && expanded ? ( + + ) : null} + {showUpdateCard ? ( +
+ +
+ ) : null} + {/* + Yield to the cards above: a relay problem or a pending update is more + urgent than an invitation to share compute. + */} + {relayConnectionCard.showSidebarRelayConnectionCard || + showUpdateCard ? null : ( + + )} + + ); +} diff --git a/desktop/src/shared/api/tauriMesh.ts b/desktop/src/shared/api/tauriMesh.ts index 8a0153123..444611a07 100644 --- a/desktop/src/shared/api/tauriMesh.ts +++ b/desktop/src/shared/api/tauriMesh.ts @@ -112,3 +112,37 @@ export type MeshModelCatalog = { export async function meshModelCatalog(): Promise { return await invokeTauri("mesh_model_catalog"); } + +/** Coarse per-device state. See Rust `MeshDeviceState`. */ +export type MeshDeviceState = "serving" | "loading" | "standby" | "consuming"; + +export type MeshSnapshotDevice = { + deviceId: string | null; + label: string; + /** Shared AI memory in GB, honoring that member's own cap. */ + capacityGb: number | null; + models: string[]; + state: MeshDeviceState; + isSelf: boolean; +}; + +/** + * Community-wide shared-compute snapshot: who is sharing right now, how much + * they are sharing, and what is ready to use. + * + * Presentation only — routing still goes through the availability path. An + * empty mesh arrives as `reason` with zero counts, never as an error. + */ +export type MeshSnapshot = { + sharingDeviceCount: number; + /** `null` when no sharing device reported a figure — render the count only. */ + sharedCapacityGb: number | null; + models: string[]; + devices: MeshSnapshotDevice[]; + includesSelf: boolean; + reason: string | null; +}; + +export async function meshSnapshot(): Promise { + return await invokeTauri("mesh_snapshot"); +}