mirror of
https://github.com/block/buzz.git
synced 2026-11-05 00:41:28 +00:00
Simplify shared compute settings experience
Signed-off-by: Alessandro Joabar <sandro@squareup.com>
This commit is contained in:
@@ -1,59 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { classifyModelRef } from "./classifyModelRef.ts";
|
||||
|
||||
test("empty string → unknown", () => {
|
||||
assert.deepEqual(classifyModelRef(""), { kind: "unknown" });
|
||||
assert.deepEqual(classifyModelRef(" "), { kind: "unknown" });
|
||||
});
|
||||
|
||||
test("hf:// prefix → huggingface", () => {
|
||||
assert.deepEqual(classifyModelRef("hf://meshllm/qwen3-8b@main"), {
|
||||
kind: "huggingface",
|
||||
ref: "hf://meshllm/qwen3-8b@main",
|
||||
});
|
||||
});
|
||||
|
||||
test("absolute path → local-path", () => {
|
||||
assert.deepEqual(classifyModelRef("/Users/me/models/qwen.gguf"), {
|
||||
kind: "local-path",
|
||||
path: "/Users/me/models/qwen.gguf",
|
||||
});
|
||||
});
|
||||
|
||||
test("relative path with ./ → local-path", () => {
|
||||
assert.deepEqual(classifyModelRef("./models/qwen.gguf"), {
|
||||
kind: "local-path",
|
||||
path: "./models/qwen.gguf",
|
||||
});
|
||||
});
|
||||
|
||||
test("home shortcut → local-path", () => {
|
||||
assert.deepEqual(classifyModelRef("~/models/qwen.gguf"), {
|
||||
kind: "local-path",
|
||||
path: "~/models/qwen.gguf",
|
||||
});
|
||||
});
|
||||
|
||||
test(".gguf extension without path prefix → local-path", () => {
|
||||
// Bare filename ending in .gguf — user clearly means a file.
|
||||
assert.deepEqual(classifyModelRef("my-model.gguf"), {
|
||||
kind: "local-path",
|
||||
path: "my-model.gguf",
|
||||
});
|
||||
});
|
||||
|
||||
test("plain name → catalog", () => {
|
||||
assert.deepEqual(classifyModelRef("Qwen3-8B-Q4_K_M"), {
|
||||
kind: "catalog",
|
||||
name: "Qwen3-8B-Q4_K_M",
|
||||
});
|
||||
});
|
||||
|
||||
test("trims whitespace before classifying", () => {
|
||||
assert.deepEqual(classifyModelRef(" Qwen3-8B-Q4_K_M "), {
|
||||
kind: "catalog",
|
||||
name: "Qwen3-8B-Q4_K_M",
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Classification of a free-text model ref entered into the serve card.
|
||||
* Mirrors mesh's own resolution categories for input validation.
|
||||
*/
|
||||
export type ModelRefKind =
|
||||
| { kind: "catalog"; name: string }
|
||||
| { kind: "huggingface"; ref: string }
|
||||
| { kind: "local-path"; path: string }
|
||||
| { kind: "unknown" };
|
||||
|
||||
/**
|
||||
* Classify a model-ref string the way mesh-llm's runtime does:
|
||||
* - `hf://…` → HuggingFace ref
|
||||
* - starts with `/` or `./` or `~`, OR ends with `.gguf` → local file
|
||||
* - otherwise non-empty → catalog name
|
||||
* - empty/whitespace → unknown
|
||||
*
|
||||
* Source: mesh runtime/mod.rs:3390 ("local file, catalog name, or HuggingFace URL").
|
||||
*
|
||||
* This is validation-only — canonical resolution happens server-side via
|
||||
* `mesh_start_node`.
|
||||
*/
|
||||
export function classifyModelRef(raw: string): ModelRefKind {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return { kind: "unknown" };
|
||||
}
|
||||
if (trimmed.startsWith("hf://")) {
|
||||
return { kind: "huggingface", ref: trimmed };
|
||||
}
|
||||
// Local path heuristics. Conservative: only mark as path when there are
|
||||
// unambiguous signals (leading separator, home shortcut, .gguf extension).
|
||||
const looksLikePath =
|
||||
trimmed.startsWith("/") ||
|
||||
trimmed.startsWith("./") ||
|
||||
trimmed.startsWith("../") ||
|
||||
trimmed.startsWith("~") ||
|
||||
trimmed.toLowerCase().endsWith(".gguf");
|
||||
if (looksLikePath) {
|
||||
return { kind: "local-path", path: trimmed };
|
||||
}
|
||||
return { kind: "catalog", name: trimmed };
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
describeExperimentalEmpty,
|
||||
describeSplitEntry,
|
||||
deriveMeshExperimentalModel,
|
||||
} from "./experimentalModel.ts";
|
||||
|
||||
function entry(overrides = {}) {
|
||||
return {
|
||||
name: "Model-Q4",
|
||||
sizeLabel: "20GB",
|
||||
sizeGb: 20,
|
||||
layerCount: 64,
|
||||
packageRepo: "meshllm/Model-layers",
|
||||
fitsLocally: false,
|
||||
description: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function catalog(overrides = {}) {
|
||||
return { entries: [], vramGb: 96, stale: false, ...overrides };
|
||||
}
|
||||
|
||||
test("only models beyond this machine are experimental", () => {
|
||||
// A split-capable model that fits locally is not an experimental choice --
|
||||
// Qwen3-8B is 5GB and split-capable, and mesh-llm returns SingleNodeFit for
|
||||
// it before ever considering a split. Offering it here would tell someone a
|
||||
// 5GB model needs a cluster.
|
||||
const model = deriveMeshExperimentalModel(
|
||||
catalog({
|
||||
entries: [
|
||||
entry({ name: "Fits", sizeGb: 5, fitsLocally: true }),
|
||||
entry({ name: "Beyond", sizeGb: 300, fitsLocally: false }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.equal(model.options.length, 1);
|
||||
assert.equal(model.options[0].entry.name, "Beyond");
|
||||
});
|
||||
|
||||
test("a machine that fits everything is told so, not shown a failure", () => {
|
||||
// The good outcome must not read like an error.
|
||||
const model = deriveMeshExperimentalModel(
|
||||
catalog({ entries: [entry({ fitsLocally: true })] }),
|
||||
);
|
||||
assert.equal(model.options.length, 0);
|
||||
assert.equal(model.emptyReason, "machineFitsEverything");
|
||||
assert.match(describeExperimentalEmpty(model.emptyReason), /on its own/);
|
||||
});
|
||||
|
||||
test("empty has distinct causes that never read alike", () => {
|
||||
// "Could not read the catalog", "the catalog has no packages", and "this
|
||||
// machine is big enough" are three different facts.
|
||||
assert.equal(deriveMeshExperimentalModel(undefined).emptyReason, "loading");
|
||||
assert.equal(
|
||||
deriveMeshExperimentalModel(null).emptyReason,
|
||||
"catalogUnavailable",
|
||||
);
|
||||
assert.equal(
|
||||
deriveMeshExperimentalModel(catalog()).emptyReason,
|
||||
"noPackages",
|
||||
);
|
||||
assert.equal(
|
||||
deriveMeshExperimentalModel(catalog({ stale: true })).emptyReason,
|
||||
"catalogUnavailable",
|
||||
);
|
||||
|
||||
const messages = new Set(
|
||||
[
|
||||
"loading",
|
||||
"catalogUnavailable",
|
||||
"noPackages",
|
||||
"machineFitsEverything",
|
||||
].map(describeExperimentalEmpty),
|
||||
);
|
||||
assert.equal(messages.size, 4, "each cause needs its own sentence");
|
||||
});
|
||||
|
||||
test("detail states size and layers, and never invents either", () => {
|
||||
assert.equal(describeSplitEntry(entry()), "20 GB · 64 layers");
|
||||
// Two upstream entries publish "30 layers" where bytes belong. Layers are the
|
||||
// unit actually distributed, so they stand alone; a size is never synthesized
|
||||
// from a layer count, because they are not convertible.
|
||||
assert.equal(
|
||||
describeSplitEntry(
|
||||
entry({ sizeGb: null, sizeLabel: "30 layers", layerCount: 30 }),
|
||||
),
|
||||
"30 layers",
|
||||
);
|
||||
// Neither figure published: fall back to the raw label rather than inventing.
|
||||
assert.equal(
|
||||
describeSplitEntry(
|
||||
entry({ sizeGb: null, sizeLabel: "unknown", layerCount: null }),
|
||||
),
|
||||
"unknown",
|
||||
);
|
||||
});
|
||||
|
||||
test("a large model with no published size still reaches the list", () => {
|
||||
// Unknown size is reported as not fitting, which is the honest direction:
|
||||
// offering the split path is recoverable, promising a local start is not.
|
||||
const model = deriveMeshExperimentalModel(
|
||||
catalog({
|
||||
entries: [
|
||||
entry({
|
||||
sizeGb: null,
|
||||
sizeLabel: "61 layers",
|
||||
layerCount: 61,
|
||||
fitsLocally: false,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.equal(model.options.length, 1);
|
||||
assert.equal(model.options[0].detail, "61 layers");
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
import type {
|
||||
MeshExperimentalCatalog,
|
||||
MeshSplitEntry,
|
||||
} from "@/shared/api/tauriMesh";
|
||||
|
||||
/**
|
||||
* Pure projection for the experimental (split) tier of Advanced settings.
|
||||
*
|
||||
* ## Why "beyond this machine" is the partition
|
||||
*
|
||||
* Splittability alone is not the interesting fact. A model can carry a layer
|
||||
* package and still fit on one machine — `Qwen3-8B-Q4_K_M` is 5 GB and
|
||||
* split-capable — and mesh-llm agrees about precedence:
|
||||
* `evaluate_model_target_capacity` returns `SingleNodeFit` first and only falls
|
||||
* back to `SplitCandidate` when no single node can hold the model.
|
||||
*
|
||||
* So offering every split-capable model here would tell someone a 5 GB model
|
||||
* needs a cluster. The experimental tier is the intersection: split-capable
|
||||
* **and** beyond local memory. Anything that fits belongs in the ordinary
|
||||
* picker, where it starts immediately.
|
||||
*
|
||||
* ## Why the consequence is stated before selection, not after
|
||||
*
|
||||
* A collective model does not load on demand. mesh-llm gathers a cohort over
|
||||
* gossip and holds a settle barrier — at least two participants, membership
|
||||
* unchanged for a dwell period — then elects a coordinator deterministically by
|
||||
* VRAM. Until that forms, a node named for a collective model sits waiting.
|
||||
*
|
||||
* Nothing about that looks different from a slow download, so a person who was
|
||||
* not told would reasonably read it as a hang and file a bug. The disclosure is
|
||||
* therefore part of the option, not a footnote after the fact.
|
||||
*/
|
||||
|
||||
export type MeshExperimentalOption = {
|
||||
entry: MeshSplitEntry;
|
||||
/** e.g. "134 GB · 80 layers", or "61 layers" when no byte size is published. */
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type MeshExperimentalModel = {
|
||||
/** Split-capable models that exceed this machine — the experimental offer. */
|
||||
options: MeshExperimentalOption[];
|
||||
/**
|
||||
* Why the list is empty, or null when it has entries.
|
||||
*
|
||||
* An empty list has three quite different causes and they must not read
|
||||
* alike: the catalog could not be read, it holds no packages at all, or this
|
||||
* machine is simply large enough that nothing needs splitting. The third is
|
||||
* good news and must not look like a failure.
|
||||
*/
|
||||
emptyReason:
|
||||
| "loading"
|
||||
| "catalogUnavailable"
|
||||
| "noPackages"
|
||||
| "machineFitsEverything"
|
||||
| null;
|
||||
};
|
||||
|
||||
/** Bytes → GB label, matching the rest of the mesh surfaces. */
|
||||
function formatGb(gb: number): string {
|
||||
return `${gb >= 10 ? Math.round(gb) : Math.round(gb * 10) / 10} GB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One line describing reach: size where known, layers always.
|
||||
*
|
||||
* Layers are the unit actually distributed, so they are the honest figure when
|
||||
* the catalog publishes a layer count in place of bytes (two upstream entries
|
||||
* do). Never synthesizes a size from a layer count — they are not convertible.
|
||||
*/
|
||||
export function describeSplitEntry(entry: MeshSplitEntry): string {
|
||||
const parts: string[] = [];
|
||||
if (entry.sizeGb !== null) {
|
||||
parts.push(formatGb(entry.sizeGb));
|
||||
}
|
||||
if (entry.layerCount !== null) {
|
||||
parts.push(`${entry.layerCount} layers`);
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
// Neither figure published. Say nothing rather than invent a number.
|
||||
return entry.sizeLabel ?? "";
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export function deriveMeshExperimentalModel(
|
||||
catalog: MeshExperimentalCatalog | null | undefined,
|
||||
): MeshExperimentalModel {
|
||||
if (catalog === undefined) {
|
||||
return { options: [], emptyReason: "loading" };
|
||||
}
|
||||
if (catalog === null) {
|
||||
return { options: [], emptyReason: "catalogUnavailable" };
|
||||
}
|
||||
if (catalog.entries.length === 0) {
|
||||
return {
|
||||
options: [],
|
||||
emptyReason: catalog.stale ? "catalogUnavailable" : "noPackages",
|
||||
};
|
||||
}
|
||||
const beyondThisMachine = catalog.entries.filter(
|
||||
(entry) => !entry.fitsLocally,
|
||||
);
|
||||
if (beyondThisMachine.length === 0) {
|
||||
// Every split-capable model fits here. Not a failure — the machine is big
|
||||
// enough that splitting buys nothing.
|
||||
return { options: [], emptyReason: "machineFitsEverything" };
|
||||
}
|
||||
return {
|
||||
options: beyondThisMachine.map((entry) => ({
|
||||
entry,
|
||||
detail: describeSplitEntry(entry),
|
||||
})),
|
||||
emptyReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Copy for an empty experimental tier, keyed on why. */
|
||||
export function describeExperimentalEmpty(
|
||||
reason: NonNullable<MeshExperimentalModel["emptyReason"]>,
|
||||
): string {
|
||||
switch (reason) {
|
||||
case "loading":
|
||||
return "Checking for models that can run across machines…";
|
||||
case "catalogUnavailable":
|
||||
return "Can't reach the model catalog right now.";
|
||||
case "noPackages":
|
||||
return "No models in the catalog can run across machines yet.";
|
||||
case "machineFitsEverything":
|
||||
// Deliberately not phrased as an absence: this is the good outcome.
|
||||
return "This computer can run every model in the catalog on its own.";
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { meshExperimentalCatalog } from "@/shared/api/tauriMesh";
|
||||
import type { MeshExperimentalCatalog } from "@/shared/api/tauriMesh";
|
||||
|
||||
/**
|
||||
* Load the split-capable model catalog.
|
||||
*
|
||||
* Fetched once rather than polled: the community catalog is a published dataset
|
||||
* that changes when someone packages a model, not live state. mesh-llm caches it
|
||||
* on disk for 24h, so re-asking on a timer would add nothing.
|
||||
*
|
||||
* `undefined` while in flight and `null` on failure are deliberately distinct —
|
||||
* the experimental tier has to tell "still looking" from "couldn't read the
|
||||
* catalog" from "the catalog genuinely has no packages", and all three would
|
||||
* otherwise render as an unexplained empty list.
|
||||
*
|
||||
* Requires no running node: you have to choose what to share before you start
|
||||
* sharing it.
|
||||
*/
|
||||
export function useMeshExperimentalCatalog(enabled: boolean): {
|
||||
catalog: MeshExperimentalCatalog | null | undefined;
|
||||
error: string | null;
|
||||
} {
|
||||
const [catalog, setCatalog] = React.useState<
|
||||
MeshExperimentalCatalog | null | undefined
|
||||
>(undefined);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const loaded = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Only when the section is actually opened: the first call can download the
|
||||
// catalog, which is not work to do for a screen nobody expanded.
|
||||
if (!enabled || loaded.current) return;
|
||||
loaded.current = true;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await meshExperimentalCatalog();
|
||||
if (!cancelled) {
|
||||
setCatalog(next);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
// null, not an empty catalog: "could not read" must not masquerade as
|
||||
// "nothing published".
|
||||
setCatalog(null);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
// Allow a retry on reopen — a transient network failure should not
|
||||
// permanently empty the section.
|
||||
loaded.current = false;
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return { catalog, error };
|
||||
}
|
||||
@@ -22,8 +22,8 @@ describe("community compute visual components", () => {
|
||||
);
|
||||
assert.match(html, /community-compute-kpi-members-sharing/);
|
||||
assert.match(html, /community-compute-kpi-contributed-vram/);
|
||||
assert.match(html, /community-compute-kpi-vram-allocated/);
|
||||
assert.match(html, /24 community members/);
|
||||
assert.doesNotMatch(html, /community-compute-kpi-vram-allocated/);
|
||||
assert.match(html, /24 people in this community/);
|
||||
assert.match(html, />2,744 GB</);
|
||||
});
|
||||
|
||||
|
||||
@@ -6,39 +6,33 @@ export type CommunityComputeKpiRowProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** Compact, responsive headline figures for the community compute pool. */
|
||||
/** The two figures that help a member understand and grow the compute commons. */
|
||||
export function CommunityComputeKpiRow({
|
||||
kpis,
|
||||
className,
|
||||
}: CommunityComputeKpiRowProps) {
|
||||
const items = [
|
||||
{
|
||||
label: "Members sharing",
|
||||
label: "People contributing",
|
||||
value: formatCount(kpis.contributorMemberCount),
|
||||
detail: `${formatCount(kpis.memberCount)} community members`,
|
||||
},
|
||||
{
|
||||
label: "Contributed VRAM",
|
||||
value: formatCapacity(kpis.sharedCapacityGb),
|
||||
detail: `${formatCount(kpis.deploymentCount)} models available`,
|
||||
},
|
||||
{
|
||||
label: "VRAM allocated",
|
||||
value:
|
||||
kpis.allocatedPercent === null
|
||||
? "—"
|
||||
: `${Math.round(kpis.allocatedPercent)}%`,
|
||||
detail:
|
||||
kpis.allocatedCapacityGb === null
|
||||
? "Not fully reported"
|
||||
: `${formatCapacity(kpis.allocatedCapacityGb)} hosting models`,
|
||||
kpis.contributorMemberCount === 0
|
||||
? "Be the first contributor"
|
||||
: `${formatCount(kpis.memberCount)} people in this community`,
|
||||
testId: "members-sharing",
|
||||
},
|
||||
{
|
||||
label: "Compute shared",
|
||||
value: formatCapacity(kpis.sharedCapacityGb),
|
||||
detail: "Available to community agents",
|
||||
testId: "contributed-vram",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<dl
|
||||
className={cn(
|
||||
"grid grid-cols-1 overflow-hidden rounded-xl border border-border/70 bg-muted/15 sm:grid-cols-3",
|
||||
"grid grid-cols-1 overflow-hidden rounded-xl border border-border/70 bg-muted/15 sm:grid-cols-2",
|
||||
className,
|
||||
)}
|
||||
data-testid="community-compute-kpis"
|
||||
@@ -49,7 +43,7 @@ export function CommunityComputeKpiRow({
|
||||
"min-w-0 px-3 py-2.5",
|
||||
index > 0 && "border-border/60 border-t sm:border-l sm:border-t-0",
|
||||
)}
|
||||
data-testid={`community-compute-kpi-${kpiTestId(item.label)}`}
|
||||
data-testid={`community-compute-kpi-${item.testId}`}
|
||||
key={item.label}
|
||||
>
|
||||
<dt className="truncate text-2xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
@@ -77,7 +71,3 @@ function formatCapacity(value: number | null): string {
|
||||
if (value === null) return "—";
|
||||
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(value)} GB`;
|
||||
}
|
||||
|
||||
function kpiTestId(label: string): string {
|
||||
return label.toLowerCase().replaceAll(" ", "-");
|
||||
}
|
||||
|
||||
@@ -14,9 +14,11 @@ export function MeshComputeCommunityView({
|
||||
error,
|
||||
onRefresh,
|
||||
snapshot,
|
||||
isPreparing = false,
|
||||
}: {
|
||||
communityName: string;
|
||||
error: string | null;
|
||||
isPreparing?: boolean;
|
||||
onRefresh: () => void;
|
||||
snapshot: MeshSnapshot | CommunityComputeSnapshotInput | null;
|
||||
}) {
|
||||
@@ -43,13 +45,15 @@ export function MeshComputeCommunityView({
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold tracking-tight">
|
||||
{model.deployments.length === 1
|
||||
? "Your community’s first compute node"
|
||||
? "The mesh starts here"
|
||||
: "Community mesh"}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{model.deployments.length === 1
|
||||
? "This machine is the foundation of the mesh. More contributors add capacity, model choice, and resilience."
|
||||
: "See the compute currently available to your community. Larger territories offer more model memory."}
|
||||
{isPreparing
|
||||
? "Your contribution is being prepared and will appear here when it is ready."
|
||||
: model.deployments.length === 1
|
||||
? "One tile is a beginning. Every contributor makes the mesh more capable and resilient."
|
||||
: "Each tile is compute a community member is making available."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -1,52 +1,30 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useCommunities } from "@/features/communities/useCommunities";
|
||||
import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
import { useMeshComputeState } from "../hooks/useMeshComputeState";
|
||||
import { MeshComputeCommunityView } from "./MeshComputeCommunityView";
|
||||
import { MeshComputeSettingsCard } from "./MeshComputeSettingsCard";
|
||||
import { MeshComputeShareBanner } from "./MeshComputeShareBanner";
|
||||
|
||||
export function MeshComputePage() {
|
||||
const { activeCommunity } = useCommunities();
|
||||
const mesh = useMeshComputeState();
|
||||
const [tab, setTab] = React.useState("community");
|
||||
const communityName = activeCommunity?.name ?? "this community";
|
||||
|
||||
return (
|
||||
<section className="min-w-0" data-testid="settings-mesh-compute-page">
|
||||
<SettingsSectionHeader
|
||||
description={`Contribute this machine’s spare capacity to unlock more models and keep ${communityName}’s agents available when demand grows.`}
|
||||
description={`Share this machine’s spare capacity with ${communityName}, then see everyone contributing to the mesh.`}
|
||||
title="Compute"
|
||||
/>
|
||||
|
||||
<div className="space-y-5">
|
||||
<MeshComputeShareBanner communityName={communityName} mesh={mesh} />
|
||||
|
||||
<Tabs onValueChange={setTab} value={tab}>
|
||||
<TabsList className="grid w-full grid-cols-2 sm:w-72">
|
||||
<TabsTrigger data-testid="compute-tab-community" value="community">
|
||||
Community
|
||||
</TabsTrigger>
|
||||
<TabsTrigger data-testid="compute-tab-settings" value="settings">
|
||||
Settings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent className="mt-5 space-y-5" value="community">
|
||||
<MeshComputeCommunityView
|
||||
communityName={communityName}
|
||||
error={mesh.error}
|
||||
onRefresh={mesh.refreshSnapshot}
|
||||
snapshot={mesh.snapshot}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="mt-8" value="settings">
|
||||
<MeshComputeSettingsCard hideHeader />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<MeshComputeCommunityView
|
||||
communityName={communityName}
|
||||
error={mesh.error}
|
||||
isPreparing={mesh.pendingAction === "start"}
|
||||
onRefresh={mesh.refreshSnapshot}
|
||||
snapshot={mesh.snapshot}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,670 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
AgentConfigTextInput,
|
||||
AgentDropdownSelect,
|
||||
type AgentDropdownOption,
|
||||
} from "@/features/agents/ui/agentConfigControls";
|
||||
import {
|
||||
CUSTOM_MODEL_DROPDOWN_VALUE,
|
||||
PERSONA_FIELD_CONTROL_CLASS,
|
||||
PERSONA_FIELD_SHELL_CLASS,
|
||||
} from "@/features/agents/ui/agentConfigOptions";
|
||||
|
||||
import {
|
||||
meshStartNode,
|
||||
meshStopNode,
|
||||
meshInstalledModels,
|
||||
meshModelCatalog,
|
||||
} from "@/shared/api/tauriMesh";
|
||||
import type {
|
||||
MeshCatalogEntry,
|
||||
MeshModelCatalog,
|
||||
MeshModelOption,
|
||||
MeshNodeStatus,
|
||||
} from "@/shared/api/tauriMesh";
|
||||
import { MeshExperimentalSection } from "./MeshExperimentalSection";
|
||||
import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader";
|
||||
import { classifyModelRef } from "../classifyModelRef";
|
||||
import {
|
||||
downloadPercent,
|
||||
formatDownloadBytes,
|
||||
useMeshDownloadProgress,
|
||||
} from "../hooks/useMeshDownloadProgress";
|
||||
import { useMeshNodeStatus } from "../hooks/useMeshNodeStatus";
|
||||
import { useMeshServingUsage } from "../hooks/useMeshServingUsage";
|
||||
import { deriveMeshShareToggle } from "../shareToggleState";
|
||||
import { deriveServingIndicator } from "../servingUsage";
|
||||
import {
|
||||
getShareComputeModel,
|
||||
setShareComputeMaxVramGb,
|
||||
setShareComputeModel,
|
||||
useShareComputeMaxVramGb,
|
||||
useShareComputeModel,
|
||||
} from "../shareComputePreferences";
|
||||
|
||||
// Keep the Share compute controls visually and behaviorally aligned with the
|
||||
// agent configuration fields. This is intentionally the same shell used by
|
||||
// AgentDefaultsEditor rather than a local approximation of a select.
|
||||
const MESH_SELECT_TRIGGER_CLASS = cn(
|
||||
PERSONA_FIELD_CONTROL_CLASS,
|
||||
PERSONA_FIELD_SHELL_CLASS,
|
||||
"h-11 px-3 py-2 leading-6 hover:bg-muted/40 focus:bg-muted/40 [&>svg]:text-muted-foreground/60",
|
||||
);
|
||||
|
||||
const SHARE_COMPUTE_REVEAL_TRANSITION = {
|
||||
duration: 0.22,
|
||||
ease: [0.23, 1, 0.32, 1],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Settings → Compute → Share compute.
|
||||
*
|
||||
* One toggle, one model field, an "Already installed" picklist, an Advanced
|
||||
* group. User-facing copy describes the shared-compute behavior without
|
||||
* exposing implementation protocols or raw mesh controls.
|
||||
*/
|
||||
export function MeshComputeSettingsCard({
|
||||
hideHeader = false,
|
||||
}: {
|
||||
hideHeader?: boolean;
|
||||
} = {}) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const { status, error, refresh } = useMeshNodeStatus();
|
||||
const [installedModels, setInstalledModels] = React.useState<
|
||||
MeshModelOption[]
|
||||
>([]);
|
||||
const [catalog, setCatalog] = React.useState<MeshModelCatalog | null>(null);
|
||||
const modelInput = useShareComputeModel();
|
||||
const maxVramGb = useShareComputeMaxVramGb();
|
||||
const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = React.useState(false);
|
||||
const [actionInFlight, setActionInFlight] = React.useState(false);
|
||||
const [pendingAction, setPendingAction] = React.useState<
|
||||
"start" | "stop" | null
|
||||
>(null);
|
||||
const [actionError, setActionError] = React.useState<string | null>(null);
|
||||
const { progress: downloadProgress, reset: resetDownloadProgress } =
|
||||
useMeshDownloadProgress();
|
||||
|
||||
// Fetch installed models. Called on mount and whenever the running state
|
||||
// changes (a fresh start may have downloaded a new model). Stale-tolerant —
|
||||
// the picklist is a convenience, not load-bearing.
|
||||
const refreshInstalled = React.useCallback(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const list = await meshInstalledModels();
|
||||
if (!cancelled) setInstalledModels(list);
|
||||
} catch {
|
||||
// Non-fatal — picklist just stays empty; user can still type a ref.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: status?.state is the intentional trigger — re-fetch installed models when the node transitions (a fresh start may have downloaded a new model)
|
||||
React.useEffect(() => refreshInstalled(), [refreshInstalled, status?.state]);
|
||||
|
||||
// One-shot hardware-aware catalog fetch. Purely additive: when it fails
|
||||
// (stub build, survey error) the card falls back to the free-text field.
|
||||
// When there is no saved choice, make the curated recommendation the actual
|
||||
// default so a new member can turn Share Compute on directly. An explicit
|
||||
// saved draft always wins.
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const value = await meshModelCatalog();
|
||||
if (cancelled) return;
|
||||
setCatalog(value);
|
||||
if (getShareComputeModel().trim() === "" && value.recommended) {
|
||||
setShareComputeModel(value.recommended);
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal — picker just doesn't render.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Mirror only a SERVE runtime's model into the field. A client reports the
|
||||
// remote model it is consuming; copying that value here both loses the
|
||||
// member's local sharing choice and can cause this machine to download the
|
||||
// remote member's much larger model when sharing is enabled.
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
status?.state === "running" &&
|
||||
status.mode === "serve" &&
|
||||
status.modelId &&
|
||||
status.modelId !== modelInput
|
||||
) {
|
||||
setShareComputeModel(status.modelId);
|
||||
}
|
||||
}, [status?.state, status?.mode, status?.modelId, modelInput]);
|
||||
|
||||
// The Share toggle reflects ONLY serve-mode occupancy. A client-mode runtime
|
||||
// (this machine consuming a peer's compute) shares the single runtime slot
|
||||
// and also reports state:"running" — but it must NOT light this switch, and
|
||||
// toggling off must never tear down that unrelated consume session.
|
||||
const { isSharing, isConsuming, slotOccupied } =
|
||||
deriveMeshShareToggle(status);
|
||||
// Host-side "who is using the compute I'm sharing" — only polled while
|
||||
// actively sharing (serve mode). Read-only; reads the node's own metrics.
|
||||
const servingUsage = useMeshServingUsage(isSharing);
|
||||
const servingIndicator = deriveServingIndicator(servingUsage, isSharing);
|
||||
// A consuming client may be intentionally replaced by a serve runtime, so
|
||||
// keep the local model controls available in client mode. Serve and unknown
|
||||
// occupants remain locked until stopped/recovered.
|
||||
const controlsDisabled = actionInFlight || (slotOccupied && !isConsuming);
|
||||
const refClass = classifyModelRef(modelInput);
|
||||
const canStart = refClass.kind !== "unknown" && !actionInFlight;
|
||||
const showSharingControls = isSharing || pendingAction === "start";
|
||||
|
||||
async function handleToggle(next: boolean) {
|
||||
// Never let the Share switch tear down a consume session. The switch is
|
||||
// already disabled while consuming, but status can be stale between polls,
|
||||
// so refuse a stop that isn't stopping OUR serve node as a belt-and-braces
|
||||
// guard (the backend enforces this authoritatively too).
|
||||
if (!next && !isSharing) {
|
||||
return;
|
||||
}
|
||||
setActionError(null);
|
||||
setPendingAction(next ? "start" : "stop");
|
||||
setActionInFlight(true);
|
||||
try {
|
||||
if (next) {
|
||||
const maxVram =
|
||||
maxVramGb.trim() === "" ? undefined : Number.parseFloat(maxVramGb);
|
||||
await meshStartNode({
|
||||
mode: "serve",
|
||||
modelId: modelInput.trim() || undefined,
|
||||
maxVramGb:
|
||||
typeof maxVram === "number" && !Number.isNaN(maxVram)
|
||||
? maxVram
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
await meshStopNode();
|
||||
}
|
||||
refresh();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setActionInFlight(false);
|
||||
setPendingAction(null);
|
||||
resetDownloadProgress();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="min-w-0" data-testid="settings-mesh-share-compute">
|
||||
{hideHeader ? null : (
|
||||
<SettingsSectionHeader
|
||||
title="Share compute"
|
||||
description="Provide compute to your Buzz community. More capacity can increase intelligence and availability of models for agents."
|
||||
/>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<p className="mb-3 rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
Couldn't check shared compute: {error}
|
||||
</p>
|
||||
) : null}
|
||||
{actionError ? (
|
||||
<p className="mb-3 rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
{downloadProgress ? (
|
||||
<DownloadProgressBar progress={downloadProgress} />
|
||||
) : null}
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="flex min-w-0 items-start justify-between gap-6">
|
||||
<div className="min-w-0">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="mesh-share-compute-toggle"
|
||||
>
|
||||
Share this machine
|
||||
</label>
|
||||
{!isSharing ? (
|
||||
<StatusLine
|
||||
isConsuming={isConsuming}
|
||||
pendingAction={pendingAction}
|
||||
status={status}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
checked={isSharing}
|
||||
data-testid="mesh-share-compute-toggle"
|
||||
disabled={
|
||||
// A serve node can always be stopped. An off node or consuming
|
||||
// client can start sharing once a valid local model is selected.
|
||||
// Unknown occupants remain protected from replacement.
|
||||
actionInFlight ||
|
||||
(isSharing
|
||||
? false
|
||||
: slotOccupied && !isConsuming
|
||||
? true
|
||||
: !canStart)
|
||||
}
|
||||
id="mesh-share-compute-toggle"
|
||||
onCheckedChange={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MeshModelPicker
|
||||
catalog={catalog}
|
||||
disabled={controlsDisabled}
|
||||
installedModels={installedModels}
|
||||
isCustomModelEditing={isCustomModelEditing}
|
||||
model={modelInput}
|
||||
onCustomModelEditingChange={setIsCustomModelEditing}
|
||||
onModelChange={setShareComputeModel}
|
||||
/>
|
||||
|
||||
<div className="pt-3">
|
||||
<button
|
||||
aria-expanded={advancedOpen}
|
||||
className="inline-flex h-9 items-center gap-1.5 text-sm font-medium text-foreground transition-colors hover:text-foreground/80 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-testid="mesh-share-compute-advanced-toggle"
|
||||
onClick={() => setAdvancedOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<span>Advanced</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform duration-150 ease-out",
|
||||
advancedOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{advancedOpen ? (
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="mesh-vram">
|
||||
Max VRAM (GB)
|
||||
</label>
|
||||
<AgentConfigTextInput
|
||||
data-testid="mesh-share-compute-vram"
|
||||
disabled={controlsDisabled}
|
||||
id="mesh-vram"
|
||||
inputMode="decimal"
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setShareComputeMaxVramGb(next);
|
||||
}}
|
||||
placeholder="No limit"
|
||||
usePersonaInputStyle
|
||||
value={maxVramGb}
|
||||
/>
|
||||
|
||||
{/*
|
||||
Nested one level deeper than the memory cap: capping memory is a
|
||||
normal advanced adjustment, whereas a collective model changes
|
||||
whether sharing starts at all. Two clicks to reach it is the
|
||||
point.
|
||||
*/}
|
||||
<MeshExperimentalSection
|
||||
disabled={controlsDisabled}
|
||||
onModelChange={setShareComputeModel}
|
||||
selectedModel={modelInput}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{showSharingControls ? (
|
||||
<motion.div
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
className="overflow-hidden"
|
||||
data-testid="mesh-share-compute-options-motion"
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
key="mesh-share-compute-options"
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: SHARE_COMPUTE_REVEAL_TRANSITION
|
||||
}
|
||||
>
|
||||
<section
|
||||
className="space-y-1"
|
||||
data-testid="mesh-share-compute-sharing-status"
|
||||
>
|
||||
<h3 className="text-sm font-medium">Sharing</h3>
|
||||
<div className="space-y-1 rounded-lg bg-muted/30 px-3 py-2">
|
||||
<StatusLine
|
||||
isConsuming={isConsuming}
|
||||
omitSharingVerb
|
||||
pendingAction={pendingAction}
|
||||
status={status}
|
||||
/>
|
||||
{servingIndicator.show ? (
|
||||
<p
|
||||
className={
|
||||
servingIndicator.hasRemoteConsumers
|
||||
? "text-2xs text-emerald-600 dark:text-emerald-400"
|
||||
: "text-2xs text-muted-foreground"
|
||||
}
|
||||
data-testid="mesh-serving-usage"
|
||||
title={servingIndicator.detail ?? undefined}
|
||||
>
|
||||
{servingIndicator.label}
|
||||
{servingIndicator.detail ? (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
· {servingIndicator.detail}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the lifecycle/health text under the toggle. Maps Max's `state` ×
|
||||
* `health` matrix to honest copy — no "starting…" stuck forever when mesh
|
||||
* is actually downloading weights or has failed.
|
||||
*/
|
||||
/**
|
||||
* Live model-download progress: name, bytes, percent bar. Rendered above the
|
||||
* option group while the backend streams mesh-download-progress events —
|
||||
* the answer to "it just greys out while downloading".
|
||||
*/
|
||||
function DownloadProgressBar({
|
||||
progress,
|
||||
}: {
|
||||
progress: NonNullable<ReturnType<typeof useMeshDownloadProgress>["progress"]>;
|
||||
}) {
|
||||
const percent = downloadPercent(progress);
|
||||
const bytes = formatDownloadBytes(progress);
|
||||
return (
|
||||
<div
|
||||
className="mb-3 rounded-lg bg-muted/30 px-3 py-2"
|
||||
data-testid="mesh-download-progress"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2 text-sm">
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{progress.status === "preparing" ? "Preparing" : "Downloading"}{" "}
|
||||
{progress.label}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
{percent != null ? `${percent}%` : bytes || "…"}
|
||||
</span>
|
||||
</div>
|
||||
{bytes && percent != null ? (
|
||||
<p className="mt-0.5 text-sm font-normal text-muted-foreground">
|
||||
{bytes}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full bg-primary transition-[width] duration-300",
|
||||
percent == null && "w-1/4 animate-pulse",
|
||||
)}
|
||||
style={percent != null ? { width: `${percent}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FIT_LABEL: Record<MeshCatalogEntry["fit"], string> = {
|
||||
comfortable: "Fits well",
|
||||
tight: "Tight fit",
|
||||
tradeoff: "Trade-off",
|
||||
too_large: "Too large",
|
||||
};
|
||||
|
||||
const FIT_CLASS: Record<MeshCatalogEntry["fit"], string> = {
|
||||
comfortable: "text-green-600 dark:text-green-400",
|
||||
tight: "text-amber-600 dark:text-amber-400",
|
||||
tradeoff: "text-orange-600 dark:text-orange-400",
|
||||
too_large: "text-destructive",
|
||||
};
|
||||
|
||||
/**
|
||||
* Share Compute's models use the agent configuration picker instead of a
|
||||
* second, hand-rolled option system. The catalog remains hardware-aware, but
|
||||
* its recommendations, installed models, and advanced choices now appear in
|
||||
* the same searchable dropdown used when customizing an agent.
|
||||
*/
|
||||
function MeshModelPicker({
|
||||
catalog,
|
||||
disabled,
|
||||
installedModels,
|
||||
isCustomModelEditing,
|
||||
model,
|
||||
onCustomModelEditingChange,
|
||||
onModelChange,
|
||||
}: {
|
||||
catalog: MeshModelCatalog | null;
|
||||
disabled: boolean;
|
||||
installedModels: readonly MeshModelOption[];
|
||||
isCustomModelEditing: boolean;
|
||||
model: string;
|
||||
onCustomModelEditingChange: (editing: boolean) => void;
|
||||
onModelChange: (model: string) => void;
|
||||
}) {
|
||||
const options = React.useMemo<AgentDropdownOption[]>(() => {
|
||||
const seen = new Set<string>();
|
||||
const catalogOptions = (catalog?.entries ?? []).map((entry) => {
|
||||
seen.add(entry.name);
|
||||
return {
|
||||
disabled: entry.fit === "too_large",
|
||||
label: <MeshModelOptionLabel entry={entry} />,
|
||||
value: entry.name,
|
||||
};
|
||||
});
|
||||
const localOptions = installedModels.flatMap((installed) => {
|
||||
if (seen.has(installed.id)) return [];
|
||||
return [
|
||||
{
|
||||
label: (
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<span className="min-w-0 truncate">
|
||||
{installed.name ?? installed.id}
|
||||
</span>
|
||||
<span className="shrink-0 text-2xs text-muted-foreground">
|
||||
Installed
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
value: installed.id,
|
||||
},
|
||||
];
|
||||
});
|
||||
return [
|
||||
...catalogOptions,
|
||||
...localOptions,
|
||||
{ label: "Custom model…", value: CUSTOM_MODEL_DROPDOWN_VALUE },
|
||||
];
|
||||
}, [catalog?.entries, installedModels]);
|
||||
const knownModel = options.some((option) => option.value === model.trim());
|
||||
const showCustomModelInput =
|
||||
isCustomModelEditing || (model.trim().length > 0 && !knownModel);
|
||||
const selectedValue = showCustomModelInput
|
||||
? CUSTOM_MODEL_DROPDOWN_VALUE
|
||||
: model.trim();
|
||||
|
||||
function handleModelChange(next: string) {
|
||||
if (next === CUSTOM_MODEL_DROPDOWN_VALUE) {
|
||||
onCustomModelEditingChange(true);
|
||||
return;
|
||||
}
|
||||
onCustomModelEditingChange(false);
|
||||
onModelChange(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5" data-testid="mesh-share-compute-catalog">
|
||||
<label className="text-sm font-medium" htmlFor="mesh-share-compute-model">
|
||||
Model
|
||||
</label>
|
||||
<AgentDropdownSelect
|
||||
className={MESH_SELECT_TRIGGER_CLASS}
|
||||
disabled={disabled}
|
||||
id="mesh-share-compute-model"
|
||||
onValueChange={handleModelChange}
|
||||
options={options}
|
||||
placeholder="Select a model"
|
||||
placeholderClassName="text-muted-foreground/55"
|
||||
searchable
|
||||
testId="mesh-share-compute-model"
|
||||
value={selectedValue}
|
||||
/>
|
||||
{showCustomModelInput ? (
|
||||
<AgentConfigTextInput
|
||||
aria-label="Custom model reference"
|
||||
autoCorrect="off"
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
// A stored custom ref starts out inferred rather than explicitly
|
||||
// selected. Mark it as an active custom edit before applying a
|
||||
// cleared value so the field stays mounted while it is replaced.
|
||||
onCustomModelEditingChange(true);
|
||||
onModelChange(event.target.value);
|
||||
}}
|
||||
placeholder="Qwen3-8B-Q4_K_M or hf://meshllm/qwen3-8b@main"
|
||||
usePersonaInputStyle
|
||||
value={model}
|
||||
/>
|
||||
) : null}
|
||||
<p className="text-sm font-normal text-muted-foreground">
|
||||
{catalog
|
||||
? `Recommended for this machine${catalog.gpuName ? ` (${catalog.gpuName}, ${catalog.vramDisplay} AI memory)` : ""}.`
|
||||
: "Choose a model or enter a model reference or local file."}{" "}
|
||||
Buzz downloads remote models when sharing starts.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MeshModelOptionLabel({ entry }: { entry: MeshCatalogEntry }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{entry.name}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{entry.size}</span>
|
||||
<span className={cn("shrink-0", FIT_CLASS[entry.fit])}>
|
||||
{FIT_LABEL[entry.fit]}
|
||||
</span>
|
||||
{entry.recommended ? (
|
||||
<span className="shrink-0 rounded bg-primary/15 px-1.5 text-2xs font-medium text-primary">
|
||||
Recommended
|
||||
</span>
|
||||
) : null}
|
||||
{entry.installed ? (
|
||||
<span className="shrink-0 text-2xs text-muted-foreground">
|
||||
Installed
|
||||
</span>
|
||||
) : null}
|
||||
{!entry.curated ? (
|
||||
<span className="shrink-0 text-2xs text-muted-foreground">
|
||||
Advanced
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusLine({
|
||||
displayModel,
|
||||
isConsuming,
|
||||
omitSharingVerb = false,
|
||||
pendingAction,
|
||||
status,
|
||||
}: {
|
||||
displayModel?: string;
|
||||
isConsuming: boolean;
|
||||
omitSharingVerb?: boolean;
|
||||
pendingAction: "start" | "stop" | null;
|
||||
status: MeshNodeStatus | null;
|
||||
}) {
|
||||
if (pendingAction === "start") {
|
||||
return <p className="text-sm text-muted-foreground">Starting…</p>;
|
||||
}
|
||||
if (pendingAction === "stop") {
|
||||
return <p className="text-sm text-muted-foreground">Stopping…</p>;
|
||||
}
|
||||
// A client-mode runtime owns the single slot: this machine is consuming a
|
||||
// peer's compute, not sharing. The switch stays off, but remains available
|
||||
// so the member can replace the client with a serving runtime.
|
||||
if (isConsuming) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This machine is currently using another member's shared compute. Turn on
|
||||
sharing to switch to the selected local model; Buzz may briefly restart.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!status) {
|
||||
return <p className="text-sm text-muted-foreground">Checking status…</p>;
|
||||
}
|
||||
const { state, health, modelId, modelName } = status;
|
||||
const modelLabel = displayModel ?? modelName ?? modelId ?? "";
|
||||
|
||||
if (state === "off") {
|
||||
return null;
|
||||
}
|
||||
if (state === "starting") {
|
||||
const reason =
|
||||
health.status === "degraded" || health.status === "failed"
|
||||
? health.reason
|
||||
: "Starting…";
|
||||
return <p className="text-sm text-muted-foreground">{reason}</p>;
|
||||
}
|
||||
if (state === "running") {
|
||||
if (health.status === "failed") {
|
||||
return (
|
||||
<p className="text-sm text-destructive">
|
||||
Couldn't load: {health.reason}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (health.status === "degraded") {
|
||||
return (
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">
|
||||
Active{modelLabel ? ` — ${modelLabel}` : ""}. {health.reason}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{omitSharingVerb ? "" : "Sharing"}
|
||||
{modelLabel ? `${omitSharingVerb ? "" : " "}${modelLabel}` : ""} with
|
||||
relay members.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state === "stopping") {
|
||||
return <p className="text-sm text-muted-foreground">Stopping…</p>;
|
||||
}
|
||||
if (state === "failed") {
|
||||
const reason =
|
||||
health.status === "failed" || health.status === "degraded"
|
||||
? health.reason
|
||||
: "Couldn't start.";
|
||||
return <p className="text-sm text-destructive">{reason}</p>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Cpu, LoaderCircle, ShieldCheck, Sparkles, Zap } from "lucide-react";
|
||||
import { Cpu, LoaderCircle } from "lucide-react";
|
||||
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
import type { useMeshComputeState } from "../hooks/useMeshComputeState";
|
||||
import {
|
||||
downloadPercent,
|
||||
formatDownloadBytes,
|
||||
} from "../hooks/useMeshDownloadProgress";
|
||||
|
||||
export function MeshComputeShareBanner({
|
||||
communityName,
|
||||
@@ -12,26 +16,17 @@ export function MeshComputeShareBanner({
|
||||
mesh: ReturnType<typeof useMeshComputeState>;
|
||||
}) {
|
||||
const {
|
||||
error,
|
||||
memory,
|
||||
modelToShare,
|
||||
downloadProgress,
|
||||
pendingAction,
|
||||
shareSwitchChecked,
|
||||
status,
|
||||
toggle,
|
||||
setSharing,
|
||||
} = mesh;
|
||||
const busy = pendingAction !== null;
|
||||
const model = status?.modelName ?? status?.modelId ?? modelToShare;
|
||||
const capacity = memory.totalGb == null ? null : formatGb(memory.totalGb);
|
||||
const statusCopy = shareStatusCopy({
|
||||
communityName,
|
||||
modelToShare,
|
||||
pendingAction,
|
||||
statusModel: status?.modelName ?? status?.modelId ?? null,
|
||||
isConsuming: toggle.isConsuming,
|
||||
isSharing: toggle.isSharing,
|
||||
});
|
||||
const progress = downloadProgress ? downloadPercent(downloadProgress) : null;
|
||||
const progressDetail = downloadProgress
|
||||
? formatDownloadBytes(downloadProgress)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -57,56 +52,66 @@ export function MeshComputeShareBanner({
|
||||
className="text-base font-semibold"
|
||||
htmlFor="compute-share-banner-toggle"
|
||||
>
|
||||
Automatic contribution
|
||||
Share compute
|
||||
</label>
|
||||
<p className="mt-1 max-w-2xl text-sm leading-relaxed text-muted-foreground">
|
||||
Let Buzz choose the most useful compatible model and make this
|
||||
machine available when {communityName} needs it.
|
||||
Contribute this machine’s spare capacity so {communityName}’s agents
|
||||
have more compute available. Pause anytime.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={shareSwitchChecked}
|
||||
data-testid="compute-share-banner-toggle"
|
||||
disabled={busy || (!toggle.isSharing && !modelToShare)}
|
||||
disabled={busy || (!toggle.isSharing && !mesh.modelToShare)}
|
||||
id="compute-share-banner-toggle"
|
||||
onCheckedChange={setSharing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-px border-border/60 border-t bg-border/60 sm:grid-cols-3">
|
||||
<Benefit
|
||||
icon={Sparkles}
|
||||
label="Unlock community models"
|
||||
detail={
|
||||
model ? `Recommended: ${model}` : "Hardware-aware model selection"
|
||||
}
|
||||
/>
|
||||
<Benefit
|
||||
icon={Zap}
|
||||
label="Increase availability"
|
||||
detail={
|
||||
capacity
|
||||
? `Offer up to ${capacity}`
|
||||
: "Uses compatible spare capacity"
|
||||
}
|
||||
/>
|
||||
<Benefit
|
||||
icon={ShieldCheck}
|
||||
label="You stay in control"
|
||||
detail="Pause anytime; advanced limits in Settings"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-border/60 border-t bg-background/60 px-4 py-2.5">
|
||||
<p
|
||||
className="text-xs text-muted-foreground"
|
||||
data-testid="compute-share-banner-status"
|
||||
>
|
||||
{statusCopy}
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="border-border/60 border-t bg-background/60 px-4 py-3"
|
||||
>
|
||||
<p className="text-sm" data-testid="compute-share-banner-status">
|
||||
{statusCopy(mesh, communityName)}
|
||||
</p>
|
||||
{error ? (
|
||||
{downloadProgress ? (
|
||||
<div className="mt-2" data-testid="mesh-download-progress">
|
||||
<div className="flex justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span className="truncate">
|
||||
{downloadProgress.status === "preparing"
|
||||
? "Preparing"
|
||||
: "Downloading"}{" "}
|
||||
{downloadProgress.label}
|
||||
</span>
|
||||
<span className="shrink-0">
|
||||
{progress !== null
|
||||
? `${progress}%`
|
||||
: progressDetail || "Working…"}
|
||||
</span>
|
||||
</div>
|
||||
{progressDetail && progress !== null ? (
|
||||
<p className="mt-1 text-2xs text-muted-foreground">
|
||||
{progressDetail}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full bg-primary transition-[width] duration-300",
|
||||
progress === null &&
|
||||
"w-1/4 animate-pulse motion-reduce:animate-none",
|
||||
)}
|
||||
style={
|
||||
progress !== null ? { width: `${progress}%` } : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{mesh.error ? (
|
||||
<p className="mt-1 text-xs text-destructive" role="alert">
|
||||
{error}
|
||||
{mesh.error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -114,58 +119,23 @@ export function MeshComputeShareBanner({
|
||||
);
|
||||
}
|
||||
|
||||
function Benefit({
|
||||
icon: Icon,
|
||||
label,
|
||||
detail,
|
||||
}: {
|
||||
icon: typeof Sparkles;
|
||||
label: string;
|
||||
detail: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 bg-background/80 px-4 py-3">
|
||||
<Icon className="mt-0.5 size-3.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium">{label}</p>
|
||||
<p
|
||||
className="mt-0.5 truncate text-2xs text-muted-foreground"
|
||||
title={detail}
|
||||
>
|
||||
{detail}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function shareStatusCopy({
|
||||
communityName,
|
||||
isConsuming,
|
||||
isSharing,
|
||||
modelToShare,
|
||||
pendingAction,
|
||||
statusModel,
|
||||
}: {
|
||||
communityName: string;
|
||||
isConsuming: boolean;
|
||||
isSharing: boolean;
|
||||
modelToShare: string | null;
|
||||
pendingAction: "start" | "stop" | null;
|
||||
statusModel: string | null;
|
||||
}): string {
|
||||
if (pendingAction === "start")
|
||||
return `Preparing ${statusModel ?? modelToShare ?? "a model"} for ${communityName}…`;
|
||||
if (pendingAction === "stop") return "Stopping shared compute…";
|
||||
if (isSharing)
|
||||
return `${statusModel ?? modelToShare ?? "A model"} is available to ${communityName}`;
|
||||
if (isConsuming)
|
||||
return "Using another member’s compute; enable this to contribute instead";
|
||||
if (modelToShare)
|
||||
return "Ready to contribute automatically when you turn this on";
|
||||
return "Checking this machine and choosing a compatible model…";
|
||||
}
|
||||
|
||||
function formatGb(value: number): string {
|
||||
return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }).format(value)} GB`;
|
||||
function statusCopy(
|
||||
mesh: ReturnType<typeof useMeshComputeState>,
|
||||
communityName: string,
|
||||
): string {
|
||||
const model =
|
||||
mesh.status?.modelName ?? mesh.status?.modelId ?? mesh.modelToShare;
|
||||
if (mesh.pendingAction === "start") {
|
||||
return mesh.downloadProgress
|
||||
? "Downloading the files needed to contribute. Your tile will appear when this machine is ready."
|
||||
: "Checking this machine and preparing shared compute. Your tile will appear when it is ready.";
|
||||
}
|
||||
if (mesh.pendingAction === "stop") return "Stopping shared compute…";
|
||||
if (mesh.toggle.isSharing)
|
||||
return `This machine is sharing compute with ${communityName}${model ? ` using ${model}` : ""}.`;
|
||||
if (mesh.toggle.isConsuming)
|
||||
return "This machine is using shared compute. Turn this on to contribute too.";
|
||||
if (mesh.modelToShare)
|
||||
return "Ready to contribute. Buzz will choose compatible settings automatically.";
|
||||
return "Checking whether this machine can contribute…";
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import { ChevronDown, FlaskConical } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
import { useMeshExperimentalCatalog } from "../hooks/useMeshExperimentalCatalog";
|
||||
import {
|
||||
describeExperimentalEmpty,
|
||||
deriveMeshExperimentalModel,
|
||||
} from "../experimentalModel";
|
||||
|
||||
/**
|
||||
* The experimental tier of Advanced settings: models served across machines.
|
||||
*
|
||||
* ## Why this is a separate, collapsed tier
|
||||
*
|
||||
* Every other model in the picker starts when you flip the switch. These do not.
|
||||
* mesh-llm gathers a cohort over gossip and holds a settle barrier — at least
|
||||
* two participants, membership unchanged for a dwell period — then elects a
|
||||
* coordinator deterministically by VRAM. Until that forms, a node named for a
|
||||
* collective model waits.
|
||||
*
|
||||
* That waiting is indistinguishable from a slow download, so someone who was not
|
||||
* warned would read it as a hang. The disclosure therefore sits above the list,
|
||||
* before a choice is made, rather than as a toast afterwards.
|
||||
*
|
||||
* ## Why it can be empty for good reasons
|
||||
*
|
||||
* Three different facts produce an empty list — the catalog could not be read,
|
||||
* it holds no layer packages, or this machine is large enough that nothing needs
|
||||
* splitting — and the third is good news. `deriveMeshExperimentalModel` keeps
|
||||
* them distinct so this never renders an unexplained blank.
|
||||
*/
|
||||
export function MeshExperimentalSection({
|
||||
disabled,
|
||||
onModelChange,
|
||||
selectedModel,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
onModelChange: (model: string) => void;
|
||||
selectedModel: string;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
// Fetch only once opened: the first call can download the catalog, which is
|
||||
// not work to do for a section nobody expanded.
|
||||
const { catalog } = useMeshExperimentalCatalog(open);
|
||||
const model = React.useMemo(
|
||||
() => deriveMeshExperimentalModel(catalog),
|
||||
[catalog],
|
||||
);
|
||||
const selected = selectedModel.trim();
|
||||
|
||||
return (
|
||||
<div className="pt-1">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className="inline-flex h-9 items-center gap-1.5 text-sm font-medium text-foreground transition-colors hover:text-foreground/80 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-testid="mesh-experimental-toggle"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
type="button"
|
||||
>
|
||||
<FlaskConical className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Experimental</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform duration-150 ease-out",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="mt-2 space-y-3" data-testid="mesh-experimental-panel">
|
||||
{/*
|
||||
Stated before the list, not after a selection. The wait is the whole
|
||||
character of these models, and it looks exactly like a hang to
|
||||
anyone who was not told.
|
||||
*/}
|
||||
<p className="rounded-lg bg-muted/40 px-3 py-2 text-2xs leading-relaxed text-muted-foreground">
|
||||
These models are too large for this computer alone and run across
|
||||
several machines at once.{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
Sharing will not start until enough well-connected computers are
|
||||
sharing the same model
|
||||
</span>{" "}
|
||||
— it waits instead of loading. For advanced setups.
|
||||
</p>
|
||||
|
||||
{model.emptyReason ? (
|
||||
<p
|
||||
className="text-2xs text-muted-foreground"
|
||||
data-testid="mesh-experimental-empty"
|
||||
>
|
||||
{describeExperimentalEmpty(model.emptyReason)}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1" data-testid="mesh-experimental-list">
|
||||
{model.options.map((option) => {
|
||||
const isSelected = selected === option.entry.name;
|
||||
return (
|
||||
<li key={option.entry.name}>
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-center justify-between gap-3 rounded-lg px-3 py-2 text-left transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/10 ring-1 ring-primary/40"
|
||||
: "hover:bg-muted/50",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
)}
|
||||
data-testid="mesh-experimental-option"
|
||||
disabled={disabled}
|
||||
onClick={() => onModelChange(option.entry.name)}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 truncate text-sm">
|
||||
{option.entry.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-2xs tabular-nums text-muted-foreground">
|
||||
{option.detail}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,73 +26,32 @@ type E2eWindow = Window & {
|
||||
}) => void;
|
||||
};
|
||||
|
||||
test("Share compute chooses a model before sharing", async ({ page }) => {
|
||||
const modelRef = "hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M";
|
||||
test("Share compute uses one automatic contribution control", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await openSettings(page, "compute");
|
||||
await page.getByTestId("compute-tab-settings").click();
|
||||
|
||||
const card = page.getByTestId("settings-mesh-share-compute");
|
||||
const toggle = page.getByTestId("mesh-share-compute-toggle");
|
||||
const model = page.getByTestId("mesh-share-compute-model");
|
||||
await expect(page.getByTestId("compute-tab-community")).toHaveCount(0);
|
||||
await expect(page.getByTestId("compute-tab-settings")).toHaveCount(0);
|
||||
await expect(page.getByTestId("settings-mesh-share-compute")).toHaveCount(0);
|
||||
|
||||
await expect(card).not.toContainText("Not sharing right now");
|
||||
await expect(
|
||||
page.getByTestId("mesh-share-compute-options-motion"),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("mesh-share-compute-sharing-status"),
|
||||
).toHaveCount(0);
|
||||
await expect(model).toBeVisible();
|
||||
const toggle = page.getByTestId("compute-share-banner-toggle");
|
||||
await expect(toggle).toBeEnabled();
|
||||
await model.click();
|
||||
await page.getByRole("option", { name: "Custom model…" }).click();
|
||||
await page.getByLabel("Custom model reference").fill(modelRef);
|
||||
|
||||
await toggle.click();
|
||||
await expect(
|
||||
page.getByTestId("mesh-share-compute-options-motion"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("mesh-share-compute-sharing-status"),
|
||||
).toBeVisible();
|
||||
await expect(model).toBeVisible();
|
||||
await expect(card).toContainText(
|
||||
"Buzz downloads remote models when sharing starts",
|
||||
);
|
||||
await expect(toggle).toBeChecked();
|
||||
await expect(
|
||||
page.getByTestId("mesh-share-compute-sharing-status"),
|
||||
).toContainText("SmolLM2 135M with relay members");
|
||||
await expect(page.getByTestId("compute-share-banner-status")).toContainText(
|
||||
"Your tile will appear when it is ready",
|
||||
);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as E2eWindow).__BUZZ_E2E_COMMANDS__ ?? []),
|
||||
)
|
||||
.toContain("mesh_start_node");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() => (window as E2eWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [],
|
||||
),
|
||||
)
|
||||
.toContainEqual({
|
||||
command: "mesh_start_node",
|
||||
payload: {
|
||||
request: { mode: "serve", modelId: modelRef },
|
||||
},
|
||||
});
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).not.toBeChecked();
|
||||
await expect(card).not.toContainText("Not sharing right now");
|
||||
await expect(
|
||||
page.getByTestId("mesh-share-compute-options-motion"),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId("mesh-share-compute-sharing-status"),
|
||||
).toHaveCount(0);
|
||||
await expect(model).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => (window as E2eWindow).__BUZZ_E2E_COMMANDS__ ?? []),
|
||||
@@ -100,67 +59,6 @@ test("Share compute chooses a model before sharing", async ({ page }) => {
|
||||
.toContain("mesh_stop_node");
|
||||
});
|
||||
|
||||
test("a consuming client can switch to sharing its saved local model", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Regression: consuming someone else's shared compute starts a client-mode
|
||||
// node in the single runtime slot, which reports state:"running". The Share
|
||||
// toggle keyed off state alone and lit up. A later guard overcorrected by
|
||||
// disabling the switch and copying the remote model over the local sharing
|
||||
// choice. Keep the switch off, preserve the local model, then replace the
|
||||
// client with one serve start (never a stop command).
|
||||
const localModel = "hf://demo/local-small-model:Q4_K_M";
|
||||
await page.addInitScript((model) => {
|
||||
window.localStorage.setItem("buzz.mesh-compute.share.model.v1", model);
|
||||
}, localModel);
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
// The mesh seed hook is installed when the mock bridge boots; calling it
|
||||
// before then silently no-ops (optional chaining) and the seed is lost.
|
||||
await page.waitForFunction(
|
||||
() => typeof (window as E2eWindow).__BUZZ_E2E_SET_MESH__ === "function",
|
||||
);
|
||||
await page.evaluate(() => {
|
||||
(window as E2eWindow).__BUZZ_E2E_SET_MESH__?.({
|
||||
nodeState: "running",
|
||||
nodeMode: "client",
|
||||
});
|
||||
});
|
||||
await openSettings(page, "compute");
|
||||
await page.getByTestId("compute-tab-settings").click();
|
||||
|
||||
const card = page.getByTestId("settings-mesh-share-compute");
|
||||
const toggle = page.getByTestId("mesh-share-compute-toggle");
|
||||
await expect(card).toContainText(
|
||||
"This machine is currently using another member's shared compute",
|
||||
);
|
||||
await expect(card).toContainText("Buzz may briefly restart");
|
||||
await expect(toggle).not.toBeChecked();
|
||||
await expect(
|
||||
page.getByTestId("mesh-share-compute-options-motion"),
|
||||
).toHaveCount(0);
|
||||
await expect(toggle).toBeEnabled();
|
||||
const customModel = page.getByLabel("Custom model reference");
|
||||
await expect(customModel).toHaveValue(localModel);
|
||||
await customModel.fill("");
|
||||
await expect(customModel).toBeVisible();
|
||||
await customModel.fill("hf://demo/replacement-model:Q4_K_M");
|
||||
await toggle.click();
|
||||
await expect(toggle).toBeChecked();
|
||||
|
||||
const commands = await page.evaluate(() => ({
|
||||
names: (window as E2eWindow).__BUZZ_E2E_COMMANDS__ ?? [],
|
||||
payloads: (window as E2eWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [],
|
||||
}));
|
||||
expect(commands.names).not.toContain("mesh_stop_node");
|
||||
expect(commands.payloads).toContainEqual({
|
||||
command: "mesh_start_node",
|
||||
payload: {
|
||||
request: { mode: "serve", modelId: "hf://demo/replacement-model:Q4_K_M" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("Shared Compute is only available from Settings", async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
@@ -215,9 +113,6 @@ test("Community Compute keeps the live community topology focused", async ({
|
||||
await expect(
|
||||
page.getByTestId("community-compute-kpi-contributed-vram"),
|
||||
).toContainText("96 GB");
|
||||
await expect(
|
||||
page.getByTestId("community-compute-kpi-vram-allocated"),
|
||||
).toContainText("42%");
|
||||
await expect(
|
||||
page.getByTestId("community-compute-territory-map"),
|
||||
).toBeVisible();
|
||||
|
||||
Reference in New Issue
Block a user