← back to Exo
dashboard/src/routes/+page.svelte
6777 lines
<script lang="ts">
import {
TopologyGraph,
ChatForm,
ChatMessages,
ChatSidebar,
ModelCard,
ModelPickerModal,
ChatModelSelector,
} from "$lib/components";
import {
pickAutoModel,
getAutoTierIndex,
} from "$lib/components/ChatModelSelector.svelte";
import {
favorites,
toggleFavorite,
getFavoritesSet,
} from "$lib/stores/favorites.svelte";
import {
hasRecents,
getRecentModelIds,
recordRecentLaunch,
} from "$lib/stores/recents.svelte";
import {
hasStartedChat,
isTopologyMinimized,
topologyData,
lastUpdate,
clearChat,
instances,
runners,
downloads,
placementPreviews,
selectedPreviewModelId,
isLoadingPreviews,
selectPreviewModel,
togglePreviewNodeFilter,
clearPreviewNodeFilter,
previewNodeFilter,
createConversation,
setSelectedChatModel,
selectedChatModel,
sendMessage,
thinkingEnabled,
generateImage,
editImage,
editingImage,
messages,
debugMode,
toggleDebugMode,
topologyOnlyMode,
toggleTopologyOnlyMode,
chatSidebarVisible,
toggleChatSidebarVisible,
mobileChatSidebarOpen,
toggleMobileChatSidebar,
setMobileChatSidebarOpen,
mobileRightSidebarOpen,
toggleMobileRightSidebar,
setMobileRightSidebarOpen,
nodeThunderbolt,
nodeRdmaCtl,
thunderboltBridgeCycles,
nodeThunderboltBridge,
nodeIdentities,
isConnected,
type DownloadProgress,
type PlacementPreview,
} from "$lib/stores/app.svelte";
import { addToast, dismissByMessage } from "$lib/stores/toast.svelte";
import HeaderNav from "$lib/components/HeaderNav.svelte";
import DeviceIcon from "$lib/components/DeviceIcon.svelte";
import { fade, fly, slide } from "svelte/transition";
import { tweened } from "svelte/motion";
import { cubicInOut, cubicOut } from "svelte/easing";
import { onMount } from "svelte";
const chatStarted = $derived(hasStartedChat());
const minimized = $derived(isTopologyMinimized());
const data = $derived(topologyData());
const update = $derived(lastUpdate());
const instanceData = $derived(instances());
const runnersData = $derived(runners());
const downloadsData = $derived(downloads());
const previewsData = $derived(placementPreviews());
const selectedModelId = $derived(selectedPreviewModelId());
const loadingPreviews = $derived(isLoadingPreviews());
const debugEnabled = $derived(debugMode());
const topologyOnlyEnabled = $derived(topologyOnlyMode());
const sidebarVisible = $derived(chatSidebarVisible());
const mobileChatOpen = $derived(mobileChatSidebarOpen());
const mobileRightOpen = $derived(mobileRightSidebarOpen());
const tbBridgeCycles = $derived(thunderboltBridgeCycles());
const tbBridgeData = $derived(nodeThunderboltBridge());
const identitiesData = $derived(nodeIdentities());
const tbIdentifiers = $derived(nodeThunderbolt());
const rdmaCtlData = $derived(nodeRdmaCtl());
const nodeFilter = $derived(previewNodeFilter());
// Aggregate active download progress across all instances for header indicator
const activeDownloadSummary = $derived.by(() => {
let totalBytes = 0;
let downloadedBytes = 0;
let count = 0;
for (const [id, inst] of Object.entries(instanceData)) {
const status = getInstanceDownloadStatus(id, inst);
if (status.isDownloading && status.progress) {
count++;
totalBytes += status.progress.totalBytes || 0;
downloadedBytes += status.progress.downloadedBytes || 0;
}
}
if (count === 0) return null;
return {
count,
percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
};
});
// Detect macOS version mismatches across cluster nodes
const macosVersionMismatch = $derived.by(() => {
if (!identitiesData) return null;
const entries = Object.entries(identitiesData);
// Filter to macOS nodes (version starts with a digit, e.g. "15.3")
const macosNodes = entries.filter(([_, id]) => {
const v = id.osVersion;
return v && v !== "Unknown" && /^\d/.test(v);
});
if (macosNodes.length < 2) return null;
// Compare on buildVersion for precise mismatch detection
const buildVersions = new Set(
macosNodes.map(([_, id]) => id.osBuildVersion ?? id.osVersion),
);
if (buildVersions.size <= 1) return null;
return macosNodes.map(([nodeId, id]) => ({
nodeId,
friendlyName: getNodeName(nodeId),
version: id.osVersion!,
buildVersion: id.osBuildVersion ?? "Unknown",
}));
});
// Detect TB5 nodes where RDMA is not enabled
const tb5WithoutRdma = $derived.by(() => {
const rdmaCtl = rdmaCtlData;
if (!rdmaCtl) return false;
const ids = tbIdentifiers;
if (!ids) return false;
// Find nodes with TB5 hardware (any TB interface)
const tb5NodeIds = Object.entries(ids)
.filter(([_, node]) => node.interfaces.length > 0)
.map(([id]) => id);
if (tb5NodeIds.length < 2) return false;
// At least one TB5 node has RDMA disabled
return tb5NodeIds.some((id) => rdmaCtl[id]?.enabled !== true);
});
let tb5InfoDismissed = $state(false);
// Detect Mac Studio nodes using RDMA on en2 (the port next to ethernet — RDMA doesn't work there)
const macStudioEn2RdmaWarning = $derived.by(() => {
const edges = data?.edges;
const ids = tbIdentifiers;
const rdmaCtl = rdmaCtlData;
if (!edges || !ids || !rdmaCtl) return null;
const affectedConnections: Array<{
nodeId: string;
nodeName: string;
peerNodeId: string;
peerNodeName: string;
rdmaIface: string;
}> = [];
const isMacStudio = (node: (typeof data.nodes)[string] | undefined) =>
node?.system_info?.model_id === "Mac Studio";
for (const edge of edges) {
if (!edge.sourceRdmaIface && !edge.sinkRdmaIface) continue;
const sourceNode = data?.nodes?.[edge.source];
if (
isMacStudio(sourceNode) &&
edge.sourceRdmaIface === "rdma_en2" &&
rdmaCtl[edge.source]?.enabled
) {
affectedConnections.push({
nodeId: edge.source,
nodeName:
sourceNode?.friendly_name || edge.source.slice(0, 8) + "...",
peerNodeId: edge.target,
peerNodeName:
data?.nodes?.[edge.target]?.friendly_name ||
edge.target.slice(0, 8) + "...",
rdmaIface: "en2",
});
}
const sinkNode = data?.nodes?.[edge.target];
if (
isMacStudio(sinkNode) &&
edge.sinkRdmaIface === "rdma_en2" &&
rdmaCtl[edge.target]?.enabled
) {
affectedConnections.push({
nodeId: edge.target,
nodeName: sinkNode?.friendly_name || edge.target.slice(0, 8) + "...",
peerNodeId: edge.source,
peerNodeName:
sourceNode?.friendly_name || edge.source.slice(0, 8) + "...",
rdmaIface: "en2",
});
}
}
// Deduplicate by nodeId
const seen = new Set<string>();
const unique = affectedConnections.filter((c) => {
if (seen.has(c.nodeId)) return false;
seen.add(c.nodeId);
return true;
});
return unique.length > 0 ? unique : null;
});
let macStudioEn2Dismissed = $state(false);
// Helper to get friendly node name from node ID
function getNodeName(nodeId: string): string {
const node = data?.nodes?.[nodeId];
return node?.friendly_name || nodeId.slice(0, 8) + "...";
}
// Helper to get the thunderbolt bridge service name from a cycle
function getTbBridgeServiceName(cycle: string[]): string {
// Try to find service name from any node in the cycle
for (const nodeId of cycle) {
const nodeData = tbBridgeData?.[nodeId];
if (nodeData?.serviceName) {
return nodeData.serviceName;
}
}
return "Thunderbolt Bridge"; // Fallback if no service name found
}
// Copy to clipboard state and function
let copiedCommand = $state(false);
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
copiedCommand = true;
setTimeout(() => {
copiedCommand = false;
}, 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
}
// Warning icon SVG path (reused across warning snippets)
const warningIconPath =
"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z";
const infoIconPath =
"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z";
let mounted = $state(false);
let localNodeId = $state<string | null>(null);
let pendingFirefoxQuery = $state<string | null>(null); // ?q= param deferred until state loads
// ── Onboarding wizard state ──
const ONBOARDING_COMPLETE_KEY = "exo-onboarding-complete";
let onboardingStep = $state(0); // 0 = not in onboarding, 1-9 = wizard steps
let onboardingModelId = $state<string | null>(null); // model selected during onboarding
let onboardingFadingOut = $state(false); // true during fade-out transition
const showOnboarding = $derived(onboardingStep > 0);
const showOnboardingOverlay = $derived(showOnboarding || onboardingFadingOut);
// ── Steps 1-5 animation state: cinematic SVG story ──
const SIMULATED_STUDIO_GB = 256; // simulated Mac Studio memory
const onboardingCombinedGB = $derived(
userDeviceInfo.memoryGB + SIMULATED_STUDIO_GB,
);
// Models unlocked by adding the second device — one per base model, well-known preferred
const unlockedModels = $derived.by(() => {
if (models.length === 0) return [];
const singleGB = userDeviceInfo.memoryGB;
const combinedGB = onboardingCombinedGB;
const candidates = models
.filter((m) => {
const sizeGB = getModelSizeGB(m);
return sizeGB > singleGB && sizeGB <= combinedGB && m.family;
})
.sort((a, b) => getModelSizeGB(a) - getModelSizeGB(b));
// Deduplicate by base_model (or family as fallback) — keep smallest quant per base
const seen = new Set<string>();
const deduped: typeof candidates = [];
for (const m of candidates) {
const key = m.base_model || m.family || m.id;
if (seen.has(key)) continue;
seen.add(key);
deduped.push(m);
}
return deduped.slice(0, 3);
});
// User device info from topology — uses /node_id to find our own node
const userDeviceInfo = $derived.by(() => {
if (!data || Object.keys(data.nodes).length === 0) {
return { name: "MacBook Pro", memoryGB: 36, deviceType: "macbook pro" };
}
const ourNode = localNodeId ? data.nodes[localNodeId] : undefined;
const node = ourNode ?? Object.values(data.nodes)[0];
const totalMem =
node.macmon_info?.memory?.ram_total ?? node.system_info?.memory ?? 0;
const memGB = Math.round(totalMem / (1024 * 1024 * 1024));
const name = node.friendly_name || "Your Mac";
const modelId = (node.system_info?.model_id || "macbook pro").toLowerCase();
return { name, memoryGB: memGB || 36, deviceType: modelId };
});
let showContinueButton = $state(false);
let stepTitle = $state("");
let stepTransitioning = $state(false);
// Advance to the next onboarding step
function advanceStep(target: number) {
showContinueButton = false;
if (target <= 5) {
// Steps 1-5 share a persistent SVG canvas — just set the step directly
onboardingStep = target;
} else {
// Leaving the cinematic sequence — fade out, then switch
stepTransitioning = true;
setTimeout(() => {
onboardingStep = target;
stepTransitioning = false;
}, 350);
}
}
// Tweened animation values for the persistent SVG canvas
const device1X = tweened(350, { duration: 800, easing: cubicInOut });
const device2X = tweened(550, { duration: 800, easing: cubicInOut });
const device2Opacity = tweened(0, { duration: 600, easing: cubicOut });
const connectionOpacity = tweened(0, { duration: 500, easing: cubicOut });
const connectionIsRed = tweened(0, { duration: 500, easing: cubicOut }); // 0=gold, 1=red
const combinedLabelOpacity = tweened(0, { duration: 500, easing: cubicOut });
const modelBlockY = tweened(20, { duration: 700, easing: cubicInOut });
const modelBlockOpacity = tweened(0, { duration: 500, easing: cubicOut });
const modelSplitProgress = tweened(0, { duration: 800, easing: cubicInOut }); // 0=unified, 1=fully split
const disconnectXOpacity = tweened(0, { duration: 400, easing: cubicOut });
const device1Opacity = tweened(1, { duration: 600, easing: cubicOut });
const logoOpacity = tweened(1, { duration: 600, easing: cubicOut });
// Step 2 chip fade: 0→N where each chip fades in at its stagger offset
const chipPhase = tweened(0, { duration: 800, easing: cubicOut });
const deviceCountOpacity = tweened(0, { duration: 600, easing: cubicOut });
const topologyOpacity = tweened(1, { duration: 400, easing: cubicOut });
const titleOpacity = tweened(0, { duration: 500, easing: cubicOut });
const subtitleOpacity = tweened(0, { duration: 500, easing: cubicOut });
// ── Step 1: "Your EXO Network" — show real topology ──
$effect(() => {
if (onboardingStep === 1) {
showContinueButton = false;
stepTitle = "";
// Reset all tweens to initial
device1X.set(350, { duration: 0 });
device1Opacity.set(0, { duration: 0 });
device2Opacity.set(0, { duration: 0 });
connectionOpacity.set(0, { duration: 0 });
connectionIsRed.set(0, { duration: 0 });
combinedLabelOpacity.set(0, { duration: 0 });
modelBlockOpacity.set(0, { duration: 0 });
modelSplitProgress.set(0, { duration: 0 });
disconnectXOpacity.set(0, { duration: 0 });
logoOpacity.set(1, { duration: 0 });
titleOpacity.set(0, { duration: 0 });
subtitleOpacity.set(0, { duration: 0 });
chipPhase.set(0, { duration: 0 });
deviceCountOpacity.set(0, { duration: 0 });
topologyOpacity.set(1, { duration: 0 });
const t1 = setTimeout(() => {
titleOpacity.set(1);
}, 300);
const t2 = setTimeout(() => {
deviceCountOpacity.set(1);
}, 800);
const t3 = setTimeout(() => {
showContinueButton = true;
}, 1200);
return () => {
clearTimeout(t1);
clearTimeout(t2);
clearTimeout(t3);
};
}
});
// ── Step 2: "Add devices to run larger models" — cross-fade topology out, device pair animates in ──
$effect(() => {
if (onboardingStep === 2) {
showContinueButton = false;
// Cross-fade: fade out real topology
topologyOpacity.set(0);
// Immediately transition out step 1 elements
logoOpacity.set(0);
deviceCountOpacity.set(0);
// Smoothly crossfade the title: fade old out, update text, fade new in
titleOpacity.set(0, { duration: 300 });
subtitleOpacity.set(0, { duration: 0 });
// Delay all step 2 animations by 400ms to let topology fade out
const DELAY = 400;
const t0 = setTimeout(() => {
stepTitle = "Add devices to run larger models";
titleOpacity.set(1, { duration: 400 });
}, DELAY + 300);
const t1 = setTimeout(() => {
device1Opacity.set(1, { duration: 0 });
device1X.set(220);
device2X.set(480, { duration: 0 });
device2Opacity.set(0, { duration: 0 });
}, DELAY + 200);
const t2 = setTimeout(() => {
device2Opacity.set(1);
device2X.set(480);
}, DELAY + 700);
const t3 = setTimeout(() => {
connectionOpacity.set(1);
}, DELAY + 1200);
const t4 = setTimeout(() => {
combinedLabelOpacity.set(1);
}, DELAY + 1600);
// Staggered chip fade-in (each chip offsets by 0.6 in chipPhase)
const t5 = setTimeout(() => {
chipPhase.set(3, { duration: 1800 });
}, DELAY + 1800);
const t6 = setTimeout(() => {
showContinueButton = true;
}, DELAY + 3200);
return () => {
clearTimeout(t0);
clearTimeout(t1);
clearTimeout(t2);
clearTimeout(t3);
clearTimeout(t4);
clearTimeout(t5);
clearTimeout(t6);
};
}
});
// ── Step 3: "exo splits the model" — model block appears, splits ──
$effect(() => {
if (onboardingStep === 3) {
showContinueButton = false;
// Gently fade out the unlock chips
chipPhase.set(0, { duration: 600 });
// Crossfade title
titleOpacity.set(0, { duration: 250 });
subtitleOpacity.set(0, { duration: 250 });
setTimeout(() => {
stepTitle = "exo splits models across devices";
titleOpacity.set(1, { duration: 400 });
subtitleOpacity.set(1, { duration: 400 });
}, 250);
// Wait for chips to fade before showing model block
const t1 = setTimeout(() => {
modelBlockOpacity.set(1);
modelBlockY.set(50);
}, 600);
const t2 = setTimeout(() => {
modelSplitProgress.set(1);
}, 1500);
const t3 = setTimeout(() => {
showContinueButton = true;
}, 2300);
return () => {
clearTimeout(t1);
clearTimeout(t2);
clearTimeout(t3);
};
}
});
// ── Step 4: "A device disconnects... exo self-heals" — full disconnect+heal sequence ──
$effect(() => {
if (onboardingStep === 4) {
showContinueButton = false;
// Crossfade title
titleOpacity.set(0, { duration: 250 });
subtitleOpacity.set(0, { duration: 250 });
setTimeout(() => {
stepTitle = "When a device disconnects...";
titleOpacity.set(1, { duration: 400 });
subtitleOpacity.set(1, { duration: 400 });
}, 250);
// Phase 1: Disconnect
const t1 = setTimeout(() => {
connectionIsRed.set(1);
}, 400);
const t2 = setTimeout(() => {
disconnectXOpacity.set(1);
}, 800);
const t3 = setTimeout(() => {
device2Opacity.set(0);
connectionOpacity.set(0);
disconnectXOpacity.set(0);
combinedLabelOpacity.set(0);
}, 1600);
// Phase 2: Self-heal — crossfade title + subtitle
const t4 = setTimeout(() => {
titleOpacity.set(0, { duration: 250 });
subtitleOpacity.set(0, { duration: 250 });
}, 2550);
const t4b = setTimeout(() => {
stepTitle = "exo self-heals";
titleOpacity.set(1, { duration: 400 });
subtitleOpacity.set(1, { duration: 400 });
}, 2800);
const t5 = setTimeout(() => {
device1X.set(350);
device2X.set(350);
}, 3100);
const t6 = setTimeout(() => {
modelSplitProgress.set(0);
modelBlockY.set(20); // Lift up while merging
connectionIsRed.set(0);
}, 3700);
const t7 = setTimeout(() => {
modelBlockY.set(125); // Settle back down just above the device
}, 4800);
const t8 = setTimeout(() => {
advanceStep(6);
}, 6200);
return () => {
clearTimeout(t1);
clearTimeout(t2);
clearTimeout(t3);
clearTimeout(t4);
clearTimeout(t4b);
clearTimeout(t5);
clearTimeout(t6);
clearTimeout(t7);
clearTimeout(t8);
};
}
});
// Recommended models for onboarding: 2 large, 2 medium, 2 small
// Always includes Llama-3.2-3B-4bit as a fast-loading small option
const PINNED_ONBOARDING_MODEL = "mlx-community/Llama-3.2-3B-Instruct-4bit";
const onboardingModels = $derived.by(() => {
if (models.length === 0) return [];
const sorted = [...models]
.filter((m) => hasEnoughMemory(m) && getModelSizeGB(m) > 0)
.sort((a, b) => getModelSizeGB(b) - getModelSizeGB(a));
if (sorted.length <= 6) return sorted;
// Split into thirds by size: large (top third), medium (middle), small (bottom)
const third = Math.max(1, Math.floor(sorted.length / 3));
const large = sorted.slice(0, third);
const medium = sorted.slice(third, third * 2);
const small = sorted.slice(third * 2);
// Pick 2 from each tier, ensuring pinned model counts as a small pick
const pinned =
small.find((m) => m.id === PINNED_ONBOARDING_MODEL) ||
sorted.find((m) => m.id === PINNED_ONBOARDING_MODEL);
const pickLarge = large.slice(0, 2);
const pickMedium = medium.slice(0, 2);
const pickSmall = pinned
? [
small.find((m) => m.id !== PINNED_ONBOARDING_MODEL) || small[0],
pinned,
].filter(Boolean)
: small.slice(0, 2);
const result = [...pickLarge, ...pickMedium, ...pickSmall];
// Deduplicate (in case pinned was already picked)
const seen = new Set<string>();
return result.filter((m) => {
if (seen.has(m.id)) return false;
seen.add(m.id);
return true;
});
});
// Track onboarding instance status for auto-advancing steps.
// Uses runner status as source of truth to avoid false "ready" from missing download data.
// Only tracks the specific model launched during onboarding (ignores other running instances).
$effect(() => {
if (onboardingStep === 7 && instanceCount > 0 && onboardingModelId) {
let anyDownloading = false;
let anyReady = false;
for (const [id, inst] of Object.entries(instanceData)) {
// Only check instances for the model we launched during onboarding
if (getInstanceModelId(inst) !== onboardingModelId) continue;
const runnerStatus = deriveInstanceStatus(inst);
if (
runnerStatus.statusText === "READY" ||
runnerStatus.statusText === "LOADED" ||
runnerStatus.statusText === "RUNNING"
) {
anyReady = true;
} else if (runnerStatus.statusText === "DOWNLOADING") {
anyDownloading = true;
} else {
const dlStatus = getInstanceDownloadStatus(id, inst);
if (dlStatus.isDownloading) anyDownloading = true;
}
}
// Model already cached & ready — skip download AND loading steps
if (anyReady) {
onboardingStep = 9;
} else if (anyDownloading) {
// Stay on step 7 (downloading)
} else {
// Not ready and not downloading — could be loading, initializing, or preparing.
// Only advance to step 8 if runners are actually in a loading state.
for (const [, inst] of Object.entries(instanceData)) {
if (getInstanceModelId(inst) !== onboardingModelId) continue;
const runnerStatus = deriveInstanceStatus(inst);
if (
runnerStatus.statusText === "LOADING" ||
runnerStatus.statusText === "WARMING UP"
) {
onboardingStep = 8;
break;
}
}
}
}
});
$effect(() => {
if (onboardingStep === 8 && instanceCount > 0 && onboardingModelId) {
for (const [, inst] of Object.entries(instanceData)) {
if (getInstanceModelId(inst) !== onboardingModelId) continue;
const runnerStatus = deriveInstanceStatus(inst);
if (
runnerStatus.statusText === "READY" ||
runnerStatus.statusText === "LOADED" ||
runnerStatus.statusText === "RUNNING"
) {
onboardingStep = 9;
break;
}
}
}
});
function completeOnboarding() {
// Trigger fade-out, then fully remove overlay
onboardingFadingOut = true;
onboardingStep = 0;
try {
localStorage.setItem(ONBOARDING_COMPLETE_KEY, "true");
} catch {
// ignore
}
// Persist to server (~/.exo)
fetch("/onboarding", { method: "POST" }).catch(() => {});
// Remove overlay after fade-out transition completes
setTimeout(() => {
onboardingFadingOut = false;
}, 500);
}
// Auto-complete onboarding when user sends a message from step 9
$effect(() => {
if (onboardingStep === 9 && chatStarted) {
completeOnboarding();
}
});
let onboardingError = $state<string | null>(null);
async function onboardingLaunchModel(modelId: string) {
onboardingModelId = modelId;
onboardingError = null;
selectPreviewModel(modelId);
onboardingStep = 7;
// Launch via standard placement API (same as main dashboard)
// Single-node: force Pipeline/Ring regardless of persisted defaults
const nodeCount = topologyData()
? Object.keys(topologyData()!.nodes).length
: 1;
const sharding = nodeCount <= 1 ? "Pipeline" : selectedSharding;
const instanceType = nodeCount <= 1 ? "MlxRing" : selectedInstanceType;
try {
const placementResponse = await fetch(
`/instance/placement?model_id=${encodeURIComponent(modelId)}&sharding=${sharding}&instance_meta=${instanceType}&min_nodes=1`,
);
if (!placementResponse.ok) {
const errorText = await placementResponse.text();
onboardingError = `Failed to get placement: ${errorText}`;
onboardingStep = 6;
return;
}
const instanceData = await placementResponse.json();
const response = await fetch("/instance", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instance: instanceData }),
});
if (!response.ok) {
const errorText = await response.text();
onboardingError = `Failed to launch: ${errorText}`;
onboardingStep = 6;
return;
}
setSelectedChatModel(modelId);
recordRecentLaunch(modelId);
} catch (error) {
onboardingError = `Network error: ${error}`;
onboardingStep = 6;
}
}
// Helper to get onboarding download progress
const onboardingDownloadProgress = $derived.by(() => {
if (instanceCount === 0) return null;
for (const [id, inst] of Object.entries(instanceData)) {
const status = getInstanceDownloadStatus(id, inst);
if (status.isDownloading && status.progress) {
return status.progress;
}
}
return null;
});
// Helper to get onboarding model loading progress (layers loaded)
const onboardingLoadProgress = $derived.by(() => {
if (instanceCount === 0 || !onboardingModelId) return null;
let layersLoaded = 0,
totalLayers = 0;
for (const [, inst] of Object.entries(instanceData)) {
if (getInstanceModelId(inst) !== onboardingModelId) continue;
const status = deriveInstanceStatus(inst);
if (
status.statusText === "LOADING" &&
status.totalLayers &&
status.totalLayers > 0
) {
layersLoaded += status.layersLoaded ?? 0;
totalLayers += status.totalLayers;
}
}
if (totalLayers === 0) return null;
return {
layersLoaded,
totalLayers,
percentage: (layersLoaded / totalLayers) * 100,
};
});
// Instance launch state
let models = $state<
Array<{
id: string;
name?: string;
storage_size_megabytes?: number;
tasks?: string[];
hugging_face_id?: string;
is_custom?: boolean;
family?: string;
quantization?: string;
base_model?: string;
capabilities?: string[];
}>
>([]);
type ModelMemoryFitStatus =
| "fits_now"
| "fits_cluster_capacity"
| "too_large";
// Model tasks lookup for ChatForm - maps both short IDs and full HuggingFace IDs
const modelTasks = $derived(() => {
const tasks: Record<string, string[]> = {};
for (const model of models) {
if (model.tasks && model.tasks.length > 0) {
// Map by short ID
tasks[model.id] = model.tasks;
// Also map by hugging_face_id from the API response
if (model.hugging_face_id) {
tasks[model.hugging_face_id] = model.tasks;
}
}
}
return tasks;
});
const modelCapabilities = $derived(() => {
const caps: Record<string, string[]> = {};
for (const model of models) {
if (model.capabilities && model.capabilities.length > 0) {
caps[model.id] = model.capabilities;
if (model.hugging_face_id) {
caps[model.hugging_face_id] = model.capabilities;
}
}
}
return caps;
});
// Helper to check if a model supports image generation
function modelSupportsImageGeneration(modelId: string): boolean {
const model = models.find(
(m) => m.id === modelId || m.hugging_face_id === modelId,
);
if (!model?.tasks) return false;
return (
model.tasks.includes("TextToImage") ||
model.tasks.includes("ImageToImage")
);
}
// Helper to check if a model supports image editing
function modelSupportsImageEditing(modelId: string): boolean {
const model = models.find(
(m) => m.id === modelId || m.hugging_face_id === modelId,
);
if (!model?.tasks) return false;
return model.tasks.includes("ImageToImage");
}
// Route a message to the correct endpoint based on model capabilities.
// Image models go to generateImage/editImage; text models go to sendMessage.
function routeMessage(
content: string,
files?: {
id: string;
name: string;
type: string;
textContent?: string;
preview?: string;
}[],
) {
const model = selectedChatModel();
if (!model) {
sendMessage(content, files, thinkingEnabled());
return;
}
const currentEditImage = editingImage();
// Image editing mode (explicit edit or attached image with ImageToImage model)
if (currentEditImage && content && modelSupportsImageEditing(model)) {
editImage(content, currentEditImage.imageDataUrl);
return;
}
if (
modelSupportsImageEditing(model) &&
files?.length &&
files[0].preview &&
content
) {
editImage(content, files[0].preview);
return;
}
// Text-to-image generation
if (modelSupportsImageGeneration(model) && content) {
generateImage(content);
return;
}
// Default: text chat
sendMessage(content, files, thinkingEnabled());
}
let selectedSharding = $state<"Pipeline" | "Tensor">("Pipeline");
type InstanceMeta = "MlxRing" | "MlxJaccl";
// Launch defaults persistence
const LAUNCH_DEFAULTS_KEY = "exo-launch-defaults-v2";
interface LaunchDefaults {
modelId: string | null;
sharding: "Pipeline" | "Tensor";
instanceType: InstanceMeta;
minNodes: number;
}
function saveLaunchDefaults(): void {
const defaults: LaunchDefaults = {
modelId: selectedPreviewModelId(),
sharding: selectedSharding,
instanceType: selectedInstanceType,
minNodes: selectedMinNodes,
};
try {
localStorage.setItem(LAUNCH_DEFAULTS_KEY, JSON.stringify(defaults));
} catch (e) {
console.warn("Failed to save launch defaults:", e);
}
}
function loadLaunchDefaults(): LaunchDefaults | null {
try {
const stored = localStorage.getItem(LAUNCH_DEFAULTS_KEY);
if (!stored) return null;
return JSON.parse(stored) as LaunchDefaults;
} catch (e) {
console.warn("Failed to load launch defaults:", e);
return null;
}
}
function applyLaunchDefaults(
availableModels: Array<{ id: string }>,
maxNodes: number,
): void {
const defaults = loadLaunchDefaults();
if (!defaults) return;
// Apply sharding and instance type unconditionally
selectedSharding = defaults.sharding;
selectedInstanceType =
defaults.instanceType === "MlxRing" ? "MlxRing" : "MlxJaccl";
// Apply minNodes if valid (between 1 and maxNodes)
if (
defaults.minNodes &&
defaults.minNodes >= 1 &&
defaults.minNodes <= maxNodes
) {
selectedMinNodes = defaults.minNodes;
}
// Only apply model if it exists in the available models
if (
defaults.modelId &&
availableModels.some((m) => m.id === defaults.modelId)
) {
selectPreviewModel(defaults.modelId);
setSelectedChatModel(defaults.modelId);
}
}
let selectedInstanceType = $state<InstanceMeta>("MlxRing");
let selectedMinNodes = $state<number>(1);
let minNodesInitialized = $state(false);
let launchingModelId = $state<string | null>(null);
let instanceDownloadExpandedNodes = $state<Set<string>>(new Set());
// Model picker modal state
let isModelPickerOpen = $state(false);
// Advanced options toggle (hides technical jargon for new users)
let showAdvancedOptions = $state(false);
// Favorites state (reactive)
const favoritesSet = $derived(getFavoritesSet());
// Recent models state (reactive)
const recentModelIds = $derived(getRecentModelIds());
const showRecentsTab = $derived(hasRecents());
// Slider dragging state
let isDraggingSlider = $state(false);
let sliderTrackElement: HTMLDivElement | null = $state(null);
// Instances container ref for scrolling
let instancesContainerRef: HTMLDivElement | null = $state(null);
// Chat scroll container ref for precise scroll behavior
let chatScrollRef: HTMLDivElement | null = $state(null);
// Instance hover state for highlighting nodes in topology
let hoveredInstanceId = $state<string | null>(null);
// Preview card hover state for highlighting nodes in topology
let hoveredPreviewNodes = $state<Set<string>>(new Set());
// Computed: Check if filter is active (from store)
const isFilterActive = $derived(() => nodeFilter.size > 0);
// Helper to unwrap tagged instance for hover highlighting
function unwrapInstanceNodes(instanceWrapped: unknown): Set<string> {
if (!instanceWrapped || typeof instanceWrapped !== "object")
return new Set();
const keys = Object.keys(instanceWrapped as Record<string, unknown>);
if (keys.length !== 1) return new Set();
const instance = (instanceWrapped as Record<string, unknown>)[keys[0]];
if (!instance || typeof instance !== "object") return new Set();
const inst = instance as {
shardAssignments?: { nodeToRunner?: Record<string, string> };
};
if (!inst.shardAssignments?.nodeToRunner) return new Set();
return new Set(Object.keys(inst.shardAssignments.nodeToRunner));
}
function toggleInstanceDownloadDetails(nodeId: string): void {
const next = new Set(instanceDownloadExpandedNodes);
if (next.has(nodeId)) {
next.delete(nodeId);
} else {
next.add(nodeId);
}
instanceDownloadExpandedNodes = next;
}
// Compute highlighted nodes from hovered instance or hovered preview
const highlightedNodes = $derived(() => {
// First check instance hover
if (hoveredInstanceId) {
const instanceWrapped = instanceData[hoveredInstanceId];
return unwrapInstanceNodes(instanceWrapped);
}
// Then check preview hover
if (hoveredPreviewNodes.size > 0) {
return hoveredPreviewNodes;
}
return new Set<string>();
});
// Helper to estimate memory from model ID (mirrors ModelCard logic)
// Uses regex with word boundaries to avoid false matches like '4bit' matching '4b'
function estimateMemoryGB(modelId: string, modelName?: string): number {
// Check both ID and name for quantization info
const combined = `${modelId} ${modelName || ""}`.toLowerCase();
// Detect quantization level - affects memory by roughly 2x between levels
const is4bit =
combined.includes("4bit") ||
combined.includes("4-bit") ||
combined.includes(":4bit");
const is8bit =
combined.includes("8bit") ||
combined.includes("8-bit") ||
combined.includes(":8bit");
// 4-bit = 0.5 bytes/param, 8-bit = 1 byte/param, fp16 = 2 bytes/param
const quantMultiplier = is4bit ? 0.5 : is8bit ? 1 : 2;
const id = modelId.toLowerCase();
// Known large models that don't follow the standard naming pattern
// DeepSeek V3 has 685B parameters
if (id.includes("deepseek-v3")) {
return Math.round(685 * quantMultiplier);
}
// DeepSeek V2 has 236B parameters
if (id.includes("deepseek-v2")) {
return Math.round(236 * quantMultiplier);
}
// Llama 4 Scout/Maverick are large models
if (id.includes("llama-4")) {
return Math.round(400 * quantMultiplier);
}
// Match parameter counts with word boundaries (e.g., "70b" but not "4bit")
const paramMatch = id.match(/(\d+(?:\.\d+)?)\s*b(?![a-z])/i);
if (paramMatch) {
const params = parseFloat(paramMatch[1]);
return Math.max(4, Math.round(params * quantMultiplier));
}
// Fallback patterns for explicit size markers (assume fp16 baseline, adjust for quant)
if (id.includes("405b") || id.includes("400b"))
return Math.round(405 * quantMultiplier);
if (id.includes("180b")) return Math.round(180 * quantMultiplier);
if (id.includes("141b") || id.includes("140b"))
return Math.round(140 * quantMultiplier);
if (id.includes("123b") || id.includes("120b"))
return Math.round(123 * quantMultiplier);
if (id.includes("72b") || id.includes("70b"))
return Math.round(70 * quantMultiplier);
if (id.includes("67b") || id.includes("65b"))
return Math.round(65 * quantMultiplier);
if (
id.includes("35b") ||
id.includes("34b") ||
id.includes("32b") ||
id.includes("30b")
)
return Math.round(32 * quantMultiplier);
if (id.includes("27b") || id.includes("26b") || id.includes("22b"))
return Math.round(24 * quantMultiplier);
if (id.includes("14b") || id.includes("13b") || id.includes("15b"))
return Math.round(14 * quantMultiplier);
if (id.includes("8b") || id.includes("9b") || id.includes("7b"))
return Math.round(8 * quantMultiplier);
if (id.includes("3b") || id.includes("3.8b"))
return Math.round(4 * quantMultiplier);
if (
id.includes("2b") ||
id.includes("1b") ||
id.includes("1.5b") ||
id.includes("0.5b")
)
return Math.round(2 * quantMultiplier);
return 16; // Default fallback
}
// Helper to estimate performance from model ID
function estimatePerformance(modelId: string): { ttft: number; tps: number } {
const id = modelId.toLowerCase();
if (id.includes("405b") || id.includes("400b"))
return { ttft: 8000, tps: 3 };
if (id.includes("180b")) return { ttft: 4000, tps: 5 };
if (id.includes("141b") || id.includes("140b"))
return { ttft: 3500, tps: 6 };
if (id.includes("123b") || id.includes("120b"))
return { ttft: 3000, tps: 7 };
if (id.includes("72b") || id.includes("70b"))
return { ttft: 1800, tps: 12 };
if (id.includes("67b") || id.includes("65b"))
return { ttft: 1600, tps: 14 };
if (
id.includes("35b") ||
id.includes("34b") ||
id.includes("32b") ||
id.includes("30b")
)
return { ttft: 900, tps: 22 };
if (id.includes("27b") || id.includes("26b") || id.includes("22b"))
return { ttft: 700, tps: 28 };
if (id.includes("14b") || id.includes("13b") || id.includes("15b"))
return { ttft: 400, tps: 45 };
if (id.includes("8b") || id.includes("9b") || id.includes("7b"))
return { ttft: 200, tps: 65 };
if (id.includes("4b") || id.includes("3b") || id.includes("3.8b"))
return { ttft: 100, tps: 95 };
if (
id.includes("2b") ||
id.includes("1b") ||
id.includes("1.5b") ||
id.includes("0.5b")
)
return { ttft: 50, tps: 150 };
return { ttft: 300, tps: 50 };
}
const matchesSelectedRuntime = (runtime: InstanceMeta): boolean =>
selectedInstanceType === "MlxRing"
? runtime === "MlxRing"
: runtime === "MlxJaccl";
// Helper to check if a model can be launched (has valid placement with >= minNodes)
function canModelFit(modelId: string): boolean {
// Find previews matching the model, sharding, and instance type
const matchingPreviews = previewsData.filter(
(p: PlacementPreview) =>
p.model_id === modelId &&
p.sharding === selectedSharding &&
matchesSelectedRuntime(p.instance_meta) &&
p.error === null &&
p.memory_delta_by_node !== null,
);
// Check if any preview has node count >= selectedMinNodes
return matchingPreviews.some(
(p: PlacementPreview) => getPreviewNodeCount(p) >= selectedMinNodes,
);
}
// Helper to get model size in GB (from megabytes)
function getModelSizeGB(model: {
id: string;
name?: string;
storage_size_megabytes?: number;
}): number {
if (model.storage_size_megabytes) {
return model.storage_size_megabytes / 1024;
}
return estimateMemoryGB(model.id, model.name);
}
// Calculate available memory in the cluster (in GB)
const availableMemoryGB = $derived(() => {
if (!data) return 0;
return (
Object.values(data.nodes).reduce((acc, n) => {
const total =
n.macmon_info?.memory?.ram_total ?? n.system_info?.memory ?? 0;
const used = n.macmon_info?.memory?.ram_usage ?? 0;
return acc + (total - used);
}, 0) /
(1024 * 1024 * 1024)
);
});
// Calculate total memory in the cluster (in GB)
const clusterTotalMemoryGB = $derived(() => {
if (!data) return 0;
return (
Object.values(data.nodes).reduce((acc, n) => {
const total =
n.macmon_info?.memory?.ram_total ?? n.system_info?.memory ?? 0;
return acc + total;
}, 0) /
(1024 * 1024 * 1024)
);
});
function getModelMemoryFitStatus(model: {
id: string;
name?: string;
storage_size_megabytes?: number;
}): ModelMemoryFitStatus {
const modelSizeGB = getModelSizeGB(model);
if (modelSizeGB <= availableMemoryGB()) {
return "fits_now";
}
if (modelSizeGB <= clusterTotalMemoryGB()) {
return "fits_cluster_capacity";
}
return "too_large";
}
// Check if a model has enough memory to run
function hasEnoughMemory(model: {
id: string;
name?: string;
storage_size_megabytes?: number;
}): boolean {
return getModelMemoryFitStatus(model) === "fits_now";
}
// Sorted models for dropdown - biggest first, unrunnable at the end
const sortedModels = $derived(() => {
return [...models].sort((a, b) => {
// First: models that have enough memory come before those that don't
const aCanFit = hasEnoughMemory(a);
const bCanFit = hasEnoughMemory(b);
if (aCanFit && !bCanFit) return -1;
if (!aCanFit && bCanFit) return 1;
// Then: sort by size (biggest first)
const aSize = getModelSizeGB(a);
const bSize = getModelSizeGB(b);
return bSize - aSize;
});
});
// Compute model tags (FASTEST, BIGGEST)
const modelTags = $derived(() => {
const tags: Record<string, string[]> = {};
if (models.length === 0) return tags;
// Find the fastest model (highest TPS)
let fastestId = "";
let highestTps = 0;
// Find the biggest model (most memory)
let biggestId = "";
let highestMemory = 0;
for (const model of models) {
const perf = estimatePerformance(model.id);
const mem = getModelSizeGB(model);
if (perf.tps > highestTps) {
highestTps = perf.tps;
fastestId = model.id;
}
if (mem > highestMemory) {
highestMemory = mem;
biggestId = model.id;
}
}
if (fastestId) {
tags[fastestId] = tags[fastestId] || [];
tags[fastestId].push("FASTEST");
}
if (biggestId && biggestId !== fastestId) {
tags[biggestId] = tags[biggestId] || [];
tags[biggestId].push("BIGGEST");
} else if (biggestId === fastestId && biggestId) {
// Same model is both - unlikely but handle it
tags[biggestId].push("BIGGEST");
}
return tags;
});
onMount(async () => {
mounted = true;
fetchModels();
fetch("/node_id")
.then((r) => (r.ok ? r.json() : null))
.then((id) => {
if (id) localNodeId = id;
})
.catch(() => {});
// Handle reset-onboarding query parameter (triggered from native Settings)
const params = new URLSearchParams(window.location.search);
if (params.has("reset-onboarding")) {
localStorage.removeItem(ONBOARDING_COMPLETE_KEY);
window.history.replaceState({}, "", window.location.pathname);
onboardingStep = 1;
return;
}
// Firefox AI sidebar integration: handle ?q= query parameter
// Firefox's built-in AI sidebar (about:config: browser.ml.chat.enabled) sends
// the user's prompt as ?q=<URL-encoded prompt> to the configured provider URL.
// See: https://support.mozilla.org/en-US/kb/ai-chatbot
const queryParam = params.get("q");
if (queryParam) {
// Clean up the URL to prevent re-submission on page refresh
window.history.replaceState({}, "", window.location.pathname);
// Defer the auto-send until cluster state is loaded (topologyData,
// instances, availableMemory) so that model auto-selection works
// correctly. The $effect below will pick this up once data is ready.
pendingFirefoxQuery = queryParam;
}
// Check server-side onboarding state (persisted in ~/.exo)
try {
const res = await fetch("/onboarding");
if (res.ok) {
const data = await res.json();
if (!data.completed) {
onboardingStep = 1;
}
return;
}
} catch {
// Server unreachable — fall through to localStorage
}
// Fallback: check localStorage
if (!localStorage.getItem(ONBOARDING_COMPLETE_KEY)) {
onboardingStep = 1;
}
});
// Deferred Firefox AI sidebar auto-send: wait for cluster state and model
// list before submitting. Both data (from /state polling) and models (from
// the async /models fetch in onMount) must be loaded for handleAutoSend to
// correctly auto-select a model.
$effect(() => {
if (pendingFirefoxQuery && data && models.length > 0) {
const query = pendingFirefoxQuery;
pendingFirefoxQuery = null;
handleChatSend(query);
}
});
async function fetchModels() {
try {
const response = await fetch("/models");
if (response.ok) {
const data = await response.json();
// API returns { data: [{ id, name }] } format
models = data.data || [];
// Restore last launch defaults if available
const currentNodeCount = topologyData()
? Object.keys(topologyData()!.nodes).length
: 1;
applyLaunchDefaults(models, currentNodeCount);
}
} catch (error) {
console.error("Failed to fetch models:", error);
}
}
async function addModelFromPicker(modelId: string) {
const response = await fetch("/models/add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model_id: modelId }),
});
if (!response.ok) {
let message = `Failed to add model (${response.status}: ${response.statusText})`;
try {
const err = await response.json();
if (err.detail) message = err.detail;
} catch {
// use default message
}
throw new Error(message);
}
await fetchModels();
}
async function deleteCustomModel(modelId: string) {
try {
const response = await fetch(
`/models/custom/${encodeURIComponent(modelId)}`,
{ method: "DELETE" },
);
if (response.ok) {
await fetchModels();
}
} catch {
console.error("Failed to delete custom model");
}
}
function handleModelPickerSelect(modelId: string) {
selectPreviewModel(modelId);
setSelectedChatModel(modelId);
saveLaunchDefaults();
isModelPickerOpen = false;
}
async function launchInstance(
modelId: string,
specificPreview?: PlacementPreview | null,
) {
if (!modelId || launchingModelId) return;
launchingModelId = modelId;
try {
// Use the specific preview if provided, otherwise fall back to filtered preview
const preview = specificPreview ?? filteredPreview();
let response: Response;
if (preview?.instance) {
// Launch with pre-computed placement from preview
response = await fetch("/instance", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instance: preview.instance }),
});
} else {
// No preview available — use place_instance to let server decide placement
response = await fetch("/place_instance", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model_id: modelId,
sharding: selectedSharding,
instance_meta: selectedInstanceType,
min_nodes: 1,
}),
});
}
if (!response.ok) {
const errorText = await response.text();
console.error("Failed to launch instance:", errorText);
addToast({
type: "error",
message: `Failed to launch model: ${errorText}`,
});
} else {
addToast({ type: "info", message: `Launching model...` });
// Always auto-select the newly launched model so the user chats to what they just launched
setSelectedChatModel(modelId);
userForcedIdle = false;
pendingChatModelId = modelId;
chatLaunchState = "launching";
// Record the launch in recent models history
recordRecentLaunch(modelId);
// Scroll to the bottom of instances container to show the new instance
// Use multiple attempts to ensure DOM has updated with the new instance
const scrollToBottom = () => {
if (instancesContainerRef) {
instancesContainerRef.scrollTo({
top: instancesContainerRef.scrollHeight,
behavior: "smooth",
});
}
};
setTimeout(scrollToBottom, 200);
setTimeout(scrollToBottom, 500);
setTimeout(scrollToBottom, 1000);
}
} catch (error) {
console.error("Error launching instance:", error);
addToast({
type: "error",
message: "Failed to launch model. Check console for details.",
});
} finally {
launchingModelId = null;
}
}
// Helper to extract model ID from download shard metadata
function extractModelIdFromDownload(
downloadPayload: Record<string, unknown>,
): string | null {
const shardMetadata =
downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
if (!shardMetadata || typeof shardMetadata !== "object") return null;
// Shard metadata is a tagged union: { PipelineShardMetadata: {...} } or { TensorShardMetadata: {...} }
const shardObj = shardMetadata as Record<string, unknown>;
const shardKeys = Object.keys(shardObj);
if (shardKeys.length !== 1) return null;
const shardData = shardObj[shardKeys[0]] as Record<string, unknown>;
if (!shardData) return null;
// Model meta is nested: shard.model_card.model_id
const modelMeta = shardData.model_card ?? shardData.modelCard;
if (!modelMeta || typeof modelMeta !== "object") return null;
const meta = modelMeta as Record<string, unknown>;
return (meta.model_id as string) ?? (meta.modelId as string) ?? null;
}
// Helper to parse download progress from payload
function parseDownloadProgress(
payload: Record<string, unknown>,
): DownloadProgress | null {
const progress = payload.download_progress ?? payload.downloadProgress;
if (!progress || typeof progress !== "object") return null;
const prog = progress as Record<string, unknown>;
const totalBytes = getBytes(prog.total);
const downloadedBytes = getBytes(prog.downloaded);
const speed = (prog.speed as number) ?? 0;
const completedFiles =
(prog.completed_files as number) ?? (prog.completedFiles as number) ?? 0;
const totalFiles =
(prog.total_files as number) ?? (prog.totalFiles as number) ?? 0;
const etaMs = (prog.eta_ms as number) ?? (prog.etaMs as number) ?? 0;
const files: DownloadProgress["files"] = [];
const filesObj = (prog.files ?? {}) as Record<string, unknown>;
for (const [fileName, fileData] of Object.entries(filesObj)) {
if (!fileData || typeof fileData !== "object") continue;
const fd = fileData as Record<string, unknown>;
const fTotal = getBytes(fd.total);
const fDownloaded = getBytes(fd.downloaded);
files.push({
name: fileName,
totalBytes: fTotal,
downloadedBytes: fDownloaded,
speed: (fd.speed as number) ?? 0,
etaMs: (fd.eta_ms as number) ?? (fd.etaMs as number) ?? 0,
percentage: fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0,
});
}
return {
totalBytes,
downloadedBytes,
speed,
etaMs:
etaMs ||
(speed > 0 ? ((totalBytes - downloadedBytes) / speed) * 1000 : 0),
percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
completedFiles,
totalFiles,
files,
};
}
// Helper to get download status for a model (checks all downloads for matching model ID)
type NodeDownloadStatus = {
nodeId: string;
nodeName: string;
status: "completed" | "partial" | "pending" | "downloading";
percentage: number;
progress: DownloadProgress | null;
};
// Shared helper: collect per-node download status for a model across a set of nodes.
// Handles deduplication, entry parsing, and aggregation in one place.
function collectDownloadStatus(
modelId: string,
nodeIds?: string[],
): {
isDownloading: boolean;
progress: DownloadProgress | null;
perNode: NodeDownloadStatus[];
failedError: string | null;
} {
const empty = {
isDownloading: false,
progress: null,
perNode: [] as NodeDownloadStatus[],
failedError: null,
};
if (!downloadsData || Object.keys(downloadsData).length === 0) {
return empty;
}
// Deduplicate by nodeId — a node can have multiple entries for the same model
// (e.g. PipelineShardMetadata + TensorShardMetadata). Keep the last entry,
// which is the most recently applied event.
const perNodeMap = new Map<string, NodeDownloadStatus>();
const nodeIdSet = nodeIds ? new Set(nodeIds) : null;
for (const [nodeId, nodeDownloads] of Object.entries(downloadsData)) {
if (nodeIdSet && !nodeIdSet.has(nodeId)) continue;
if (!Array.isArray(nodeDownloads)) continue;
for (const downloadWrapped of nodeDownloads) {
if (!downloadWrapped || typeof downloadWrapped !== "object") continue;
const keys = Object.keys(downloadWrapped as Record<string, unknown>);
if (keys.length !== 1) continue;
const downloadKind = keys[0];
const downloadPayload = (downloadWrapped as Record<string, unknown>)[
downloadKind
] as Record<string, unknown>;
if (!downloadPayload) continue;
const downloadModelId = extractModelIdFromDownload(downloadPayload);
if (!downloadModelId || downloadModelId !== modelId) continue;
// DownloadFailed — return with any data collected so far
if (downloadKind === "DownloadFailed") {
return {
isDownloading: false,
progress: null,
perNode: Array.from(perNodeMap.values()),
failedError:
(downloadPayload.errorMessage as string) ||
(downloadPayload.error_message as string) ||
"Download failed",
};
}
if (
downloadKind !== "DownloadOngoing" &&
downloadKind !== "DownloadPending" &&
downloadKind !== "DownloadCompleted"
)
continue;
const nodeName =
data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
if (downloadKind === "DownloadCompleted") {
perNodeMap.set(nodeId, {
nodeId,
nodeName,
status: "completed",
percentage: 100,
progress: null,
});
continue;
}
if (downloadKind === "DownloadPending") {
const pendingDownloaded = getBytes(
downloadPayload.downloaded ??
downloadPayload.downloaded_bytes ??
downloadPayload.downloadedBytes,
);
const pendingTotal = getBytes(
downloadPayload.total ??
downloadPayload.total_bytes ??
downloadPayload.totalBytes,
);
if (pendingDownloaded <= 0 && pendingTotal <= 0) continue;
const pct =
pendingTotal > 0 ? (pendingDownloaded / pendingTotal) * 100 : 0;
perNodeMap.set(nodeId, {
nodeId,
nodeName,
status: pendingDownloaded > 0 ? "partial" : "pending",
percentage: pct,
progress: null,
});
continue;
}
// DownloadOngoing
const progress = parseDownloadProgress(downloadPayload);
if (
!progress ||
(progress.downloadedBytes <= 0 && progress.totalBytes <= 0)
)
continue;
perNodeMap.set(nodeId, {
nodeId,
nodeName,
status: "downloading",
percentage: progress.percentage,
progress,
});
}
}
// Aggregate from deduplicated per-node entries
const perNode = Array.from(perNodeMap.values());
let totalBytes = 0;
let downloadedBytes = 0;
let totalSpeed = 0;
let completedFiles = 0;
let totalFiles = 0;
let isDownloading = false;
const allFiles: DownloadProgress["files"] = [];
for (const node of perNode) {
if (node.status === "downloading" && node.progress) {
isDownloading = true;
totalBytes += node.progress.totalBytes;
downloadedBytes += node.progress.downloadedBytes;
totalSpeed += node.progress.speed;
completedFiles += node.progress.completedFiles;
totalFiles += node.progress.totalFiles;
allFiles.push(...node.progress.files);
}
}
if (!isDownloading) {
return {
isDownloading: false,
progress: null,
perNode,
failedError: null,
};
}
const remainingBytes = totalBytes - downloadedBytes;
const etaMs = totalSpeed > 0 ? (remainingBytes / totalSpeed) * 1000 : 0;
return {
isDownloading: true,
progress: {
totalBytes,
downloadedBytes,
speed: totalSpeed,
etaMs,
percentage: totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0,
completedFiles,
totalFiles,
files: allFiles,
},
perNode,
failedError: null,
};
}
function getModelDownloadStatus(
modelId: string,
nodeIds?: string[],
): {
isDownloading: boolean;
progress: DownloadProgress | null;
perNode: NodeDownloadStatus[];
} {
return collectDownloadStatus(modelId, nodeIds);
}
// Helper to get download status for an instance
function getInstanceDownloadStatus(
instanceId: string,
instanceWrapped: unknown,
): {
isDownloading: boolean;
isFailed: boolean;
errorMessage: string | null;
progress: DownloadProgress | null;
statusText: string;
perNode: NodeDownloadStatus[];
} {
// Unwrap the instance to get shard assignments
const [instanceTag, instance] = getTagged(instanceWrapped);
if (!instance || typeof instance !== "object") {
return {
isDownloading: false,
isFailed: false,
errorMessage: null,
progress: null,
statusText: "PREPARING",
perNode: [],
};
}
const inst = instance as {
shardAssignments?: {
nodeToRunner?: Record<string, string>;
runnerToShard?: Record<string, unknown>;
modelId?: string;
};
};
const instanceModelId = inst.shardAssignments?.modelId;
if (!instanceModelId) {
const statusInfo = deriveInstanceStatus(instanceWrapped);
return {
isDownloading: false,
isFailed: statusInfo.statusText === "FAILED",
errorMessage: null,
progress: null,
statusText: statusInfo.statusText,
perNode: [],
};
}
// Get node IDs assigned to this instance
const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
const runnerToShard = inst.shardAssignments?.runnerToShard || {};
const runnerToNode: Record<string, string> = {};
for (const [nodeId, runnerId] of Object.entries(nodeToRunner)) {
runnerToNode[runnerId] = nodeId;
}
const instanceNodeIds = Object.keys(runnerToShard)
.map((runnerId) => runnerToNode[runnerId])
.filter(Boolean);
const result = collectDownloadStatus(instanceModelId, instanceNodeIds);
if (result.failedError) {
return {
isDownloading: false,
isFailed: true,
errorMessage: result.failedError,
progress: null,
statusText: "FAILED",
perNode: [],
};
}
if (!result.isDownloading) {
const statusInfo = deriveInstanceStatus(instanceWrapped);
return {
isDownloading: false,
isFailed: statusInfo.statusText === "FAILED",
errorMessage: null,
progress: null,
statusText: statusInfo.statusText,
perNode: result.perNode,
};
}
return {
isDownloading: true,
isFailed: false,
errorMessage: null,
progress: result.progress,
statusText: "DOWNLOADING",
perNode: result.perNode,
};
}
// Derive instance status from runners
// Get color class for a status
function getStatusColor(statusText: string): string {
switch (statusText) {
case "FAILED":
return "text-red-400";
case "SHUTDOWN":
return "text-gray-400";
case "DOWNLOADING":
return "text-blue-400";
case "LOADING":
case "WARMING UP":
case "WAITING":
case "INITIALIZING":
return "text-yellow-400";
case "RUNNING":
return "text-teal-400";
case "READY":
case "LOADED":
return "text-green-400";
default:
return "text-exo-light-gray";
}
}
function deriveInstanceStatus(instanceWrapped: unknown): {
statusText: string;
statusClass: string;
layersLoaded?: number;
totalLayers?: number;
} {
const [instanceTag, instance] = getTagged(instanceWrapped);
if (!instance || typeof instance !== "object") {
return { statusText: "PREPARING", statusClass: "inactive" };
}
const inst = instance as {
shardAssignments?: { runnerToShard?: Record<string, unknown> };
};
const runnerIds = Object.keys(inst.shardAssignments?.runnerToShard || {});
const statuses = runnerIds
.map((rid) => {
const r = runnersData[rid];
if (!r) return null;
const [kind] = getTagged(r);
const statusMap: Record<string, string> = {
RunnerWaitingForInitialization: "WaitingForInitialization",
RunnerInitializingBackend: "InitializingBackend",
RunnerWaitingForModel: "WaitingForModel",
RunnerLoading: "Loading",
RunnerLoaded: "Loaded",
RunnerWarmingUp: "WarmingUp",
RunnerReady: "Ready",
RunnerRunning: "Running",
RunnerShutdown: "Shutdown",
RunnerFailed: "Failed",
};
return kind ? statusMap[kind] || null : null;
})
.filter((s): s is string => s !== null);
const has = (s: string) => statuses.includes(s);
if (statuses.length === 0)
return { statusText: "PREPARING", statusClass: "inactive" };
if (has("Failed")) return { statusText: "FAILED", statusClass: "failed" };
if (has("Shutdown"))
return { statusText: "SHUTDOWN", statusClass: "inactive" };
if (has("Loading")) {
// Tensor parallel: each runner loads all layers — use max/min (bottleneck)
// Pipeline parallel: each runner loads a disjoint slice — use sum
const isTensor = instanceTag === "MlxJacclInstance";
let layersLoaded = isTensor ? Infinity : 0;
let totalLayers = 0;
for (const rid of runnerIds) {
const r = runnersData[rid];
if (!r) continue;
const [kind, payload] = getTagged(r);
if (
kind === "RunnerLoading" &&
payload &&
typeof payload === "object"
) {
const p = payload as { layersLoaded?: number; totalLayers?: number };
if (isTensor) {
layersLoaded = Math.min(layersLoaded, p.layersLoaded ?? 0);
totalLayers = Math.max(totalLayers, p.totalLayers ?? 0);
} else {
layersLoaded += p.layersLoaded ?? 0;
totalLayers += p.totalLayers ?? 0;
}
}
}
if (isTensor && layersLoaded === Infinity) layersLoaded = 0;
return {
statusText: "LOADING",
statusClass: "starting",
layersLoaded,
totalLayers,
};
}
if (has("WarmingUp"))
return { statusText: "WARMING UP", statusClass: "starting" };
if (has("Running"))
return { statusText: "RUNNING", statusClass: "running" };
if (has("Ready")) return { statusText: "READY", statusClass: "loaded" };
if (has("Loaded")) return { statusText: "LOADED", statusClass: "loaded" };
if (has("WaitingForModel"))
return { statusText: "WAITING", statusClass: "starting" };
if (has("InitializingBackend"))
return { statusText: "INITIALIZING", statusClass: "starting" };
if (has("WaitingForInitialization"))
return { statusText: "INITIALIZING", statusClass: "starting" };
return { statusText: "RUNNING", statusClass: "active" };
}
function getBytes(value: unknown): number {
if (typeof value === "number") return value;
if (value && typeof value === "object") {
const v = value as Record<string, unknown>;
if (typeof v.inBytes === "number") return v.inBytes;
}
return 0;
}
async function deleteInstance(instanceId: string) {
if (!confirm(`Delete instance ${instanceId.slice(0, 8)}...?`)) return;
// Get the model ID of the instance being deleted before we delete it
const deletedInstanceModelId = getInstanceModelId(instanceData[instanceId]);
const wasSelected = selectedChatModel() === deletedInstanceModelId;
try {
const response = await fetch(`/instance/${instanceId}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
console.error("Failed to delete instance:", response.status);
addToast({ type: "error", message: "Failed to delete instance" });
} else if (wasSelected) {
// If we deleted the currently selected model, switch to another available model
// Find another instance that isn't the one we just deleted
const remainingInstances = Object.entries(instanceData).filter(
([id]) => id !== instanceId,
);
if (remainingInstances.length > 0) {
// Select the last instance (most recently added, since objects preserve insertion order)
const [, lastInstance] =
remainingInstances[remainingInstances.length - 1];
const newModelId = getInstanceModelId(lastInstance);
if (
newModelId &&
newModelId !== "Unknown" &&
newModelId !== "Unknown Model"
) {
setSelectedChatModel(newModelId);
} else {
// Clear selection if no valid model found
setSelectedChatModel("");
}
} else {
// No more instances, clear the selection
setSelectedChatModel("");
}
}
} catch (error) {
console.error("Error deleting instance:", error);
}
}
// Helper to unwrap tagged unions like { MlxRingInstance: {...} }
function getTagged(obj: unknown): [string | null, unknown] {
if (!obj || typeof obj !== "object") return [null, null];
const keys = Object.keys(obj as Record<string, unknown>);
if (keys.length === 1) {
return [keys[0], (obj as Record<string, unknown>)[keys[0]]];
}
return [null, null];
}
// Get model ID from an instance
function getInstanceModelId(instanceWrapped: unknown): string {
const [, instance] = getTagged(instanceWrapped);
if (!instance || typeof instance !== "object") return "Unknown";
const inst = instance as { shardAssignments?: { modelId?: string } };
return inst.shardAssignments?.modelId || "Unknown Model";
}
// Get instance details: type (MLX Ring/IBV), sharding (Pipeline/Tensor), and node names
function getInstanceInfo(instanceWrapped: unknown): {
instanceType: string;
sharding: string;
nodeNames: string[];
nodeIds: string[];
nodeCount: number;
} {
const [instanceTag, instance] = getTagged(instanceWrapped);
if (!instance || typeof instance !== "object") {
return {
instanceType: "Unknown",
sharding: "Unknown",
nodeNames: [],
nodeIds: [],
nodeCount: 0,
};
}
// Instance type from tag
let instanceType = "Unknown";
if (instanceTag === "MlxRingInstance") instanceType = "MLX Ring";
else if (instanceTag === "MlxJacclInstance") instanceType = "MLX RDMA";
const inst = instance as {
shardAssignments?: {
nodeToRunner?: Record<string, string>;
runnerToShard?: Record<string, unknown>;
};
};
// Sharding strategy from first shard
let sharding = "Unknown";
const runnerToShard = inst.shardAssignments?.runnerToShard || {};
const firstShardWrapped = Object.values(runnerToShard)[0];
if (firstShardWrapped) {
const [shardTag] = getTagged(firstShardWrapped);
if (shardTag === "PipelineShardMetadata") sharding = "Pipeline";
else if (shardTag === "TensorShardMetadata") sharding = "Tensor";
else if (shardTag === "PrefillDecodeShardMetadata")
sharding = "Prefill/Decode";
}
// Node names from topology
const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
const nodeIds = Object.keys(nodeToRunner);
const nodeNames = nodeIds.map((nodeId) => {
const node = data?.nodes?.[nodeId];
return node?.friendly_name || nodeId.slice(0, 8);
});
return {
instanceType,
sharding,
nodeNames,
nodeIds,
nodeCount: nodeIds.length,
};
}
// Compute instance statuses by modelId for the model picker
const modelInstanceStatuses = $derived.by(() => {
const result: Record<string, { status: string; statusClass: string }> = {};
for (const [id, inst] of Object.entries(instanceData)) {
const modelId = getInstanceModelId(inst);
if (!modelId || modelId === "Unknown" || modelId === "Unknown Model")
continue;
const dlStatus = getInstanceDownloadStatus(id, inst);
const statusText = dlStatus.statusText;
let statusClass = "inactive";
if (
statusText === "READY" ||
statusText === "RUNNING" ||
statusText === "LOADED"
) {
statusClass = "ready";
} else if (statusText === "DOWNLOADING") {
statusClass = "downloading";
} else if (statusText === "LOADING" || statusText === "WARMING UP") {
statusClass = "loading";
}
// Keep the best status per modelId (ready > loading > downloading > other)
const existing = result[modelId];
if (existing) {
const rank = (c: string) =>
c === "ready" ? 3 : c === "loading" ? 2 : c === "downloading" ? 1 : 0;
if (rank(statusClass) <= rank(existing.statusClass)) continue;
}
result[modelId] = { status: statusText, statusClass };
}
return result;
});
function formatLastUpdate(): string {
if (!update) return "ACQUIRING...";
const seconds = Math.floor((Date.now() - update) / 1000);
if (seconds < 5) return "LIVE";
return `${seconds}s AGO`;
}
function formatBytes(bytes: number, decimals = 2): string {
if (!bytes || bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (
parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i]
);
}
function formatSpeed(bps: number): string {
if (!bps || bps <= 0) return "0 B/s";
return formatBytes(bps, 1) + "/s";
}
function getNodeLabel(nodeId: string): string {
const node = data?.nodes?.[nodeId];
return node?.friendly_name || nodeId.slice(0, 8);
}
function getInterfaceLabel(
nodeId: string,
ip?: string,
): { label: string; missing: boolean } {
if (!ip) return { label: "?", missing: true };
const node = data?.nodes?.[nodeId];
if (!node) return { label: "?", missing: true };
// Prefer explicit network_interfaces from NodePerformanceProfile
const matchFromInterfaces = node.network_interfaces?.find((iface) =>
(iface.addresses || []).some((addr) => addr === ip),
);
if (matchFromInterfaces?.name) {
return {
label: `${matchFromInterfaces.name} on ${getNodeLabel(nodeId)}`,
missing: false,
};
}
// Fallback to derived ip_to_interface map
const mapped = node.ip_to_interface?.[ip];
if (mapped && mapped.trim().length > 0) {
return { label: `${mapped} on ${getNodeLabel(nodeId)}`, missing: false };
}
return { label: "?", missing: true };
}
function getOrderedRunnerNodes(
instance: Record<string, unknown>,
shardType: "Pipeline" | "Tensor",
) {
const runnerToShard =
(
instance.shardAssignments as
| { runnerToShard?: Record<string, unknown> }
| undefined
)?.runnerToShard || {};
const nodeToRunner =
(
instance.shardAssignments as
| { nodeToRunner?: Record<string, string> }
| undefined
)?.nodeToRunner || {};
const runnerEntries = Object.entries(runnerToShard).map(
([runnerId, shardWrapped]) => {
const [tag, shard] = getTagged(shardWrapped);
const meta = shard as
| {
modelMeta?: {
worldSize?: number;
nLayers?: number;
deviceRank?: number;
};
}
| undefined;
const deviceRank = meta?.modelMeta?.deviceRank ?? 0;
return { runnerId, tag, deviceRank };
},
);
const ordered = runnerEntries
.filter((r) =>
shardType === "Pipeline"
? r.tag === "PipelineShardMetadata"
: r.tag === "TensorShardMetadata",
)
.sort((a, b) => a.deviceRank - b.deviceRank)
.map((r, idx) => {
const nodeId = Object.entries(nodeToRunner).find(
([, rid]) => rid === r.runnerId,
)?.[0];
return { nodeId, runnerId: r.runnerId, order: idx };
})
.filter((item) => item.nodeId);
return ordered as Array<{
nodeId: string;
runnerId: string;
order: number;
}>;
}
function pickHost(
hosts?: Array<{ ip: string; port: number }>,
): { ip: string; port: number } | null {
if (!hosts || hosts.length === 0) return null;
const scored = hosts
.filter((h) => h.ip && h.ip !== "0.0.0.0" && h.port && h.port > 0)
.map((h) => {
const ip = h.ip;
const score =
ip.startsWith("10.") ||
ip.startsWith("172.") ||
ip.startsWith("192.168")
? 3
: ip.startsWith("169.254")
? 2
: 1;
return { host: h, score };
});
if (scored.length === 0) return null;
scored.sort((a, b) => b.score - a.score);
return scored[0].host;
}
function getInstanceConnections(instanceWrapped: unknown): Array<{
from: string;
to: string;
ip: string;
ifaceLabel: string;
missingIface: boolean;
}> {
const [instanceTag, instance] = getTagged(instanceWrapped);
if (!instance || typeof instance !== "object") return [];
// Jaccl (RDMA) – show RDMA interfaces from ibvDevices
if (instanceTag === "MlxJacclInstance") {
const ordered = getOrderedRunnerNodes(
instance as Record<string, unknown>,
"Tensor",
);
const ibvDevices =
(instance as { ibvDevices?: Array<Array<string | null>> }).ibvDevices ||
[];
const rows: Array<{
from: string;
to: string;
ip: string;
ifaceLabel: string;
missingIface: boolean;
}> = [];
for (let i = 0; i < ordered.length; i++) {
for (let j = i + 1; j < ordered.length; j++) {
const iface = ibvDevices[i]?.[j] ?? ibvDevices[j]?.[i] ?? null;
if (!iface) continue;
const fromId = ordered[i].nodeId;
const toId = ordered[j].nodeId;
rows.push({
from: getNodeLabel(fromId),
to: getNodeLabel(toId),
ip: iface,
ifaceLabel: `RDMA ${iface}`,
missingIface: false,
});
}
}
return rows;
}
// Ring – derive ring order from pipeline shard ranks and pick host IPs from hostsByNode
if (instanceTag === "MlxRingInstance") {
const ordered = getOrderedRunnerNodes(
instance as Record<string, unknown>,
"Pipeline",
);
const hostsByNode =
(
instance as {
hostsByNode?: Record<string, Array<{ ip: string; port: number }>>;
}
).hostsByNode || {};
const rows: Array<{
from: string;
to: string;
ip: string;
ifaceLabel: string;
missingIface: boolean;
}> = [];
if (ordered.length === 0) return rows;
for (let idx = 0; idx < ordered.length; idx++) {
const current = ordered[idx];
const next = ordered[(idx + 1) % ordered.length];
const host = pickHost(hostsByNode[next.nodeId]);
const ip = host ? `${host.ip}:${host.port}` : "?";
const ifaceInfo = host
? getInterfaceLabel(next.nodeId, host.ip)
: { label: "?", missing: true };
rows.push({
from: getNodeLabel(current.nodeId),
to: getNodeLabel(next.nodeId),
ip,
ifaceLabel: ifaceInfo.label,
missingIface: ifaceInfo.missing,
});
}
return rows;
}
return [];
}
function formatEta(ms: number): string {
if (!ms || ms <= 0) return "--";
const totalSeconds = Math.round(ms / 1000);
const s = totalSeconds % 60;
const m = Math.floor(totalSeconds / 60) % 60;
const h = Math.floor(totalSeconds / 3600);
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function handleNewChat() {
chatLaunchState = "idle";
pendingChatModelId = null;
selectedChatCategory = null;
pendingAutoMessage = null;
userForcedIdle = true;
setSelectedChatModel("");
createConversation();
}
function handleGoHome() {
chatLaunchState = "idle";
pendingChatModelId = null;
selectedChatCategory = null;
pendingAutoMessage = null;
userForcedIdle = true;
// Restore chat model from the sidebar preview selection so both selectors stay in sync
setSelectedChatModel(selectedModelId ?? "");
clearChat();
}
// Slider drag handlers
function handleSliderDrag(clientX: number) {
if (!sliderTrackElement || availableMinNodes <= 1) return;
const rect = sliderTrackElement.getBoundingClientRect();
const percentage = Math.max(
0,
Math.min(1, (clientX - rect.left) / rect.width),
);
const rawValue = Math.round(percentage * (availableMinNodes - 1)) + 1;
const clampedValue = Math.max(1, Math.min(availableMinNodes, rawValue));
// Find nearest valid value
const validCounts = validMinNodeCounts();
if (validCounts.has(clampedValue)) {
selectedMinNodes = clampedValue;
} else {
// Find nearest valid value
let nearest = clampedValue;
let minDist = Infinity;
for (const v of validCounts) {
const dist = Math.abs(v - clampedValue);
if (dist < minDist) {
minDist = dist;
nearest = v;
}
}
if (validCounts.size > 0) {
selectedMinNodes = nearest;
}
}
}
function handleSliderMouseDown(event: MouseEvent) {
isDraggingSlider = true;
handleSliderDrag(event.clientX);
}
function handleSliderMouseMove(event: MouseEvent) {
if (isDraggingSlider) {
handleSliderDrag(event.clientX);
}
}
function handleSliderMouseUp() {
isDraggingSlider = false;
saveLaunchDefaults();
}
// Handle touch events for mobile
function handleSliderTouchStart(event: TouchEvent) {
isDraggingSlider = true;
if (event.touches.length > 0) {
handleSliderDrag(event.touches[0].clientX);
}
}
function handleSliderTouchMove(event: TouchEvent) {
if (isDraggingSlider && event.touches.length > 0) {
event.preventDefault();
handleSliderDrag(event.touches[0].clientX);
}
}
function handleSliderTouchEnd() {
isDraggingSlider = false;
saveLaunchDefaults();
}
const nodeCount = $derived(data ? Object.keys(data.nodes).length : 0);
const instanceCount = $derived(Object.keys(instanceData).length);
// ── Instance status transition toasts ──
// Track previous statuses so we can detect meaningful transitions and fire toasts.
let previousInstanceStatuses: Record<string, string> = {};
$effect(() => {
const currentStatuses: Record<string, string> = {};
for (const [id, inst] of Object.entries(instanceData)) {
const dlStatus = getInstanceDownloadStatus(id, inst);
currentStatuses[id] = dlStatus.statusText;
}
const prev = previousInstanceStatuses;
// Only fire toasts if we had a previous snapshot (skip the very first poll)
if (Object.keys(prev).length > 0) {
for (const [id, currentStatus] of Object.entries(currentStatuses)) {
const prevStatus = prev[id];
if (!prevStatus || prevStatus === currentStatus) continue;
const modelId = getInstanceModelId(instanceData[id]);
const shortName = modelId
? (modelId.split("/").pop() ?? modelId)
: id.slice(0, 8);
// Downloading -> non-downloading, non-failure = download complete
if (
prevStatus === "DOWNLOADING" &&
currentStatus !== "DOWNLOADING" &&
currentStatus !== "FAILED"
) {
addToast({
type: "success",
message: `Download complete: ${shortName}`,
});
}
// Loading/Warming Up -> Ready/Loaded/Running = model ready
if (
(prevStatus === "LOADING" || prevStatus === "WARMING UP") &&
(currentStatus === "READY" ||
currentStatus === "LOADED" ||
currentStatus === "RUNNING")
) {
addToast({ type: "success", message: `Model ready: ${shortName}` });
}
// Any -> Failed
if (prevStatus !== "FAILED" && currentStatus === "FAILED") {
addToast({ type: "error", message: `Model failed: ${shortName}` });
}
// Any -> Shutdown
if (prevStatus !== "SHUTDOWN" && currentStatus === "SHUTDOWN") {
addToast({ type: "info", message: `Model shut down: ${shortName}` });
}
}
}
previousInstanceStatuses = currentStatuses;
});
// ── Connection status toasts ──
let previousConnectionStatus: boolean | null = null;
$effect(() => {
const connected = isConnected();
if (previousConnectionStatus !== null) {
if (previousConnectionStatus && !connected) {
addToast({
type: "warning",
message: "Connection to server lost",
persistent: true,
});
} else if (!previousConnectionStatus && connected) {
dismissByMessage("Connection to server lost");
addToast({ type: "success", message: "Connection restored" });
}
}
previousConnectionStatus = connected;
});
const suggestedPrompts = [
"Write a poem about the ocean",
"Explain quantum computing simply",
"Help me debug my code",
"Tell me a creative story",
];
// ── Seamless chat: launch models from chat view ──
type InFlightChatLaunchState = "launching" | "downloading" | "loading";
type ReadyLikeChatLaunchState = "idle" | "ready";
type ChatLaunchState = InFlightChatLaunchState | ReadyLikeChatLaunchState;
let chatLaunchState = $state<ChatLaunchState>("idle");
let pendingChatModelId = $state<string | null>(null);
let selectedChatCategory = $state<string | null>(null);
// Guard: when true, the restore $effect must not override chatLaunchState.
// Set by handleNewChat/handleGoHome; cleared when the user picks a model.
let userForcedIdle = $state(false);
// Restore chat launch state when switching conversations
$effect(() => {
const currentModel = selectedChatModel();
// When the user explicitly requested the model selector (New Chat / Go Home),
// skip restoring state so the selector stays visible.
if (userForcedIdle) return;
if (!currentModel) {
if (chatStarted && chatLaunchState !== "idle") {
chatLaunchState = "idle";
pendingChatModelId = null;
selectedChatCategory = null;
}
return;
}
// Model is already running — no progress to show
if (hasRunningInstance(currentModel)) {
if (chatLaunchState !== "ready") {
chatLaunchState = "ready";
}
pendingChatModelId = currentModel;
return;
}
// Model is downloading
const dlStatus = getModelDownloadStatus(currentModel);
if (dlStatus.isDownloading) {
chatLaunchState = "downloading";
pendingChatModelId = currentModel;
return;
}
// Model is loading or in another pre-ready state
for (const [, inst] of Object.entries(instanceData)) {
if (getInstanceModelId(inst) !== currentModel) continue;
const status = deriveInstanceStatus(inst);
if (status.statusText === "LOADING") {
chatLaunchState = "loading";
pendingChatModelId = currentModel;
return;
}
if (
status.statusText === "WARMING UP" ||
status.statusText === "WAITING" ||
status.statusText === "INITIALIZING" ||
status.statusText === "PREPARING"
) {
chatLaunchState = "launching";
pendingChatModelId = currentModel;
return;
}
}
// Fallthrough: model exists but has no active instance/download/loading state
chatLaunchState = "idle";
pendingChatModelId = null;
selectedChatCategory = null;
});
// Suggested prompts per category
const categorySuggestedPrompts: Record<string, string[]> = {
coding: [
"Write a Snake game in Python",
"Build a REST API with FastAPI",
"Explain how async/await works",
"Help me write unit tests for my code",
],
writing: [
"Write a short story about time travel",
"Draft a professional email to a client",
"Create a haiku about the ocean",
"Summarize the key ideas of stoicism",
],
agentic: [
"Plan a weekend trip to Tokyo",
"Research and compare React vs Svelte",
"Create a step-by-step guide to learn ML",
"Analyze the pros and cons of remote work",
],
biggest: [
"Explain quantum computing simply",
"Help me brainstorm startup ideas",
"What are the key differences between TCP and UDP?",
"Write a Python script to analyze a CSV file",
],
auto: [
"Explain quantum computing simply",
"Help me brainstorm ideas for a side project",
"Write a Python function to sort a list",
"What makes a great technical interview?",
],
};
// Cluster label for ChatModelSelector header
const chatClusterLabel = $derived.by(() => {
if (!data) return "your Mac";
const nodes = Object.values(data.nodes);
if (nodes.length === 0) return "your Mac";
if (nodes.length === 1) {
const node = nodes[0];
const name = node.system_info?.model_id || "your Mac";
const totalMem =
node.macmon_info?.memory?.ram_total ?? node.system_info?.memory ?? 0;
const memGB = Math.round(totalMem / (1024 * 1024 * 1024));
return `${name} ${memGB}GB`;
}
const totalMemGB = Math.round(clusterTotalMemoryGB());
return `cluster ${totalMemGB}GB`;
});
// Check if a model already has a running instance
function hasRunningInstance(modelId: string): boolean {
for (const [, inst] of Object.entries(instanceData)) {
const id = getInstanceModelId(inst);
if (id === modelId) {
const status = deriveInstanceStatus(inst);
if (
status.statusText === "READY" ||
status.statusText === "LOADED" ||
status.statusText === "RUNNING"
) {
return true;
}
}
}
return false;
}
function hasExistingInstance(modelId: string): boolean {
for (const [, inst] of Object.entries(instanceData)) {
if (getInstanceModelId(inst) === modelId) return true;
}
return false;
}
// Pick optimal placement from previews (frontend logic)
// Rules: 1-node → Pipeline/Ring, multi-node with RDMA → Tensor/Jaccl (most nodes),
// multi-node without RDMA → 1-node Pipeline/Ring
function pickOptimalPlacement(
previews: PlacementPreview[],
): PlacementPreview | null {
const valid = previews.filter((p) => p.instance && !p.error);
// Check if any valid placement uses multiple nodes (indicates multi-node cluster)
const hasMultiNode = valid.some((p) => getPreviewNodeCount(p) > 1);
if (hasMultiNode) {
// Multi-node with RDMA: prefer Jaccl + Tensor with most nodes (fastest TPS)
const jacclTensor = valid
.filter(
(p) => p.instance_meta === "MlxJaccl" && p.sharding === "Tensor",
)
.sort((a, b) => getPreviewNodeCount(b) - getPreviewNodeCount(a));
if (jacclTensor.length > 0) return jacclTensor[0];
// Multi-node without RDMA: fall back to single-node Pipeline/Ring
const singlePipeline = valid.filter(
(p) =>
p.instance_meta === "MlxRing" &&
p.sharding === "Pipeline" &&
getPreviewNodeCount(p) === 1,
);
if (singlePipeline.length > 0) return singlePipeline[0];
}
// Single node (or final fallback): Pipeline/Ring with fewest nodes
const ringPipeline = valid
.filter((p) => p.instance_meta === "MlxRing" && p.sharding === "Pipeline")
.sort((a, b) => getPreviewNodeCount(a) - getPreviewNodeCount(b));
if (ringPipeline.length > 0) return ringPipeline[0];
// Last resort: any valid placement, fewest nodes
return (
valid.sort(
(a, b) => getPreviewNodeCount(a) - getPreviewNodeCount(b),
)[0] ?? null
);
}
// Launch a model for seamless chat
async function launchModelForChat(
modelId: string,
category: string,
skipCreate = false,
) {
userForcedIdle = false;
pendingChatModelId = modelId;
selectedChatCategory = category;
// Check if already running — skip straight to chat
if (hasRunningInstance(modelId)) {
setSelectedChatModel(modelId);
if (!skipCreate) createConversation();
chatLaunchState = "ready";
return;
}
// Already has an instance (downloading/loading) — attach to its progress
if (hasExistingInstance(modelId)) {
setSelectedChatModel(modelId);
pendingChatModelId = modelId;
if (!skipCreate) createConversation();
const dlStatus = getModelDownloadStatus(modelId);
if (dlStatus.isDownloading) {
chatLaunchState = "downloading";
} else {
chatLaunchState = "launching";
}
return;
}
chatLaunchState = "launching";
try {
// Fetch placement previews
const res = await fetch(
`/instance/previews?model_id=${encodeURIComponent(modelId)}`,
);
if (!res.ok) {
addToast({
type: "error",
message: `Failed to get placements: ${await res.text()}`,
});
chatLaunchState = "idle";
return;
}
const data: { previews: PlacementPreview[] } = await res.json();
const placement = pickOptimalPlacement(data.previews);
if (!placement) {
addToast({
type: "error",
message: "No valid placement found for this model",
});
chatLaunchState = "idle";
return;
}
// Launch the instance
const launchRes = await fetch("/instance", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instance: placement.instance }),
});
if (!launchRes.ok) {
addToast({
type: "error",
message: `Failed to launch: ${await launchRes.text()}`,
});
chatLaunchState = "idle";
return;
}
setSelectedChatModel(modelId);
recordRecentLaunch(modelId);
if (!skipCreate) createConversation();
chatLaunchState = "downloading";
} catch (error) {
addToast({ type: "error", message: `Network error: ${error}` });
chatLaunchState = "idle";
}
}
// Handle auto-send: user typed without selecting a model
async function handleAutoSend(
content: string,
files?: {
id: string;
name: string;
type: string;
textContent?: string;
preview?: string;
}[],
) {
// Clear forced-idle so restore effect resumes normal operation
userForcedIdle = false;
// Find the best already-running model by tier
let bestRunning: { id: string; tierIndex: number } | null = null;
for (const [, inst] of Object.entries(instanceData)) {
const modelId = getInstanceModelId(inst);
if (modelId === "Unknown" || modelId === "Unknown Model") continue;
if (!hasRunningInstance(modelId)) continue;
const info = models.find((m) => m.id === modelId);
if (!info) continue;
const tierIndex = getAutoTierIndex(info.base_model ?? "");
if (!bestRunning || tierIndex < bestRunning.tierIndex) {
bestRunning = { id: modelId, tierIndex };
}
}
// Find the best auto model that fits in available memory
const totalMem = availableMemoryGB();
const modelInfos = models.map((m) => ({
id: m.id,
name: m.name ?? "",
base_model: m.base_model ?? "",
storage_size_megabytes: m.storage_size_megabytes ?? 0,
capabilities: m.capabilities ?? [],
family: m.family ?? "",
quantization: m.quantization ?? "",
}));
const autoModel = pickAutoModel(modelInfos, totalMem);
// Prefer running model unless auto-pick is a strictly better tier
if (bestRunning) {
const autoTier = autoModel
? getAutoTierIndex(autoModel.base_model)
: Infinity;
if (autoTier >= bestRunning.tierIndex) {
// Running model is same or better tier — use it directly
setSelectedChatModel(bestRunning.id);
if (!chatStarted) createConversation();
routeMessage(content, files);
return;
}
}
if (!autoModel) {
addToast({
type: "error",
message: "No model fits in your available memory",
});
return;
}
// Check if the chosen auto model is already running
if (hasRunningInstance(autoModel.id)) {
setSelectedChatModel(autoModel.id);
if (!chatStarted) createConversation();
routeMessage(content, files);
return;
}
// Already has an instance (downloading/loading) — attach to its progress
if (hasExistingInstance(autoModel.id)) {
selectedChatCategory = "auto";
setSelectedChatModel(autoModel.id);
pendingChatModelId = autoModel.id;
if (!chatStarted) createConversation();
pendingAutoMessage = { content, files };
const dlStatus = getModelDownloadStatus(autoModel.id);
if (dlStatus.isDownloading) {
chatLaunchState = "downloading";
} else {
chatLaunchState = "launching";
}
return;
}
// Need to launch first, then send
selectedChatCategory = "auto";
pendingChatModelId = autoModel.id;
chatLaunchState = "launching";
try {
const res = await fetch(
`/instance/previews?model_id=${encodeURIComponent(autoModel.id)}`,
);
if (!res.ok) {
addToast({
type: "error",
message: `Failed to get placements: ${await res.text()}`,
});
chatLaunchState = "idle";
return;
}
const data: { previews: PlacementPreview[] } = await res.json();
const placement = pickOptimalPlacement(data.previews);
if (!placement) {
addToast({ type: "error", message: "No valid placement found" });
chatLaunchState = "idle";
return;
}
const launchRes = await fetch("/instance", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ instance: placement.instance }),
});
if (!launchRes.ok) {
addToast({
type: "error",
message: `Failed to launch: ${await launchRes.text()}`,
});
chatLaunchState = "idle";
return;
}
setSelectedChatModel(autoModel.id);
recordRecentLaunch(autoModel.id);
if (!chatStarted) createConversation();
chatLaunchState = "downloading";
// Queue the message to send once model is ready
pendingAutoMessage = { content, files };
} catch (error) {
addToast({ type: "error", message: `Network error: ${error}` });
chatLaunchState = "idle";
}
}
// Pending message to send after auto-launch completes
let pendingAutoMessage = $state<{
content: string;
files?: {
id: string;
name: string;
type: string;
textContent?: string;
preview?: string;
}[];
} | null>(null);
// Best running model by tier (for auto-pick display)
const bestRunningModelId = $derived.by(() => {
let best: { id: string; tierIndex: number } | null = null;
for (const [, inst] of Object.entries(instanceData)) {
const modelId = getInstanceModelId(inst);
if (modelId === "Unknown" || modelId === "Unknown Model") continue;
if (!hasRunningInstance(modelId)) continue;
const info = models.find((m) => m.id === modelId);
if (!info) continue;
const tierIndex = getAutoTierIndex(info.base_model ?? "");
if (!best || tierIndex < best.tierIndex) {
best = { id: modelId, tierIndex };
}
}
return best?.id ?? null;
});
// Track chat launch progress (download + loading)
const chatLaunchDownload = $derived.by(() => {
if (
!pendingChatModelId ||
(chatLaunchState !== "downloading" && chatLaunchState !== "launching")
)
return null;
const status = getModelDownloadStatus(pendingChatModelId);
if (status.isDownloading) return status.progress;
return null;
});
const chatLaunchLoadProgress = $derived.by(() => {
if (
!pendingChatModelId ||
chatLaunchState === "idle" ||
chatLaunchState === "ready"
)
return null;
let layersLoaded = 0,
totalLayers = 0;
for (const [, inst] of Object.entries(instanceData)) {
if (getInstanceModelId(inst) !== pendingChatModelId) continue;
const status = deriveInstanceStatus(inst);
if (
status.statusText === "LOADING" &&
status.totalLayers &&
status.totalLayers > 0
) {
layersLoaded += status.layersLoaded ?? 0;
totalLayers += status.totalLayers;
}
}
if (totalLayers === 0) return null;
return {
layersLoaded,
totalLayers,
percentage: (layersLoaded / totalLayers) * 100,
};
});
// Auto-advance chat launch state based on instance status
$effect(() => {
if (!pendingChatModelId || chatLaunchState === "idle") return;
// Check if model is now ready
if (hasRunningInstance(pendingChatModelId)) {
chatLaunchState = "ready";
// Send pending auto message if any
if (pendingAutoMessage) {
const msg = pendingAutoMessage;
pendingAutoMessage = null;
routeMessage(msg.content, msg.files);
}
return;
}
// If already ready (set by restore effect), don't downgrade state
if (chatLaunchState === "ready") return;
// Check if currently loading
if (chatLaunchLoadProgress) {
chatLaunchState = "loading";
return;
}
// Check if currently downloading
if (chatLaunchDownload) {
chatLaunchState = "downloading";
}
});
// Check if any instance is running (for showing model selector vs chat)
const hasAnyRunningInstance = $derived(() => {
for (const [, inst] of Object.entries(instanceData)) {
const status = deriveInstanceStatus(inst);
if (
status.statusText === "READY" ||
status.statusText === "LOADED" ||
status.statusText === "RUNNING"
) {
return true;
}
}
return false;
});
// Handle model selection from ChatModelSelector
function handleChatModelSelect(modelId: string, category: string) {
launchModelForChat(modelId, category);
}
// Handle "+ Add Model" from ChatModelSelector
function handleChatAddModel() {
modelPickerContext = "chat";
isModelPickerOpen = true;
}
// Track which context opened the model picker (dashboard launch vs chat selection)
let modelPickerContext = $state<"dashboard" | "chat">("dashboard");
// Open the model picker from a chat context (e.g. clicking the model button in ChatForm)
function openChatModelPicker() {
modelPickerContext = "chat";
isModelPickerOpen = true;
}
// Handle model selection from the picker when opened from chat context
function handleChatPickerSelect(modelId: string) {
setSelectedChatModel(modelId);
selectPreviewModel(modelId);
userForcedIdle = false;
isModelPickerOpen = false;
}
// Unified send handler: sends if model running, auto-launches if not
function handleChatSend(
content: string,
files?: {
id: string;
name: string;
type: string;
textContent?: string;
preview?: string;
}[],
) {
const model = selectedChatModel();
// Model is selected and running — send directly
if (model && hasRunningInstance(model)) {
chatLaunchState = "ready";
routeMessage(content, files);
return;
}
// Model is selected but NOT running — launch it, queue the message
if (model) {
pendingAutoMessage = { content, files };
userForcedIdle = false;
// The selected model is already being placed or loaded; keep the queued
// message and let the existing launch state effects send it once ready.
if (
pendingChatModelId === model &&
chatLaunchState !== "idle" &&
chatLaunchState !== "ready"
) {
return;
}
launchModelForChat(model, "picker", messages().length > 0);
return;
}
// No model selected — fall through to auto-pick
handleAutoSend(content, files);
}
// Helper to get the number of nodes in a placement preview
function getPreviewNodeCount(preview: PlacementPreview): number {
if (!preview.memory_delta_by_node) return 0;
// Count nodes that have non-zero memory delta (i.e. nodes actually used)
return Object.entries(preview.memory_delta_by_node).filter(
([_, delta]) => delta > 0,
).length;
}
// Available min nodes options based on topology (like old dashboard)
const availableMinNodes = $derived(Math.max(1, nodeCount));
// Compute which min node values have valid previews for the current model/sharding/instance type
// A minNodes value N is valid if there exists a placement with nodeCount >= N
// Note: previewsData already contains previews for the selected model (fetched via API)
const validMinNodeCounts = $derived(() => {
if (!selectedModelId || previewsData.length === 0) {
// If no model selected or no previews, allow all node counts (UI shows all as clickable)
return new Set(
Array.from({ length: availableMinNodes }, (_, i) => i + 1),
);
}
// Find the max node count among valid placements for this model/sharding/instance
// (model_id filter not needed since previewsData is already for selected model)
let maxValidNodes = 0;
for (const preview of previewsData) {
if (preview.sharding !== selectedSharding) continue;
if (!matchesSelectedRuntime(preview.instance_meta)) continue;
if (preview.error !== null) continue;
if (!preview.memory_delta_by_node) continue;
const previewNodes = getPreviewNodeCount(preview);
if (previewNodes > maxValidNodes) {
maxValidNodes = previewNodes;
}
}
// All values from 1 to maxValidNodes are valid (since there's a placement with >= that many nodes)
if (maxValidNodes === 0) return new Set<number>();
return new Set(Array.from({ length: maxValidNodes }, (_, i) => i + 1));
});
// Get ALL filtered previews based on current settings (matching minimum nodes)
// Note: previewsData already contains previews for the selected model (fetched via API)
// Backend handles node_ids filtering, we filter by sharding/instance type and min nodes
const filteredPreviews = $derived(() => {
if (!selectedModelId || previewsData.length === 0) return [];
// Find previews matching sharding/instance type (model_id filter not needed since previewsData is already for selected model)
const matchingPreviews = previewsData.filter(
(p: PlacementPreview) =>
p.sharding === selectedSharding &&
matchesSelectedRuntime(p.instance_meta) &&
p.error === null &&
p.memory_delta_by_node !== null,
);
// Filter to previews with node count >= selectedMinNodes, sorted by node count (ascending)
return matchingPreviews
.filter(
(p: PlacementPreview) => getPreviewNodeCount(p) >= selectedMinNodes,
)
.sort(
(a: PlacementPreview, b: PlacementPreview) =>
getPreviewNodeCount(a) - getPreviewNodeCount(b),
);
});
// Get the first filtered preview (for launch function compatibility)
const filteredPreview = $derived(() => filteredPreviews()[0] ?? null);
// Auto-update selectedMinNodes when node count changes (default to 1 = show all placements)
$effect(() => {
const maxNodes = availableMinNodes;
if (!minNodesInitialized && maxNodes > 0) {
// On initial load, default to 1 (minimum) to show all valid placements
selectedMinNodes = 1;
minNodesInitialized = true;
} else if (selectedMinNodes > maxNodes) {
// If current selection exceeds available nodes, cap it
selectedMinNodes = maxNodes;
}
});
// Auto-adjust selectedMinNodes to a valid value when it becomes invalid
$effect(() => {
const valid = validMinNodeCounts();
if (valid.size > 0 && !valid.has(selectedMinNodes)) {
// Find the smallest valid count >= current selection, or the largest valid count
const validArray = Array.from(valid).sort((a, b) => a - b);
const nextValid =
validArray.find((n) => n >= selectedMinNodes) ??
validArray[validArray.length - 1];
if (nextValid !== undefined) {
selectedMinNodes = nextValid;
}
}
});
// Calculate total memory usage across all nodes
const clusterMemory = $derived(() => {
if (!data) return { used: 0, total: 0 };
return Object.values(data.nodes).reduce(
(acc, n) => {
const total =
n.macmon_info?.memory?.ram_total ?? n.system_info?.memory ?? 0;
const used = n.macmon_info?.memory?.ram_usage ?? 0;
return { used: acc.used + used, total: acc.total + total };
},
{ used: 0, total: 0 },
);
});
</script>
{#snippet clusterWarnings()}
{#if tbBridgeCycles.length > 0 || macosVersionMismatch || (tb5WithoutRdma && !tb5InfoDismissed) || (macStudioEn2RdmaWarning && !macStudioEn2Dismissed)}
<div class="absolute top-4 left-4 flex flex-col gap-2 z-40">
{#if tbBridgeCycles.length > 0}
{@const cycle = tbBridgeCycles[0]}
{@const serviceName = getTbBridgeServiceName(cycle)}
{@const disableCmd = `sudo networksetup -setnetworkserviceenabled "${serviceName}" off`}
<div class="group relative" role="alert">
<div
class="flex items-center gap-2 px-3 py-2 rounded border border-yellow-500/50 bg-yellow-500/10 backdrop-blur-sm cursor-help"
>
<svg
class="w-5 h-5 text-yellow-400 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d={warningIconPath}
/>
</svg>
<span class="text-sm font-mono text-yellow-200">
THUNDERBOLT BRIDGE CYCLE DETECTED
</span>
</div>
<!-- Tooltip on hover -->
<div
class="absolute top-full left-0 mt-2 w-80 p-3 rounded border border-yellow-500/30 bg-exo-dark-gray/95 backdrop-blur-sm opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 shadow-lg"
>
<p class="text-xs text-white/80 mb-2">
A network routing cycle was detected between nodes connected via
Thunderbolt Bridge. This can cause connectivity issues.
</p>
<p class="text-xs text-white/60 mb-2">
<span class="text-yellow-300">Affected nodes:</span>
{cycle.map(getNodeName).join(" → ")}
</p>
<p class="text-xs text-white/60 mb-1">
<span class="text-yellow-300">To fix:</span> Disable the Thunderbolt
Bridge on one of the affected nodes:
</p>
<button
type="button"
onclick={() => copyToClipboard(disableCmd)}
class="w-full flex items-center gap-2 text-[10px] font-mono bg-exo-black/60 px-2 py-1.5 rounded text-exo-yellow break-all text-left hover:bg-exo-black/80 transition-colors cursor-pointer group/copy"
title="Click to copy"
>
<span class="flex-1">{disableCmd}</span>
<svg
class="w-3.5 h-3.5 flex-shrink-0 text-white/40 group-hover/copy:text-exo-yellow transition-colors"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
{#if copiedCommand}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5 13l4 4L19 7"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
{/if}
</svg>
</button>
</div>
</div>
{/if}
{#if macosVersionMismatch}
<div class="group relative" role="alert">
<div
class="flex items-center gap-2 px-3 py-2 rounded border border-yellow-500/50 bg-yellow-500/10 backdrop-blur-sm cursor-help"
>
<svg
class="w-5 h-5 text-yellow-400 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d={warningIconPath}
/>
</svg>
<span class="text-sm font-mono text-yellow-200">
INCOMPATIBLE macOS VERSIONS
</span>
</div>
<!-- Tooltip on hover -->
<div
class="absolute top-full left-0 mt-2 w-80 p-3 rounded border border-yellow-500/30 bg-exo-dark-gray/95 backdrop-blur-sm opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 shadow-lg"
>
<p class="text-xs text-white/80 mb-2">
Nodes in this cluster are running different macOS versions. This
may cause inference compatibility issues.
</p>
<div class="text-xs text-white/60 mb-2">
<span class="text-yellow-300">Node versions:</span>
{#each macosVersionMismatch as node}
<div class="ml-2">
{node.friendlyName} — macOS {node.version} ({node.buildVersion})
</div>
{/each}
</div>
<p class="text-xs text-white/60">
<span class="text-yellow-300">Suggested action:</span> Update all nodes
to the same macOS version for best compatibility.
</p>
</div>
</div>
{/if}
{#if tb5WithoutRdma && !tb5InfoDismissed}
<div class="group relative" role="status">
<div
class="flex items-center gap-2 px-3 py-2 rounded border border-yellow-500/50 bg-yellow-500/10 backdrop-blur-sm cursor-help"
>
<svg
class="w-5 h-5 text-yellow-400 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d={warningIconPath}
/>
</svg>
<span class="text-sm font-mono text-yellow-200">
RDMA NOT ENABLED
</span>
<button
type="button"
onclick={() => (tb5InfoDismissed = true)}
class="ml-1 text-yellow-300/60 hover:text-yellow-200 transition-colors cursor-pointer"
title="Dismiss"
>
<svg
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Tooltip on hover -->
<div
class="absolute top-full left-0 mt-2 w-80 p-3 rounded border border-yellow-500/30 bg-exo-dark-gray/95 backdrop-blur-sm opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 shadow-lg"
>
<p class="text-xs text-white/80 mb-2">
Thunderbolt 5 hardware detected on multiple nodes. Enable RDMA for
significantly faster inter-node communication.
</p>
<p class="text-xs text-white/60 mb-1.5">
<span class="text-yellow-300">To enable:</span>
</p>
<ol
class="text-xs text-white/60 list-decimal list-inside space-y-0.5 mb-1.5"
>
<li>Connect nodes with TB5 cables</li>
<li>Boot to Recovery (hold power 10s → Options)</li>
<li>Open Terminal from the Utilities menu</li>
<li>
Run
<code class="text-yellow-300 bg-yellow-400/10 px-1 rounded"
>rdma_ctl enable</code
>
</li>
<li>Reboot</li>
</ol>
<p class="text-xs text-white/40">
Requires macOS 26.2+, TB5 cables, and matching OS versions.
</p>
</div>
</div>
{/if}
{#if macStudioEn2RdmaWarning && !macStudioEn2Dismissed}
<div class="group relative" role="alert">
<div
class="flex items-center gap-2 px-3 py-2 rounded border border-red-500/50 bg-red-500/10 backdrop-blur-sm cursor-help"
>
<svg
class="w-5 h-5 text-red-400 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d={warningIconPath}
/>
</svg>
<span class="text-sm font-mono text-red-200">
RDMA INCOMPATIBLE PORT
</span>
<button
type="button"
onclick={() => (macStudioEn2Dismissed = true)}
class="ml-1 text-red-300/60 hover:text-red-200 transition-colors cursor-pointer"
title="Dismiss"
>
<svg
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Expanded tooltip on hover -->
<div
class="absolute top-full left-0 mt-2 w-96 p-4 rounded border border-red-500/30 bg-[#1a1a1a]/95 backdrop-blur-sm opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 shadow-lg"
>
<p class="text-xs text-white/80 mb-3">
The Thunderbolt 5 port next to the Ethernet port on Mac Studio
does
<span class="text-red-400 font-semibold">not support RDMA</span>.
Move the cable to one of the other three TB5 ports.
</p>
<div class="text-xs text-white/60 mb-3">
<span class="text-red-300">Affected:</span>
{#each macStudioEn2RdmaWarning as conn}
<div class="ml-2 mt-0.5">
<span class="text-white/80">{conn.nodeName}</span>
<span class="text-white/30">→</span>
<span class="text-white/60">{conn.peerNodeName}</span>
<span class="text-white/30 ml-1">(en2)</span>
</div>
{/each}
</div>
<!-- Mac Studio back panel illustration -->
<div class="bg-black/40 rounded p-3 mb-3">
<p
class="text-[10px] font-mono text-white/30 uppercase tracking-wider mb-2"
>
Mac Studio — Rear Panel
</p>
<svg
viewBox="0 0 320 72"
class="w-full"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="1"
y="1"
width="318"
height="70"
rx="6"
ry="6"
fill="none"
stroke="rgba(255,255,255,0.12)"
stroke-width="1"
/>
<!-- TB5 port 1 -->
<rect
x="24"
y="22"
width="28"
height="14"
rx="4"
fill="none"
stroke="rgba(255,255,255,0.3)"
stroke-width="1"
/>
<text
x="38"
y="52"
text-anchor="middle"
fill="rgba(255,255,255,0.25)"
style="font-size:7px;font-family:ui-monospace,monospace;"
>TB5</text
>
<!-- TB5 port 2 -->
<rect
x="62"
y="22"
width="28"
height="14"
rx="4"
fill="none"
stroke="rgba(255,255,255,0.3)"
stroke-width="1"
/>
<text
x="76"
y="52"
text-anchor="middle"
fill="rgba(255,255,255,0.25)"
style="font-size:7px;font-family:ui-monospace,monospace;"
>TB5</text
>
<!-- TB5 port 3 -->
<rect
x="100"
y="22"
width="28"
height="14"
rx="4"
fill="none"
stroke="rgba(255,255,255,0.3)"
stroke-width="1"
/>
<text
x="114"
y="52"
text-anchor="middle"
fill="rgba(255,255,255,0.25)"
style="font-size:7px;font-family:ui-monospace,monospace;"
>TB5</text
>
<!-- TB5 port 4: INCOMPATIBLE (en2) — equally spaced with ports 1-3 -->
<rect
x="138"
y="22"
width="28"
height="14"
rx="4"
fill="rgba(239,68,68,0.1)"
stroke="rgba(239,68,68,0.7)"
stroke-width="1.5"
/>
<line
x1="142"
y1="25"
x2="162"
y2="33"
stroke="rgba(239,68,68,0.8)"
stroke-width="1.5"
stroke-linecap="round"
/>
<line
x1="162"
y1="25"
x2="142"
y2="33"
stroke="rgba(239,68,68,0.8)"
stroke-width="1.5"
stroke-linecap="round"
/>
<text
x="152"
y="52"
text-anchor="middle"
fill="rgba(239,68,68,0.6)"
style="font-size:7px;font-family:ui-monospace,monospace;font-weight:600;"
>en2</text
>
<!-- Ethernet port -->
<rect
x="196"
y="19"
width="24"
height="20"
rx="2"
fill="none"
stroke="rgba(255,255,255,0.2)"
stroke-width="1"
/>
<rect
x="200"
y="23"
width="16"
height="12"
rx="1"
fill="none"
stroke="rgba(255,255,255,0.12)"
stroke-width="0.75"
/>
<text
x="208"
y="52"
text-anchor="middle"
fill="rgba(255,255,255,0.25)"
style="font-size:7px;font-family:ui-monospace,monospace;"
>ETH</text
>
<!-- Green checkmarks on working ports -->
<circle
cx="38"
cy="62"
r="3"
fill="none"
stroke="rgba(74,222,128,0.5)"
stroke-width="0.75"
/>
<circle
cx="76"
cy="62"
r="3"
fill="none"
stroke="rgba(74,222,128,0.5)"
stroke-width="0.75"
/>
<circle
cx="114"
cy="62"
r="3"
fill="none"
stroke="rgba(74,222,128,0.5)"
stroke-width="0.75"
/>
</svg>
</div>
<p class="text-xs text-white/50">
<span class="text-green-400">Fix:</span> Move the Thunderbolt cable
to any of the three leftmost ports (all support RDMA).
</p>
</div>
</div>
{/if}
</div>
{/if}
{/snippet}
{#snippet clusterWarningsCompact()}
{#if tbBridgeCycles.length > 0 || macosVersionMismatch || (tb5WithoutRdma && !tb5InfoDismissed) || (macStudioEn2RdmaWarning && !macStudioEn2Dismissed)}
<div class="absolute top-2 left-2 flex flex-col gap-1">
{#if tbBridgeCycles.length > 0}
<div
class="flex items-center gap-1.5 px-2 py-1 rounded border border-yellow-500/50 bg-yellow-500/10 backdrop-blur-sm"
title="Thunderbolt Bridge cycle detected"
>
<svg
class="w-3.5 h-3.5 text-yellow-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d={warningIconPath}
/>
</svg>
<span class="text-[10px] font-mono text-yellow-200">TB CYCLE</span>
</div>
{/if}
{#if macosVersionMismatch}
<div
class="flex items-center gap-1.5 px-2 py-1 rounded border border-yellow-500/50 bg-yellow-500/10 backdrop-blur-sm"
title="Incompatible macOS versions detected"
>
<svg
class="w-3.5 h-3.5 text-yellow-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d={warningIconPath}
/>
</svg>
<span class="text-[10px] font-mono text-yellow-200"
>macOS MISMATCH</span
>
</div>
{/if}
{#if tb5WithoutRdma && !tb5InfoDismissed}
<div
class="flex items-center gap-1.5 px-2 py-1 rounded border border-yellow-500/50 bg-yellow-500/10 backdrop-blur-sm"
title="Thunderbolt 5 detected — RDMA not enabled. Enable for faster inter-node communication."
>
<svg
class="w-3.5 h-3.5 text-yellow-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d={warningIconPath}
/>
</svg>
<span class="text-[10px] font-mono text-yellow-200"
>RDMA NOT ENABLED</span
>
</div>
{/if}
{#if macStudioEn2RdmaWarning && !macStudioEn2Dismissed}
<div
class="flex items-center gap-1.5 px-2 py-1 rounded border border-red-500/50 bg-red-500/10 backdrop-blur-sm"
title="Mac Studio RDMA incompatible port (en2) — move cable to another TB5 port"
>
<svg
class="w-3.5 h-3.5 text-red-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d={warningIconPath}
/>
</svg>
<span class="text-[10px] font-mono text-red-200">BAD RDMA PORT</span>
</div>
{/if}
</div>
{/if}
{/snippet}
<!-- Global event listeners for slider dragging + onboarding keyboard nav -->
<svelte:window
onmousemove={handleSliderMouseMove}
onmouseup={handleSliderMouseUp}
ontouchmove={handleSliderTouchMove}
ontouchend={handleSliderTouchEnd}
onkeydown={(e) => {
if (!showOnboardingOverlay || stepTransitioning) return;
if (e.key === "ArrowRight" || e.key === " " || e.key === "Enter") {
if (onboardingStep >= 1 && onboardingStep <= 4 && showContinueButton) {
e.preventDefault();
advanceStep(onboardingStep < 4 ? onboardingStep + 1 : 6);
}
}
}}
/>
<div
class="relative h-screen w-full flex flex-col bg-exo-dark-gray overflow-hidden"
>
<!-- Scanline overlay -->
<!-- Scanline overlay -->
<div
class="fixed inset-0 pointer-events-none z-50 scanlines"
style="transition: opacity 0.5s ease; opacity: {showOnboardingOverlay
? 0
: 0.2};"
></div>
<!-- Shooting Stars Background -->
<div
class="shooting-stars"
style="transition: opacity 0.5s ease; opacity: {showOnboardingOverlay
? 0.4
: 1};"
>
<div
class="shooting-star"
style="top: 10%; left: 20%; --duration: 45s; --delay: 0s;"
></div>
<div
class="shooting-star"
style="top: 30%; left: 65%; --duration: 45s; --delay: 15s;"
></div>
<div
class="shooting-star"
style="top: 50%; left: 40%; --duration: 45s; --delay: 30s;"
></div>
</div>
{#if showOnboardingOverlay}
<!-- ═══════════════════════════════════════════════════════ -->
<!-- FULL-SCREEN ONBOARDING WIZARD (overlay) -->
<!-- ═══════════════════════════════════════════════════════ -->
<div
class="absolute inset-0 flex items-center justify-center z-30 bg-exo-black"
style="transition: opacity 0.45s cubic-bezier(0.4, 0, 0.2, 1); opacity: {onboardingFadingOut
? 0
: 1};"
>
{#if onboardingStep >= 1 && onboardingStep <= 4}
<!-- Steps 1-4: Cinematic SVG animation story -->
<div
class="flex flex-col items-center w-full max-w-3xl px-8"
style="transition: opacity 0.6s cubic-bezier(0.4, 0, 0.2, 1), transform 0.6s cubic-bezier(0.4, 0, 0.2, 1); opacity: {stepTransitioning
? 0
: 1}; transform: scale({stepTransitioning ? 0.98 : 1});"
>
<!-- Logo + Step title -->
<div class="text-center mb-8">
<!-- Logo — smoothly shrinks away when leaving step 1 -->
<div
style="opacity: {$logoOpacity}; max-height: {$logoOpacity *
80}px; overflow: hidden; transition: max-height 0.6s cubic-bezier(0.4, 0, 0.2, 1);"
>
<img src="/exo-logo.png" alt="exo" class="w-36 mx-auto mb-10" />
</div>
<!-- Title — single element, text updates instantly -->
<h1
class="text-2xl font-light text-white/90 tracking-wide"
style="opacity: {$titleOpacity}; font-family: -apple-system, 'SF Pro Display', system-ui, sans-serif; letter-spacing: 0.02em;"
>
{onboardingStep === 1
? "EXO connects all your devices into an AI supercomputer."
: stepTitle}
</h1>
<!-- Subtitle — uses tweened opacity, reserves space to prevent layout shift -->
<p
class="text-sm mt-2 text-white/40 max-w-md mx-auto"
style="opacity: {$subtitleOpacity}; font-family: -apple-system, 'SF Pro Display', system-ui, sans-serif; font-weight: 300; min-height: 1.5em;"
>
{#if onboardingStep === 2}
{:else if onboardingStep === 3}
The model is automatically distributed. Each device handles a
piece.
{:else if onboardingStep === 4}
{stepTitle === "exo self-heals"
? "exo automatically redistributes the model so inference continues without interruption."
: "Devices can leave anytime. Laptops close, machines restart."}
{:else}
{/if}
</p>
</div>
<!-- Device display area -->
<div class="relative w-full" style="height: 420px;">
<!-- Device count label — fades in on step 1, fades out on step 2 -->
<p
class="absolute left-0 right-0 text-center text-lg text-white/50 font-light tracking-wide z-10"
style="top: 20px; opacity: {$deviceCountOpacity}; font-family: -apple-system, 'SF Pro Display', system-ui, sans-serif; pointer-events: none;"
>
Your EXO Network
</p>
<!-- Step 1: Real topology graph -->
{#if onboardingStep <= 1 || $topologyOpacity > 0.01}
<div
class="absolute inset-0 flex items-center justify-center"
style="opacity: {$topologyOpacity}; pointer-events: {onboardingStep <=
1
? 'none'
: 'none'};"
>
<TopologyGraph class="w-full h-full" />
</div>
{/if}
<!-- Steps 2+: Tweened SVG canvas with device pair -->
<svg
viewBox="0 0 700 420"
class="w-full h-full"
xmlns="http://www.w3.org/2000/svg"
style="position: relative;"
>
<!-- Device 1 (User's device) -->
<g
transform="translate({$device1X}, 210)"
opacity={$device1Opacity}
style="transition: opacity 0.6s ease;"
>
<DeviceIcon
deviceType={userDeviceInfo.deviceType}
cx={0}
cy={0}
size={110}
ramPercent={60}
uid="onb-d1"
/>
<text
x="0"
y="-105"
text-anchor="middle"
fill="rgba(255,255,255,0.9)"
style="font-size: 15px; font-family: -apple-system, 'SF Pro Display', system-ui, sans-serif; font-weight: 500; letter-spacing: 0.01em;"
>
{userDeviceInfo.name}
</text>
<text
x="0"
y="105"
text-anchor="middle"
style="font-size: 14px; font-family: 'SF Mono', ui-monospace, monospace;"
>
<tspan fill="rgba(255,215,0,0.9)"
>{userDeviceInfo.memoryGB}</tspan
><tspan fill="rgba(255,255,255,0.4)">{" "}GB</tspan>
</text>
</g>
<!-- Device 2 (Mac Studio — simulated) -->
<g
transform="translate({$device2X}, 210)"
opacity={$device2Opacity}
style="transition: opacity 0.6s ease;"
>
<!-- Dashed outline to indicate simulated device -->
<rect
x={(-110 * 1.25) / 2 - 6}
y={(-110 * 0.85) / 2 - 6}
width={110 * 1.25 + 12}
height={110 * 0.85 + 12}
rx="6"
fill="none"
stroke="rgba(255,255,255,0.12)"
stroke-dasharray="4,4"
/>
<DeviceIcon
deviceType="mac studio"
cx={0}
cy={0}
size={110}
ramPercent={80}
uid="onb-d2"
/>
<text
x="0"
y="-105"
text-anchor="middle"
fill="rgba(255,255,255,0.9)"
style="font-size: 15px; font-family: -apple-system, 'SF Pro Display', system-ui, sans-serif; font-weight: 500; letter-spacing: 0.01em;"
>
Mac Studio
</text>
<text
x="0"
y="105"
text-anchor="middle"
style="font-size: 14px; font-family: 'SF Mono', ui-monospace, monospace;"
>
<tspan fill="rgba(255,215,0,0.9)">{SIMULATED_STUDIO_GB}</tspan
><tspan fill="rgba(255,255,255,0.4)">{" "}GB</tspan>
</text>
<text
x="0"
y="120"
text-anchor="middle"
fill="rgba(255,255,255,0.2)"
style="font-size: 9px; font-family: -apple-system, 'SF Pro Display', system-ui, sans-serif; font-style: italic;"
>
(example)
</text>
</g>
<!-- Connection line between devices -->
<line
x1={$device1X + 85}
y1={210}
x2={$device2X - 85}
y2={210}
stroke={$connectionIsRed > 0.5
? "rgba(220,38,38,0.7)"
: "rgba(255,255,255,0.15)"}
stroke-width="1.5"
stroke-dasharray="6,6"
opacity={$connectionOpacity}
class={$connectionIsRed > 0.5
? "onboarding-connection-line-red"
: "onboarding-connection-line"}
/>
<!-- Disconnect X mark -->
{#if $disconnectXOpacity > 0.01}
<g
transform="translate({($device1X + $device2X) / 2}, 210)"
opacity={$disconnectXOpacity}
>
<circle
r="18"
fill="rgba(220,38,38,0.1)"
stroke="rgba(220,38,38,0.6)"
stroke-width="1.5"
/>
<line
x1="-8"
y1="-8"
x2="8"
y2="8"
stroke="rgba(220,38,38,0.8)"
stroke-width="2.5"
stroke-linecap="round"
/>
<line
x1="8"
y1="-8"
x2="-8"
y2="8"
stroke="rgba(220,38,38,0.8)"
stroke-width="2.5"
stroke-linecap="round"
/>
</g>
{/if}
<!-- Combined memory label -->
<text
x={($device1X + $device2X) / 2}
y={130}
text-anchor="middle"
fill="rgba(255,215,0,0.7)"
style="font-size: 14px; font-family: 'SF Mono', ui-monospace, monospace; font-weight: 500; letter-spacing: 0.02em;"
opacity={$combinedLabelOpacity}
>
{onboardingCombinedGB} GB combined
</text>
<!-- Step 2: Models unlocked — staggered slide-up + yellow glow -->
{#if unlockedModels.length > 0 && $chipPhase > 0.01}
{@const centerX = ($device1X + $device2X) / 2}
{@const chipW = 140}
{@const chipH = 30}
{@const chipGap = 12}
{@const totalW =
unlockedModels.length * chipW +
(unlockedModels.length - 1) * chipGap}
{@const startX = centerX - totalW / 2}
<!-- SVG filter for yellow glow -->
<defs>
<filter
id="chip-glow"
x="-50%"
y="-50%"
width="200%"
height="200%"
>
<feGaussianBlur
in="SourceGraphic"
stdDeviation="4"
result="blur"
/>
<feColorMatrix
in="blur"
type="matrix"
values="1 0.8 0 0 0 0.8 0.7 0 0 0 0 0 0 0 0 0 0 0 0.4 0"
result="glow"
/>
<feMerge>
<feMergeNode in="glow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<!-- Header slides up + fades with yellow tint -->
{@const headerProgress = Math.min(1, $chipPhase)}
{@const headerY = 332 + 12 * (1 - headerProgress)}
{@const yellowR = 234}
{@const yellowG = 179}
{@const yellowB = 8}
<text
x={centerX}
y={headerY}
text-anchor="middle"
dominant-baseline="middle"
fill="rgba({yellowR},{yellowG},{yellowB},{0.5 *
headerProgress})"
opacity={headerProgress}
style="font-size: 10px; font-family: -apple-system, 'SF Pro Display', system-ui, sans-serif; font-weight: 500; letter-spacing: 0.1em;"
>
NEW MODELS UNLOCKED
</text>
<!-- Model chips — staggered slide-up + scale + yellow highlight -->
{#each unlockedModels as model, i}
{@const stagger = i * 0.6}
{@const progress = Math.max(
0,
Math.min(1, $chipPhase - stagger),
)}
{@const modelName = (
model.name ||
model.id.split("/").pop() ||
""
).slice(0, 18)}
{@const modelSize = Math.round(getModelSizeGB(model))}
{@const slideY = 16 * (1 - progress)}
{@const chipScale = 0.85 + 0.15 * progress}
<!-- Yellow highlight peaks at ~0.6 progress then settles to subtle -->
{@const highlightPeak =
progress < 0.6
? progress / 0.6
: 1 - ((progress - 0.6) / 0.4) * 0.6}
{@const borderYellow = 0.15 + 0.35 * highlightPeak}
{@const fillYellow = 0.02 + 0.06 * highlightPeak}
{#if progress > 0}
<g
transform="translate({startX +
i * (chipW + chipGap) +
chipW / 2}, {358 + slideY}) scale({chipScale})"
opacity={progress}
filter={highlightPeak > 0.3 ? "url(#chip-glow)" : "none"}
>
<rect
x={-chipW / 2}
y={-chipH / 2}
width={chipW}
height={chipH}
rx="15"
fill="rgba({yellowR},{yellowG},{yellowB},{fillYellow})"
stroke="rgba({yellowR},{yellowG},{yellowB},{borderYellow})"
stroke-width="1"
/>
<text
x="0"
y={modelSize ? -4 : 1}
text-anchor="middle"
dominant-baseline="middle"
fill="rgba(255,255,255,{0.5 + 0.3 * progress})"
style="font-size: 10px; font-family: 'SF Mono', ui-monospace, monospace; font-weight: 500;"
>
{modelName}
</text>
{#if modelSize}
<text
x="0"
y="8"
text-anchor="middle"
dominant-baseline="middle"
fill="rgba(255,255,255,{0.15 + 0.15 * progress})"
style="font-size: 8px; font-family: 'SF Mono', ui-monospace, monospace; font-weight: 400;"
>
{modelSize} GB
</text>
{/if}
</g>
{/if}
{/each}
{/if}
<!-- Model block (unified or split) -->
{#if $modelBlockOpacity > 0.01}
{#if $modelSplitProgress < 0.05}
<!-- Unified model block — centers on device1 when device2 is hidden -->
{@const modelCenterX =
$device2Opacity > 0.3
? ($device1X + $device2X) / 2
: $device1X}
<g
transform="translate({modelCenterX}, {$modelBlockY})"
opacity={$modelBlockOpacity}
>
<rect
x="-45"
y="-13"
width="90"
height="26"
rx="6"
fill="rgba(180,140,0,0.08)"
stroke="rgba(180,140,0,0.45)"
stroke-width="1.5"
/>
<text
x="0"
y="5"
text-anchor="middle"
fill="rgba(220,180,40,0.9)"
style="font-size: 12px; font-family: -apple-system, system-ui, sans-serif; font-weight: 500;"
>
LLM
</text>
</g>
{:else}
<!-- Split model halves flowing down to each device -->
{@const splitX =
$modelSplitProgress * (($device2X - $device1X) / 2)}
{@const centerX = ($device1X + $device2X) / 2}
{@const splitY = $modelBlockY + $modelSplitProgress * 80}
<!-- Left half -> Device 1 -->
<g
transform="translate({centerX - splitX}, {splitY})"
opacity={$modelBlockOpacity}
>
<rect
x="-45"
y="-13"
width="90"
height="26"
rx="6"
fill="rgba(180,140,0,0.08)"
stroke="rgba(180,140,0,0.35)"
stroke-width="1"
/>
<text
x="0"
y="4"
text-anchor="middle"
fill="rgba(220,180,40,0.75)"
style="font-size: 11px; font-family: -apple-system, system-ui, sans-serif;"
>
Shard 1/2
</text>
</g>
<!-- Right half -> Device 2 -->
<g
transform="translate({centerX + splitX}, {splitY})"
opacity={$modelBlockOpacity * $device2Opacity}
>
<rect
x="-45"
y="-13"
width="90"
height="26"
rx="6"
fill="rgba(180,140,0,0.08)"
stroke="rgba(180,140,0,0.35)"
stroke-width="1"
/>
<text
x="0"
y="4"
text-anchor="middle"
fill="rgba(220,180,40,0.75)"
style="font-size: 11px; font-family: -apple-system, system-ui, sans-serif;"
>
Shard 2/2
</text>
</g>
{/if}
{/if}
</svg>
</div>
<!-- Continue button — smooth transition, only for steps 1 and 5 -->
<div
style="transition: opacity 0.4s ease, transform 0.4s cubic-bezier(0.4,0,0.2,1); opacity: {showContinueButton
? 1
: 0}; transform: translateY({showContinueButton
? '0px'
: '12px'}); pointer-events: {showContinueButton
? 'auto'
: 'none'}; margin-top: 0.5rem;"
>
<button
type="button"
onclick={() =>
advanceStep(onboardingStep < 4 ? onboardingStep + 1 : 6)}
class="inline-flex items-center gap-2.5 px-10 py-3.5 bg-exo-yellow text-exo-black text-sm font-semibold rounded-full cursor-pointer"
style="transition: transform 0.2s ease, box-shadow 0.3s ease, filter 0.2s ease; font-family: -apple-system, 'SF Pro Display', system-ui, sans-serif; letter-spacing: 0.02em;"
onmouseenter={(e) => {
e.currentTarget.style.filter = "brightness(1.08)";
e.currentTarget.style.boxShadow =
"0 0 30px rgba(255,215,0,0.2)";
}}
onmouseleave={(e) => {
e.currentTarget.style.filter = "brightness(1)";
e.currentTarget.style.boxShadow = "none";
}}
>
Continue
<svg
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
</button>
</div>
</div>
{:else if onboardingStep === 6}
<!-- Step 6: Choose a Model -->
<div
class="flex flex-col items-center w-full max-w-2xl px-8"
style="opacity: 0; animation: onb-fade-in 0.5s ease forwards;"
>
<div class="text-center mb-8">
<h1
class="text-xl font-sans font-light text-white/90 mb-2 tracking-wide"
>
Choose a model
</h1>
<p class="text-sm font-sans text-white/40">
Showing recommended models for your devices ({Math.round(
clusterMemory().total / (1024 * 1024 * 1024),
)} GB memory available).
</p>
</div>
{#if onboardingError}
<div
class="w-full mb-6 px-4 py-3 rounded-lg border border-red-500/30 bg-red-500/10 text-sm font-mono text-red-400"
in:fade={{ duration: 200 }}
>
{onboardingError}
</div>
{/if}
{#if onboardingModels.length === 0}
<div class="text-center py-8">
<div class="text-sm text-white/40 font-sans animate-pulse">
Loading models...
</div>
</div>
{:else}
<div class="w-full space-y-3 mb-8">
{#each onboardingModels as model}
{@const sizeGB = getModelSizeGB(model)}
{@const fitsNow = hasEnoughMemory(model)}
{@const tags = modelTags()[model.id] || []}
<button
type="button"
onclick={() => onboardingLaunchModel(model.id)}
class="w-full flex items-center justify-between gap-4 px-5 py-4 rounded-xl border transition-all duration-200 cursor-pointer {fitsNow
? 'border-white/10 bg-white/5 hover:border-exo-yellow/50 hover:bg-exo-yellow/5'
: 'border-white/10 bg-white/[0.02] hover:border-white/20 opacity-60'}"
>
<div class="flex flex-col items-start gap-1 min-w-0">
<div class="flex items-center gap-2">
<span
class="text-sm font-sans font-medium text-white truncate"
>{model.name || model.id}</span
>
{#each tags as tag}
<span
class="text-[10px] font-sans font-medium px-1.5 py-0.5 rounded-full bg-exo-yellow/10 text-exo-yellow/80"
>{tag}</span
>
{/each}
</div>
<span class="text-xs font-mono text-white/40 truncate"
>{model.id}</span
>
</div>
<div class="flex items-center gap-3 flex-shrink-0">
<span class="text-xs font-mono text-white/50"
>{sizeGB >= 1 ? sizeGB.toFixed(0) : sizeGB.toFixed(1)} GB</span
>
<svg
class="w-4 h-4 text-white/40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
{/each}
</div>
{/if}
<button
type="button"
onclick={() => {
modelPickerContext = "dashboard";
isModelPickerOpen = true;
}}
class="text-sm font-sans text-white/40 hover:text-exo-yellow transition-colors cursor-pointer underline underline-offset-4 decoration-white/20 hover:decoration-exo-yellow/50"
>
Browse all models
</button>
</div>
{:else if onboardingStep === 7}
<!-- Step 7: Downloading -->
<div
class="text-center max-w-lg px-8"
style="opacity: 0; animation: onb-fade-in 0.5s ease forwards;"
>
<div class="mb-8">
<h1
class="text-xl font-sans font-light text-white/90 mb-2 tracking-wide"
>
Downloading
</h1>
{#if onboardingModelId}
<p class="text-sm text-white/40 font-sans">
{onboardingModelId.split("/").pop() ?? onboardingModelId}
</p>
{/if}
</div>
{#if onboardingDownloadProgress}
<div class="w-full max-w-md mx-auto space-y-4">
<div
class="relative h-2 bg-white/10 rounded-full overflow-hidden"
>
<div
class="absolute inset-y-0 left-0 bg-gradient-to-r from-exo-yellow to-exo-yellow-darker rounded-full transition-all duration-500"
style="width: {onboardingDownloadProgress.percentage}%"
></div>
</div>
<div class="flex justify-between text-xs font-mono text-white/50">
<span>{onboardingDownloadProgress.percentage.toFixed(1)}%</span>
<span
>{formatBytes(onboardingDownloadProgress.downloadedBytes)} /
{formatBytes(onboardingDownloadProgress.totalBytes)}</span
>
</div>
<div class="flex justify-between text-xs font-mono text-white/40">
<span>{formatSpeed(onboardingDownloadProgress.speed)}</span>
<span>ETA: {formatEta(onboardingDownloadProgress.etaMs)}</span>
</div>
</div>
{:else}
<div class="w-full max-w-md mx-auto">
<div
class="relative h-2 bg-white/10 rounded-full overflow-hidden"
>
<div
class="absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-exo-yellow to-exo-yellow-darker rounded-full animate-pulse"
></div>
</div>
<p class="text-xs font-mono text-white/40 mt-4">
Preparing download...
</p>
</div>
{/if}
<p class="text-xs font-sans text-white/40 mt-8">
This may take a few minutes depending on your connection.
</p>
</div>
{:else if onboardingStep === 8}
<!-- Step 8: Loading into memory -->
<div
class="text-center max-w-lg px-8"
style="opacity: 0; animation: onb-fade-in 0.5s ease forwards;"
>
<div class="mb-6">
<h1
class="text-xl font-sans font-light text-white/90 mb-2 tracking-wide"
>
Loading into memory
</h1>
{#if onboardingModelId}
<p class="text-sm text-white/40 font-sans">
{onboardingModelId.split("/").pop() ?? onboardingModelId}
</p>
{/if}
</div>
<!-- Device icon -->
<div class="flex justify-center mb-6">
<svg
viewBox="0 0 200 200"
class="w-32 h-32"
xmlns="http://www.w3.org/2000/svg"
>
<DeviceIcon
deviceType={userDeviceInfo.deviceType}
cx={100}
cy={100}
size={80}
ramPercent={60}
uid="onb-loading"
/>
</svg>
</div>
{#if onboardingLoadProgress}
<div class="w-full max-w-xs mx-auto space-y-3">
<div
class="relative h-2 bg-white/10 rounded-full overflow-hidden"
>
<div
class="absolute inset-y-0 left-0 bg-gradient-to-r from-exo-yellow to-exo-yellow-darker rounded-full transition-all duration-500"
style="width: {onboardingLoadProgress.percentage}%"
></div>
</div>
<p class="text-xs text-white/40 font-mono text-center">
{onboardingLoadProgress.layersLoaded} / {onboardingLoadProgress.totalLayers}
layers loaded
</p>
</div>
{:else}
<div class="flex justify-center mb-4">
<div
class="w-8 h-8 border-2 border-exo-yellow/15 border-t-exo-yellow/70 rounded-full animate-spin"
></div>
</div>
<p class="text-sm text-white/30 font-sans">Loading...</p>
{/if}
</div>
{:else if onboardingStep === 9}
<!-- Step 9: Ready — centered input with suggestion chips -->
<!-- Uses onb-fade-opacity (no transform) so fixed-position dropdown in ChatForm works correctly -->
<div
class="flex flex-col items-center justify-center w-full max-w-2xl px-8"
style="opacity: 0; animation: onb-fade-opacity 0.6s ease forwards;"
>
<img
src="/exo-logo.png"
alt="exo"
class="w-28 mb-6"
style="opacity: 0.8;"
/>
{#if onboardingModelId}
<p class="text-sm text-white/40 font-mono mb-6">
{onboardingModelId.split("/").pop() ?? onboardingModelId}
</p>
{/if}
<div class="w-full">
<ChatForm
placeholder="Ask anything"
autofocus={true}
showHelperText={false}
showModelSelector={true}
modelTasks={modelTasks()}
modelCapabilities={modelCapabilities()}
onOpenModelPicker={openChatModelPicker}
onAutoSend={handleChatSend}
/>
</div>
<div class="flex flex-wrap justify-center gap-3 mt-6">
{#each suggestedPrompts as chip}
<button
type="button"
onclick={() => {
completeOnboarding();
handleChatSend(chip);
}}
class="px-4 py-2 rounded-full border border-white/10 bg-white/5 text-sm text-white/60 hover:bg-white/10 hover:text-white/80 hover:border-white/20 transition-all duration-200 cursor-pointer"
>
{chip}
</button>
{/each}
</div>
</div>
{/if}
<!-- Replay / Skip — visible on all onboarding steps -->
<div class="absolute bottom-8 flex items-center gap-6">
<button
type="button"
onclick={() => {
onboardingStep = 0;
setTimeout(() => {
onboardingStep = 1;
}, 50);
}}
class="flex items-center gap-1.5 text-xs font-sans text-white/15 hover:text-white/35 transition-colors duration-300 cursor-pointer"
>
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2.5 2v5h5" />
<path d="M2.5 7a6.5 6.5 0 1 1 1.4-2.8" />
</svg>
Replay
</button>
<button
type="button"
onclick={completeOnboarding}
class="flex items-center gap-1.5 text-xs font-sans text-white/15 hover:text-white/35 transition-colors duration-300 cursor-pointer"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path d="M3 2.5v11L9 8 3 2.5z" />
<rect x="10.5" y="2.5" width="2.5" height="11" rx="0.5" />
</svg>
Skip
</button>
</div>
</div>
<!-- Model Picker Modal (available during onboarding step 4) -->
{#if onboardingStep === 6}
<ModelPickerModal
isOpen={isModelPickerOpen}
{models}
{selectedModelId}
favorites={favoritesSet}
{recentModelIds}
hasRecents={showRecentsTab}
existingModelIds={new Set(models.map((m) => m.id))}
canModelFit={(modelId) => {
const model = models.find((m) => m.id === modelId);
return model ? hasEnoughMemory(model) : false;
}}
getModelFitStatus={(modelId): ModelMemoryFitStatus => {
const model = models.find((m) => m.id === modelId);
return model ? getModelMemoryFitStatus(model) : "too_large";
}}
onSelect={(modelId) => {
isModelPickerOpen = false;
onboardingLaunchModel(modelId);
}}
onClose={() => (isModelPickerOpen = false)}
onToggleFavorite={toggleFavorite}
onAddModel={addModelFromPicker}
onDeleteModel={deleteCustomModel}
totalMemoryGB={clusterMemory().total / (1024 * 1024 * 1024)}
usedMemoryGB={clusterMemory().used / (1024 * 1024 * 1024)}
{downloadsData}
topologyNodes={data?.nodes}
/>
{/if}
{/if}
<!-- ═══════════════════════════════════════════════════════ -->
<!-- MAIN DASHBOARD (always rendered, behind onboarding) -->
<!-- ═══════════════════════════════════════════════════════ -->
{#if !topologyOnlyEnabled}
<HeaderNav
showHome={true}
onHome={handleGoHome}
showSidebarToggle={true}
{sidebarVisible}
onToggleSidebar={toggleChatSidebarVisible}
showMobileMenuToggle={true}
mobileMenuOpen={mobileChatOpen}
onToggleMobileMenu={toggleMobileChatSidebar}
showMobileRightToggle={!chatStarted && !topologyOnlyEnabled}
{mobileRightOpen}
onToggleMobileRight={toggleMobileRightSidebar}
downloadProgress={activeDownloadSummary}
/>
{/if}
<!-- Mobile Chat Sidebar Drawer -->
{#if !topologyOnlyEnabled}
<ChatSidebar
isMobileDrawer={true}
isOpen={mobileChatOpen}
onClose={() => setMobileChatSidebarOpen(false)}
onNewChat={handleNewChat}
onSelectConversation={() => {
userForcedIdle = false;
}}
/>
{/if}
<!-- Main Content -->
<main class="flex-1 flex overflow-hidden relative">
<!-- Left: Conversation History Sidebar (hidden in topology-only mode, welcome state, or when toggled off) - Desktop only -->
{#if !topologyOnlyEnabled && sidebarVisible}
<div
class="hidden md:block w-80 flex-shrink-0 border-r border-exo-yellow/10"
role="complementary"
aria-label="Conversation history"
>
<ChatSidebar
class="h-full"
onNewChat={handleNewChat}
onSelectConversation={() => {
userForcedIdle = false;
}}
/>
</div>
{/if}
{#if topologyOnlyEnabled}
<!-- TOPOLOGY ONLY MODE: Full-screen topology -->
<div
class="flex-1 flex flex-col min-h-0 min-w-0 p-4"
in:fade={{ duration: 300 }}
>
<div
class="flex-1 relative bg-exo-dark-gray/40 rounded-lg overflow-hidden"
>
<TopologyGraph
class="w-full h-full"
highlightedNodes={highlightedNodes()}
filteredNodes={nodeFilter}
onNodeClick={togglePreviewNodeFilter}
/>
{@render clusterWarnings()}
<!-- TB5 RDMA Not Enabled Warning -->
{#if tb5WithoutRdma && !tb5InfoDismissed}
<div
class="absolute left-4 group"
class:top-16={tbBridgeCycles.length > 0}
class:top-4={tbBridgeCycles.length === 0}
role="status"
>
<div
class="flex items-center gap-2 px-3 py-2 rounded border border-yellow-500/50 bg-yellow-500/10 backdrop-blur-sm cursor-help"
>
<svg
class="w-5 h-5 text-yellow-400 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d={warningIconPath}
/>
</svg>
<span class="text-sm font-mono text-yellow-200">
RDMA NOT ENABLED
</span>
<button
type="button"
onclick={() => (tb5InfoDismissed = true)}
class="ml-1 text-yellow-300/60 hover:text-yellow-200 transition-colors cursor-pointer"
title="Dismiss"
>
<svg
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Tooltip on hover -->
<div
class="absolute top-full left-0 mt-2 w-80 p-3 rounded border border-yellow-500/30 bg-exo-dark-gray/95 backdrop-blur-sm opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 shadow-lg"
>
<p class="text-xs text-white/80 mb-2">
Thunderbolt 5 hardware detected on multiple nodes. Enable RDMA
for significantly faster inter-node communication.
</p>
<p class="text-xs text-white/60 mb-1.5">
<span class="text-yellow-300">To enable:</span>
</p>
<ol
class="text-xs text-white/60 list-decimal list-inside space-y-0.5 mb-1.5"
>
<li>Connect nodes with TB5 cables</li>
<li>Boot to Recovery (hold power 10s → Options)</li>
<li>Open Terminal from the Utilities menu</li>
<li>
Run
<code class="text-yellow-300 bg-yellow-400/10 px-1 rounded"
>rdma_ctl enable</code
>
</li>
<li>Reboot</li>
</ol>
<p class="text-xs text-white/40">
Requires macOS 26.2+, TB5 cables, and matching OS versions.
</p>
</div>
</div>
{/if}
<!-- Exit topology-only mode button -->
<button
type="button"
onclick={toggleTopologyOnlyMode}
class="absolute bottom-4 right-4 p-2 rounded border border-exo-yellow/30 bg-exo-dark-gray/80 hover:border-exo-yellow/50 hover:bg-exo-dark-gray transition-colors cursor-pointer backdrop-blur-sm"
title="Exit topology only mode"
aria-label="Exit topology only mode"
>
<svg
class="w-5 h-5 text-exo-yellow"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="5" r="2" fill="currentColor" />
<circle cx="5" cy="19" r="2" fill="currentColor" />
<circle cx="19" cy="19" r="2" fill="currentColor" />
<path stroke-linecap="round" d="M12 7v5m0 0l-5 5m5-5l5 5" />
</svg>
</button>
</div>
</div>
{:else if !chatStarted}
<!-- WELCOME STATE: Topology + Instance Controls (no left sidebar for cleaner look) -->
<div
class="flex-1 flex overflow-hidden relative"
in:fade={{ duration: 300 }}
out:fade={{ duration: 200 }}
>
<!-- Center: MAIN TOPOLOGY DISPLAY -->
<div class="flex-1 flex flex-col min-h-0 min-w-0 py-4">
<!-- Topology Container - Takes most of the space -->
<div
class="flex-1 relative bg-exo-dark-gray/40 mx-4 mb-4 rounded-lg overflow-hidden"
>
<!-- The main topology graph - full container -->
<TopologyGraph
class="w-full h-full"
highlightedNodes={highlightedNodes()}
filteredNodes={nodeFilter}
onNodeClick={togglePreviewNodeFilter}
/>
<!-- Initial loading state before first data fetch -->
{#if !update}
<div
class="absolute inset-0 flex items-center justify-center bg-exo-dark-gray/80"
in:fade={{ duration: 200 }}
out:fade={{ duration: 300 }}
>
<div class="text-center">
<div
class="w-8 h-8 border-2 border-exo-yellow/30 border-t-exo-yellow rounded-full animate-spin mx-auto mb-4"
></div>
<p
class="text-xs font-mono text-white/40 tracking-wider uppercase"
>
Connecting to cluster…
</p>
</div>
</div>
{/if}
{@render clusterWarnings()}
<!-- TB5 RDMA Not Enabled Warning -->
{#if tb5WithoutRdma && !tb5InfoDismissed}
<div
class="absolute left-4 group"
class:top-16={tbBridgeCycles.length > 0}
class:top-4={tbBridgeCycles.length === 0}
role="status"
>
<div
class="flex items-center gap-2 px-3 py-2 rounded border border-yellow-500/50 bg-yellow-500/10 backdrop-blur-sm cursor-help"
>
<svg
class="w-5 h-5 text-yellow-400 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d={warningIconPath}
/>
</svg>
<span class="text-sm font-mono text-yellow-200">
RDMA NOT ENABLED
</span>
<button
type="button"
onclick={() => (tb5InfoDismissed = true)}
class="ml-1 text-yellow-300/60 hover:text-yellow-200 transition-colors cursor-pointer"
title="Dismiss"
>
<svg
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Tooltip on hover -->
<div
class="absolute top-full left-0 mt-2 w-80 p-3 rounded border border-yellow-500/30 bg-exo-dark-gray/95 backdrop-blur-sm opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 shadow-lg"
>
<p class="text-xs text-white/80 mb-2">
Thunderbolt 5 hardware detected on multiple nodes. Enable
RDMA for significantly faster inter-node communication.
</p>
<p class="text-xs text-white/60 mb-1.5">
<span class="text-yellow-300">To enable:</span>
</p>
<ol
class="text-xs text-white/60 list-decimal list-inside space-y-0.5 mb-1.5"
>
<li>Connect nodes with TB5 cables</li>
<li>Boot to Recovery (hold power 10s → Options)</li>
<li>Open Terminal from the Utilities menu</li>
<li>
Run
<code
class="text-yellow-300 bg-yellow-400/10 px-1 rounded"
>rdma_ctl enable</code
>
</li>
<li>Reboot</li>
</ol>
<p class="text-xs text-white/40">
Requires macOS 26.2+, TB5 cables, and matching OS versions.
</p>
</div>
</div>
{/if}
<!-- Node Filter Indicator (top-right corner) -->
{#if isFilterActive()}
<button
onclick={clearPreviewNodeFilter}
class="absolute top-2 right-2 flex items-center gap-1.5 px-2 py-1 bg-exo-dark-gray/80 border border-exo-yellow/40 rounded text-exo-yellow hover:border-exo-yellow/60 transition-colors cursor-pointer backdrop-blur-sm"
title="Clear filter"
>
<span class="text-[10px] font-mono tracking-wider">
FILTER: {nodeFilter.size}
</span>
<svg
class="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
<!-- Chat Input - Below topology, never overlaps -->
<div class="px-4 pt-4 pb-6 flex-shrink-0">
<div class="max-w-3xl mx-auto">
{#if instanceCount === 0}
<div class="text-center mb-4">
<p class="text-sm text-white/50 font-sans">
Select a model to get started.
</p>
</div>
{/if}
<ChatForm
placeholder={instanceCount === 0
? "Choose a model to start chatting"
: "Ask anything"}
showHelperText={false}
showModelSelector={true}
modelTasks={modelTasks()}
modelCapabilities={modelCapabilities()}
onOpenModelPicker={openChatModelPicker}
onAutoSend={handleChatSend}
/>
</div>
</div>
</div>
<!-- Mobile Right Sidebar Drawer (Instances) -->
{#if mobileRightOpen}
<!-- Overlay backdrop -->
<button
type="button"
class="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 md:hidden"
onclick={() => setMobileRightSidebarOpen(false)}
aria-label="Close instances panel"
></button>
<!-- Drawer panel -->
<aside
class="fixed right-0 top-0 bottom-0 w-80 bg-exo-dark-gray border-l border-exo-yellow/10 z-50 flex flex-col md:hidden overflow-y-auto"
aria-label="Instance controls mobile"
>
{@render rightSidebarContent()}
</aside>
{/if}
<!-- Right Sidebar: Instance Controls (wider on welcome page for better visibility) - Desktop only -->
<aside
class="hidden md:flex w-80 border-l border-exo-yellow/10 bg-exo-dark-gray flex-col flex-shrink-0"
aria-label="Instance controls"
>
{@render rightSidebarContent()}
</aside>
{#snippet rightSidebarContent()}
<!-- Running Instances Panel (only shown when instances exist) - Scrollable -->
{#if instanceCount > 0}
<div class="p-4 flex-shrink-0">
<!-- Panel Header -->
<div class="flex items-center gap-2 mb-4">
<div
class="w-2 h-2 bg-exo-yellow rounded-full shadow-[0_0_8px_rgba(255,215,0,0.6)] animate-pulse"
></div>
<h3
class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase"
>
Instances
</h3>
<div
class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"
></div>
</div>
<div
bind:this={instancesContainerRef}
class="max-h-72 xl:max-h-96 space-y-3 overflow-y-auto overflow-x-hidden py-px"
>
{#each Object.entries(instanceData) as [id, instance]}
{@const downloadInfo = getInstanceDownloadStatus(
id,
instance,
)}
{@const statusText = downloadInfo.statusText}
{@const isDownloading = downloadInfo.isDownloading}
{@const isFailed = statusText === "FAILED"}
{@const isLoading = statusText === "LOADING"}
{@const isWarmingUp =
statusText === "WARMING UP" || statusText === "WAITING"}
{@const isReady =
statusText === "READY" || statusText === "LOADED"}
{@const isRunning = statusText === "RUNNING"}
<!-- Instance Card -->
{@const instanceModelId = getInstanceModelId(instance)}
{@const instanceInfo = getInstanceInfo(instance)}
{@const instanceConnections =
getInstanceConnections(instance)}
<div
class="relative group cursor-pointer"
role="button"
tabindex="0"
transition:slide={{ duration: 250, easing: cubicOut }}
onmouseenter={() => (hoveredInstanceId = id)}
onmouseleave={() => (hoveredInstanceId = null)}
onclick={() => {
if (
instanceModelId &&
instanceModelId !== "Unknown" &&
instanceModelId !== "Unknown Model"
) {
userForcedIdle = false;
setSelectedChatModel(instanceModelId);
}
}}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (
instanceModelId &&
instanceModelId !== "Unknown" &&
instanceModelId !== "Unknown Model"
) {
setSelectedChatModel(instanceModelId);
}
}
}}
>
<!-- Corner accents -->
<div
class="absolute -top-px -left-px w-2 h-2 border-l border-t {isDownloading
? 'border-blue-500/50'
: isFailed
? 'border-red-500/50'
: isLoading
? 'border-yellow-500/50'
: isReady
? 'border-green-500/50'
: 'border-teal-500/50'}"
></div>
<div
class="absolute -top-px -right-px w-2 h-2 border-r border-t {isDownloading
? 'border-blue-500/50'
: isFailed
? 'border-red-500/50'
: isLoading
? 'border-yellow-500/50'
: isReady
? 'border-green-500/50'
: 'border-teal-500/50'}"
></div>
<div
class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {isDownloading
? 'border-blue-500/50'
: isFailed
? 'border-red-500/50'
: isLoading
? 'border-yellow-500/50'
: isReady
? 'border-green-500/50'
: 'border-teal-500/50'}"
></div>
<div
class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {isDownloading
? 'border-blue-500/50'
: isFailed
? 'border-red-500/50'
: isLoading
? 'border-yellow-500/50'
: isReady
? 'border-green-500/50'
: 'border-teal-500/50'}"
></div>
<div
class="bg-exo-dark-gray/60 border border-l-2 transition-all duration-200 group-hover:bg-exo-dark-gray/80 {isDownloading
? 'border-blue-500/30 border-l-blue-400 group-hover:border-blue-500/50'
: isFailed
? 'border-red-500/30 border-l-red-400 group-hover:border-red-500/50'
: isLoading
? 'border-exo-yellow/30 border-l-yellow-400 group-hover:border-exo-yellow/50'
: isReady
? 'border-green-500/30 border-l-green-400 group-hover:border-green-500/50'
: 'border-teal-500/30 border-l-teal-400 group-hover:border-teal-500/50'} p-3"
>
<div class="flex justify-between items-start mb-2 pl-2">
<div class="flex items-center gap-2">
<div
class="w-1.5 h-1.5 {isDownloading
? 'bg-blue-400 animate-pulse'
: isFailed
? 'bg-red-400'
: isLoading
? 'bg-yellow-400 animate-pulse'
: isReady
? 'bg-green-400'
: 'bg-teal-400'} rounded-full shadow-[0_0_6px_currentColor]"
></div>
<span
class="text-exo-light-gray font-mono text-sm tracking-wider"
>{id.slice(0, 8).toUpperCase()}</span
>
</div>
<button
onclick={() => deleteInstance(id)}
class="text-xs px-2 py-1 font-mono tracking-wider uppercase border border-red-500/30 text-red-400 hover:bg-red-500/20 hover:text-red-400 hover:border-red-500/50 transition-all duration-200 cursor-pointer"
>
DELETE
</button>
</div>
<div class="pl-2">
<div
class="text-exo-yellow text-xs font-mono tracking-wide truncate"
>
{getInstanceModelId(instance)}
</div>
<div
class="flex items-center gap-2 text-white/60 text-xs font-mono"
>
<span
>{instanceInfo.sharding} · {instanceInfo.instanceType}</span
>
<span
class="px-1.5 py-0.5 text-[10px] tracking-wider uppercase rounded transition-all duration-300 {isDownloading
? 'bg-blue-500/15 text-blue-400'
: isFailed
? 'bg-red-500/15 text-red-400'
: isLoading
? 'bg-yellow-500/15 text-yellow-400'
: isReady
? 'bg-green-500/15 text-green-400'
: 'bg-teal-500/15 text-teal-400'}"
>
{statusText}
</span>
</div>
{#if instanceModelId && instanceModelId !== "Unknown" && instanceModelId !== "Unknown Model"}
<a
class="inline-flex items-center gap-1 text-[11px] text-white/60 hover:text-exo-yellow transition-colors mt-1"
href={`https://huggingface.co/${instanceModelId}`}
target="_blank"
rel="noreferrer noopener"
aria-label="View model on Hugging Face"
>
<span>Hugging Face</span>
<svg
class="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14 3h7v7" />
<path d="M10 14l11-11" />
<path
d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"
/>
</svg>
</a>
{/if}
{#if instanceInfo.nodeNames.length > 0}
<div class="text-white/60 text-xs font-mono">
{instanceInfo.nodeNames.join(", ")}
</div>
{/if}
{#if debugEnabled && instanceConnections.length > 0}
<div class="mt-2 space-y-1">
{#each instanceConnections as conn}
<div
class="text-[11px] leading-snug font-mono text-white/70"
>
<span>{conn.from} -> {conn.to}: {conn.ip}</span>
<span
class={conn.missingIface
? "text-red-400"
: "text-white/60"}
>
({conn.ifaceLabel})</span
>
</div>
{/each}
</div>
{/if}
<!-- Download Progress -->
{#if downloadInfo.isDownloading && downloadInfo.progress}
<div class="mt-2 space-y-1">
<div class="flex justify-between text-xs font-mono">
<span class="text-blue-400"
>{downloadInfo.progress.percentage.toFixed(
1,
)}%</span
>
<span class="text-exo-light-gray"
>{formatBytes(
downloadInfo.progress.downloadedBytes,
)}/{formatBytes(
downloadInfo.progress.totalBytes,
)}</span
>
</div>
<div
class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
>
<div
class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
style="width: {downloadInfo.progress
.percentage}%"
></div>
</div>
<div
class="flex justify-between text-xs font-mono text-exo-light-gray"
>
<span
>{formatSpeed(
downloadInfo.progress.speed,
)}</span
>
<span
>ETA: {formatEta(
downloadInfo.progress.etaMs,
)}</span
>
<span
>{downloadInfo.progress
.completedFiles}/{downloadInfo.progress
.totalFiles} files</span
>
</div>
</div>
{#if downloadInfo.perNode.length > 0}
<div
class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
>
{#each downloadInfo.perNode.filter((n) => n.status === "downloading" && n.progress) as nodeProg}
{@const nodePercent = Math.min(
100,
Math.max(0, nodeProg.percentage),
)}
{@const isExpanded =
instanceDownloadExpandedNodes.has(
nodeProg.nodeId,
)}
<div
class="rounded border border-exo-medium-gray/40 bg-exo-black/30 p-2"
>
<button
type="button"
class="w-full text-left space-y-1.5"
onclick={() =>
toggleInstanceDownloadDetails(
nodeProg.nodeId,
)}
>
<div
class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
>
<span class="text-white/80 truncate pr-2"
>{nodeProg.nodeName}</span
>
<span
class="flex items-center gap-1 text-blue-300"
>
{nodePercent.toFixed(1)}%
<svg
class="w-3 h-3 text-exo-light-gray"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M6 8l4 4 4-4"
class={isExpanded
? "transform rotate-180 origin-center transition-transform duration-150"
: "transition-transform duration-150"}
></path>
</svg>
</span>
</div>
<div
class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
>
<div
class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
style="width: {nodePercent.toFixed(1)}%"
></div>
</div>
<div
class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
>
<span
>{formatBytes(
nodeProg.progress?.downloadedBytes ??
0,
)} / {formatBytes(
nodeProg.progress?.totalBytes ?? 0,
)}</span
>
<span
>{formatSpeed(
nodeProg.progress?.speed ?? 0,
)} • ETA {formatEta(
nodeProg.progress?.etaMs ?? 0,
)}</span
>
</div>
</button>
{#if isExpanded}
<div class="mt-2 space-y-1.5">
{#if nodeProg.progress?.files ?? [].length === 0}
<div
class="text-[11px] font-mono text-exo-light-gray/70"
>
No file details reported.
</div>
{:else}
{#each nodeProg.progress?.files ?? [] as f}
{@const filePercent = Math.min(
100,
Math.max(0, f.percentage ?? 0),
)}
{@const isFileComplete =
filePercent >= 100}
<div
class="rounded border border-exo-medium-gray/30 bg-exo-black/40 p-2"
>
<div
class="flex items-center justify-between text-[10px] font-mono text-exo-light-gray/90"
>
<span class="truncate pr-2"
>{f.name}</span
>
<span
class={isFileComplete
? "text-green-400"
: "text-white/80"}
>{filePercent.toFixed(1)}%</span
>
</div>
<div
class="relative h-1 bg-exo-black/60 rounded-sm overflow-hidden mt-1"
>
<div
class="absolute inset-y-0 left-0 bg-gradient-to-r {isFileComplete
? 'from-green-500 to-green-400'
: 'from-exo-yellow to-exo-yellow/70'} transition-all duration-300"
style="width: {filePercent.toFixed(
1,
)}%"
></div>
</div>
<div
class="flex items-center justify-between text-[10px] text-exo-light-gray/70 mt-0.5"
>
<span
>{formatBytes(
f.downloadedBytes,
)} / {formatBytes(
f.totalBytes,
)}</span
>
<span
>{formatSpeed(f.speed)} • ETA {formatEta(
f.etaMs,
)}</span
>
</div>
</div>
{/each}
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
<div class="mt-2 space-y-1">
<div
class="text-xs text-blue-400 font-mono tracking-wider"
>
DOWNLOADING
</div>
<p
class="text-[11px] text-white/50 leading-relaxed"
>
Downloading model files. Model runs on your
devices so needs to be downloaded before you can
chat.
</p>
</div>
{:else}
<div class="mt-1 space-y-1">
<div
class="text-xs {getStatusColor(
downloadInfo.statusText,
)} font-mono tracking-wider"
>
{downloadInfo.statusText}
</div>
{#if isLoading}
{@const loadStatus =
deriveInstanceStatus(instance)}
{#if loadStatus.totalLayers && loadStatus.totalLayers > 0}
<div class="mt-1 space-y-1">
<div
class="flex justify-between text-xs font-mono"
>
<span class="text-yellow-400"
>{(
((loadStatus.layersLoaded ?? 0) /
loadStatus.totalLayers) *
100
).toFixed(0)}%</span
>
<span class="text-exo-light-gray"
>{loadStatus.layersLoaded ?? 0} / {loadStatus.totalLayers}
layers</span
>
</div>
<div
class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
>
<div
class="absolute inset-y-0 left-0 bg-gradient-to-r from-yellow-500 to-yellow-400 transition-all duration-300"
style="width: {((loadStatus.layersLoaded ??
0) /
loadStatus.totalLayers) *
100}%"
></div>
</div>
</div>
{:else}
<p
class="text-[11px] text-white/50 leading-relaxed"
>
Loading model into memory...
</p>
{/if}
{:else if isWarmingUp}
<p
class="text-[11px] text-white/50 leading-relaxed"
>
Warming up...
</p>
{:else if isReady || isRunning}
<p
class="text-[11px] text-green-400/70 leading-relaxed"
>
Ready to chat!
</p>
{/if}
</div>
{#if downloadInfo.isFailed && downloadInfo.errorMessage}
<div
class="text-xs text-red-400/80 font-mono mt-1 break-words"
>
{downloadInfo.errorMessage}
</div>
{/if}
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Models Panel - Scrollable -->
<div class="p-4 flex-1 overflow-y-auto">
<!-- Panel Header -->
<div class="flex items-center gap-2 mb-3 flex-shrink-0">
<div class="w-2 h-2 border border-exo-yellow/60 rotate-45"></div>
<h3
class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase"
>
Load Model
</h3>
<div
class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"
></div>
<span class="text-sm text-white/70 font-mono"
>{models.length} models</span
>
</div>
<!-- Model Picker Button -->
<div class="flex-shrink-0 mb-3">
<button
type="button"
onclick={() => {
modelPickerContext = "dashboard";
isModelPickerOpen = true;
}}
class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-3 pr-8 py-2.5 text-sm font-mono text-left tracking-wide cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 relative"
>
{#if selectedModelId}
{@const foundModel = models.find(
(m) => m.id === selectedModelId,
)}
{#if foundModel}
{@const sizeGB = getModelSizeGB(foundModel)}
<span
class="flex items-center justify-between gap-2 w-full pr-4"
>
<span
class="flex items-center gap-2 text-exo-light-gray truncate"
>
<span class="truncate"
>{foundModel.name || foundModel.id}</span
>
</span>
<span class="text-white/50 text-xs flex-shrink-0"
>{sizeGB >= 1
? sizeGB.toFixed(0)
: sizeGB.toFixed(1)}GB</span
>
</span>
{:else}
<span class="text-exo-light-gray">{selectedModelId}</span>
{/if}
{:else if bestRunningModelId}
{@const runModel = models.find(
(m) => m.id === bestRunningModelId,
)}
{#if runModel}
{@const sizeGB = getModelSizeGB(runModel)}
<span
class="flex items-center justify-between gap-2 w-full pr-4"
>
<span
class="flex items-center gap-2 text-exo-light-gray truncate"
>
<span class="truncate"
>{runModel.name || runModel.id}</span
>
</span>
<span class="text-white/50 text-xs flex-shrink-0"
>{sizeGB >= 1
? sizeGB.toFixed(0)
: sizeGB.toFixed(1)}GB</span
>
</span>
{:else}
<span class="text-exo-light-gray">{bestRunningModelId}</span
>
{/if}
{:else}
<span class="text-white/50">— SELECT MODEL —</span>
{/if}
<div
class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none"
>
<svg
class="w-4 h-4 text-exo-yellow/60"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</button>
</div>
<!-- Advanced Options Toggle -->
<div class="flex-shrink-0 mb-4">
<button
type="button"
onclick={() => (showAdvancedOptions = !showAdvancedOptions)}
class="flex items-center gap-2 text-xs text-white/50 hover:text-white/70 font-mono tracking-wider uppercase transition-colors cursor-pointer py-1"
aria-expanded={showAdvancedOptions}
>
<svg
class="w-3 h-3 transition-transform duration-200 {showAdvancedOptions
? 'rotate-90'
: ''}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 5l7 7-7 7"
/>
</svg>
Advanced Options
</button>
{#if showAdvancedOptions}
<div class="mt-3 space-y-3 pl-1" in:fade={{ duration: 150 }}>
<!-- Sharding Strategy -->
<div>
<div class="text-xs text-white/50 font-mono mb-2">
Sharding Strategy:
</div>
<div class="flex gap-2">
<button
onclick={() => {
selectedSharding = "Pipeline";
saveLaunchDefaults();
}}
class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 cursor-pointer {selectedSharding ===
'Pipeline'
? 'bg-transparent text-exo-yellow border-exo-yellow'
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
>
<span
class="w-3 h-3 rounded-full border-2 flex items-center justify-center {selectedSharding ===
'Pipeline'
? 'border-exo-yellow'
: 'border-exo-medium-gray'}"
>
{#if selectedSharding === "Pipeline"}
<span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
></span>
{/if}
</span>
Pipeline
</button>
<button
onclick={() => {
selectedSharding = "Tensor";
saveLaunchDefaults();
}}
class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 cursor-pointer {selectedSharding ===
'Tensor'
? 'bg-transparent text-exo-yellow border-exo-yellow'
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
>
<span
class="w-3 h-3 rounded-full border-2 flex items-center justify-center {selectedSharding ===
'Tensor'
? 'border-exo-yellow'
: 'border-exo-medium-gray'}"
>
{#if selectedSharding === "Tensor"}
<span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
></span>
{/if}
</span>
Tensor
</button>
</div>
</div>
<!-- Interconnect -->
<div>
<div class="text-xs text-white/50 font-mono mb-2">
Interconnect:
</div>
<div class="flex gap-2">
<button
onclick={() => {
selectedInstanceType = "MlxRing";
saveLaunchDefaults();
}}
class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType ===
'MlxRing'
? 'bg-transparent text-exo-yellow border-exo-yellow'
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
>
<span
class="w-3 h-3 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
'MlxRing'
? 'border-exo-yellow'
: 'border-exo-medium-gray'}"
>
{#if selectedInstanceType === "MlxRing"}
<span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
></span>
{/if}
</span>
TCP/IP
</button>
<button
onclick={() => {
selectedInstanceType = "MlxJaccl";
saveLaunchDefaults();
}}
class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType ===
'MlxJaccl'
? 'bg-transparent text-exo-yellow border-exo-yellow'
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
>
<span
class="w-3 h-3 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
'MlxJaccl'
? 'border-exo-yellow'
: 'border-exo-medium-gray'}"
>
{#if selectedInstanceType === "MlxJaccl"}
<span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
></span>
{/if}
</span>
RDMA (Fast)
</button>
</div>
</div>
<!-- Minimum Devices -->
<div>
<div class="text-xs text-white/50 font-mono mb-2">
Minimum Devices:
</div>
<!-- Discrete slider track with drag support -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={sliderTrackElement}
class="relative h-16 cursor-pointer select-none px-2 pr-6"
onmousedown={handleSliderMouseDown}
ontouchstart={handleSliderTouchStart}
>
<!-- Track background -->
<div
class="absolute top-6 left-0 right-0 h-2 bg-exo-medium-gray/50 rounded-full"
></div>
<!-- Active track (fills up to selected) -->
{#if availableMinNodes > 1}
<div
class="absolute top-6 left-0 h-2 bg-white/30 rounded-full transition-all pointer-events-none"
style="width: {((selectedMinNodes - 1) /
(availableMinNodes - 1)) *
100}%"
></div>
{/if}
<!-- Dots and labels for each device count -->
{#each Array.from({ length: availableMinNodes }, (_, i) => i + 1) as n}
{@const isValid = validMinNodeCounts().has(n)}
{@const isSelected = selectedMinNodes === n}
{@const position =
availableMinNodes > 1
? ((n - 1) / (availableMinNodes - 1)) * 100
: 50}
<div
class="absolute flex flex-col items-center pointer-events-none"
style="left: {position}%; top: 0; transform: translateX(-50%);"
>
<span
class="rounded-full transition-all {isSelected
? 'w-6 h-6 bg-exo-yellow shadow-[0_0_10px_rgba(255,215,0,0.6)]'
: isValid
? 'w-4 h-4 bg-exo-light-gray/70 mt-1'
: 'w-3 h-3 bg-exo-medium-gray/50 mt-1.5'}"
></span>
<span
class="text-sm font-mono mt-1.5 tabular-nums transition-colors {isSelected
? 'text-exo-yellow font-bold'
: isValid
? 'text-white/70'
: 'text-white/30'}">{n}</span
>
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
<!-- Selected Model Preview -->
<div class="space-y-3">
{#if models.length === 0}
<div class="text-center py-8">
<div
class="text-xs text-white/70 font-mono tracking-wider uppercase"
>
Loading models...
</div>
</div>
{:else if loadingPreviews}
<div class="text-center py-8">
<div
class="text-xs text-exo-yellow font-mono tracking-wider uppercase animate-pulse"
>
Loading preview...
</div>
</div>
{:else}
{@const selectedModel = models.find(
(m) => m.id === selectedModelId,
)}
{@const allPreviews = filteredPreviews()}
{#if selectedModel && allPreviews.length > 0}
{@const tags = modelTags()[selectedModel.id] || []}
<div class="space-y-3">
{#each allPreviews as apiPreview, i}
{@const downloadStatus = getModelDownloadStatus(
selectedModel.id,
apiPreview.memory_delta_by_node
? Object.keys(apiPreview.memory_delta_by_node)
: undefined,
)}
<div
role="group"
onmouseenter={() => {
if (apiPreview.memory_delta_by_node) {
hoveredPreviewNodes = new Set(
Object.entries(apiPreview.memory_delta_by_node)
.filter(([, delta]) => (delta ?? 0) > 0)
.map(([nodeId]) => nodeId),
);
}
}}
onmouseleave={() => (hoveredPreviewNodes = new Set())}
>
<ModelCard
model={selectedModel}
isLaunching={launchingModelId === selectedModel.id}
{downloadStatus}
nodes={data?.nodes ?? {}}
sharding={apiPreview.sharding}
runtime={apiPreview.instance_meta}
onLaunch={() =>
launchInstance(selectedModel.id, apiPreview)}
{tags}
{apiPreview}
modelIdOverride={apiPreview.model_id}
/>
</div>
{/each}
</div>
{:else if selectedModel}
<div class="text-center py-4">
<div class="text-xs text-white/50 font-mono">
No valid configurations for current settings
</div>
</div>
{/if}
{/if}
</div>
</div>
{/snippet}
</div>
{:else}
<!-- CHAT STATE: Chat + Mini-Map -->
<div class="flex-1 flex overflow-hidden">
<!-- Chat Area -->
<div
class="flex-1 flex flex-col min-w-0 overflow-hidden"
in:fade={{ duration: 300, delay: 100 }}
>
{#if chatLaunchState !== "idle" && chatLaunchState !== "ready"}
<!-- Model launching/downloading/loading: show progress -->
<div class="flex-1 flex items-center justify-center px-8 py-6">
<div class="flex flex-col items-center gap-6 max-w-md w-full">
<!-- Model name -->
{#if pendingChatModelId}
<p class="text-sm text-white font-mono tracking-wide">
{pendingChatModelId.split("/").pop()?.replace(/-/g, " ") ||
pendingChatModelId}
</p>
{/if}
{#if chatLaunchState === "launching"}
<div class="flex flex-col items-center gap-3">
<div
class="w-8 h-8 border-2 border-exo-yellow/30 border-t-exo-yellow rounded-full animate-spin"
></div>
<p
class="text-xs text-exo-light-gray font-mono uppercase tracking-wider"
>
Preparing to launch…
</p>
</div>
{:else if chatLaunchState === "downloading"}
<div class="w-full flex flex-col gap-3">
<div
class="flex items-center justify-between text-xs font-mono"
>
<span class="text-exo-yellow uppercase tracking-wider"
>Downloading</span
>
{#if chatLaunchDownload}
<span class="text-exo-light-gray tabular-nums">
{chatLaunchDownload.percentage.toFixed(1)}%
</span>
{/if}
</div>
<div
class="w-full h-2 bg-exo-dark-gray rounded-full overflow-hidden border border-exo-medium-gray/30"
>
<div
class="h-full bg-gradient-to-r from-exo-yellow/80 to-exo-yellow rounded-full transition-all duration-300"
style="width: {chatLaunchDownload?.percentage ?? 0}%"
></div>
</div>
{#if chatLaunchDownload}
<div
class="flex justify-between text-[10px] text-exo-light-gray/60 font-mono"
>
<span
>{formatBytes(chatLaunchDownload.downloadedBytes)} / {formatBytes(
chatLaunchDownload.totalBytes,
)}</span
>
<span>
{#if chatLaunchDownload.speed > 0}
{formatBytes(chatLaunchDownload.speed)}/s
{/if}
{#if chatLaunchDownload.etaMs > 0}
· {formatEta(chatLaunchDownload.etaMs)}
{/if}
</span>
</div>
{/if}
</div>
{:else if chatLaunchState === "loading"}
<div class="w-full flex flex-col gap-3">
<div
class="flex items-center justify-between text-xs font-mono"
>
<span class="text-exo-yellow uppercase tracking-wider"
>Loading model</span
>
{#if chatLaunchLoadProgress}
<span class="text-exo-light-gray tabular-nums">
{chatLaunchLoadProgress.layersLoaded}/{chatLaunchLoadProgress.totalLayers}
layers
</span>
{/if}
</div>
<div
class="w-full h-2 bg-exo-dark-gray rounded-full overflow-hidden border border-exo-medium-gray/30"
>
<div
class="h-full bg-gradient-to-r from-exo-yellow/80 to-exo-yellow rounded-full transition-all duration-300"
style="width: {chatLaunchLoadProgress?.percentage ??
0}%"
></div>
</div>
</div>
{/if}
</div>
</div>
<div
class="flex-shrink-0 px-8 pb-6 pt-4 bg-gradient-to-t from-exo-black via-exo-black to-transparent"
>
<div class="max-w-7xl mx-auto">
<ChatForm
placeholder="Ask anything"
showModelSelector={true}
modelTasks={modelTasks()}
modelCapabilities={modelCapabilities()}
onAutoSend={handleChatSend}
onOpenModelPicker={openChatModelPicker}
/>
</div>
</div>
{:else if messages().length > 0 || chatLaunchState === "ready"}
<!-- Normal chat: show messages -->
<div
class="flex-1 overflow-y-auto px-8 py-6"
bind:this={chatScrollRef}
role="log"
aria-live="polite"
aria-label="Chat messages"
>
<div class="max-w-7xl mx-auto">
<ChatMessages scrollParent={chatScrollRef} />
{#if chatLaunchState === "ready" && selectedChatCategory}
{@const prompts =
categorySuggestedPrompts[selectedChatCategory] ??
categorySuggestedPrompts.auto}
<div
class="flex flex-col items-center gap-4 mt-12"
in:fade={{ duration: 300 }}
>
<p
class="text-xs text-exo-light-gray/60 font-mono uppercase tracking-wider"
>
Try asking
</p>
<div class="grid grid-cols-2 gap-2 max-w-lg w-full">
{#each prompts as prompt}
<button
type="button"
onclick={() => {
chatLaunchState = "idle";
selectedChatCategory = null;
handleChatSend(prompt);
}}
class="text-left px-3 py-2.5 text-xs text-exo-light-gray hover:text-white font-mono rounded-lg border border-exo-medium-gray/30 hover:border-exo-yellow/30 bg-exo-dark-gray/30 hover:bg-exo-dark-gray/60 transition-all duration-200 cursor-pointer"
>
{prompt}
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
<div
class="flex-shrink-0 px-8 pb-6 pt-4 bg-gradient-to-t from-exo-black via-exo-black to-transparent"
>
<div class="max-w-7xl mx-auto">
<ChatForm
placeholder="Ask anything"
showModelSelector={true}
modelTasks={modelTasks()}
modelCapabilities={modelCapabilities()}
onAutoSend={handleChatSend}
onOpenModelPicker={openChatModelPicker}
/>
</div>
</div>
{:else}
<!-- No running instance, no messages: show model selector -->
<div
class="flex-1 overflow-y-auto flex items-center justify-center px-8 py-6"
>
<ChatModelSelector
models={models.map((m) => ({
id: m.id,
name: m.name ?? "",
base_model: m.base_model ?? "",
storage_size_megabytes: m.storage_size_megabytes ?? 0,
capabilities: m.capabilities ?? [],
family: m.family ?? "",
quantization: m.quantization ?? "",
}))}
clusterLabel={chatClusterLabel}
totalMemoryGB={availableMemoryGB()}
onSelect={handleChatModelSelect}
onAddModel={handleChatAddModel}
/>
</div>
<div
class="flex-shrink-0 px-8 pb-6 pt-4 bg-gradient-to-t from-exo-black via-exo-black to-transparent"
>
<div class="max-w-7xl mx-auto">
<ChatForm
placeholder="Ask anything — we'll pick the best model automatically"
showModelSelector={!!bestRunningModelId}
modelDisplayOverride={bestRunningModelId ?? undefined}
modelTasks={modelTasks()}
modelCapabilities={modelCapabilities()}
onAutoSend={handleAutoSend}
onOpenModelPicker={openChatModelPicker}
/>
</div>
</div>
{/if}
</div>
<!-- Right: Mini-Map Sidebar - Desktop only -->
{#if minimized}
<aside
class="hidden md:flex w-80 border-l border-exo-yellow/20 bg-exo-dark-gray flex-col flex-shrink-0 overflow-y-auto"
in:fly={{ x: 100, duration: 400, easing: cubicInOut }}
aria-label="Cluster topology"
>
<!-- Topology Section - clickable to go back to main view -->
<button
class="p-4 border-b border-exo-medium-gray/30 w-full text-left cursor-pointer hover:bg-exo-medium-gray/10 transition-colors"
onclick={handleGoHome}
title="Click to return to main topology view"
>
<div class="flex items-center justify-between mb-3">
<div
class="text-xs text-exo-yellow tracking-[0.2em] uppercase flex items-center gap-2"
>
<span
class="w-1.5 h-1.5 bg-exo-yellow rounded-full status-pulse"
></span>
TOPOLOGY
</div>
<span class="text-xs text-white/70 tabular-nums"
>{nodeCount} {nodeCount === 1 ? "NODE" : "NODES"}</span
>
</div>
<div
class="relative aspect-square bg-exo-dark-gray rounded-lg overflow-hidden pointer-events-none"
>
<TopologyGraph
highlightedNodes={highlightedNodes()}
filteredNodes={nodeFilter}
/>
{@render clusterWarningsCompact()}
</div>
</button>
<!-- Instances Section (only shown when instances exist) -->
{#if instanceCount > 0}
<div class="p-4 flex-1">
<!-- Panel Header -->
<div class="flex items-center gap-2 mb-4">
<div
class="w-2 h-2 bg-exo-yellow rounded-full shadow-[0_0_8px_rgba(255,215,0,0.6)] animate-pulse"
></div>
<h3
class="text-xs text-exo-yellow font-mono tracking-[0.2em] uppercase"
>
Instances
</h3>
<div
class="flex-1 h-px bg-gradient-to-r from-exo-yellow/30 to-transparent"
></div>
</div>
<div
class="space-y-3 max-h-72 xl:max-h-96 overflow-y-auto overflow-x-hidden py-px pr-1"
>
{#each Object.entries(instanceData) as [id, instance]}
{@const downloadInfo = getInstanceDownloadStatus(
id,
instance,
)}
{@const statusText = downloadInfo.statusText}
{@const isDownloading = downloadInfo.isDownloading}
{@const isFailed = statusText === "FAILED"}
{@const isLoading = statusText === "LOADING"}
{@const isWarmingUp =
statusText === "WARMING UP" || statusText === "WAITING"}
{@const isReady =
statusText === "READY" || statusText === "LOADED"}
{@const isRunning = statusText === "RUNNING"}
<!-- Instance Card -->
{@const instanceModelId = getInstanceModelId(instance)}
{@const instanceInfo = getInstanceInfo(instance)}
{@const instanceConnections =
getInstanceConnections(instance)}
<div
class="relative group cursor-pointer"
role="button"
tabindex="0"
onmouseenter={() => (hoveredInstanceId = id)}
onmouseleave={() => (hoveredInstanceId = null)}
onclick={() => {
if (
instanceModelId &&
instanceModelId !== "Unknown" &&
instanceModelId !== "Unknown Model"
) {
userForcedIdle = false;
setSelectedChatModel(instanceModelId);
}
}}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (
instanceModelId &&
instanceModelId !== "Unknown" &&
instanceModelId !== "Unknown Model"
) {
setSelectedChatModel(instanceModelId);
}
}
}}
>
<!-- Corner accents -->
<div
class="absolute -top-px -left-px w-2 h-2 border-l border-t {isDownloading
? 'border-blue-500/50'
: isFailed
? 'border-red-500/50'
: isLoading
? 'border-yellow-500/50'
: isReady
? 'border-green-500/50'
: 'border-teal-500/50'}"
></div>
<div
class="absolute -top-px -right-px w-2 h-2 border-r border-t {isDownloading
? 'border-blue-500/50'
: isFailed
? 'border-red-500/50'
: isLoading
? 'border-yellow-500/50'
: isReady
? 'border-green-500/50'
: 'border-teal-500/50'}"
></div>
<div
class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {isDownloading
? 'border-blue-500/50'
: isFailed
? 'border-red-500/50'
: isLoading
? 'border-yellow-500/50'
: isReady
? 'border-green-500/50'
: 'border-teal-500/50'}"
></div>
<div
class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {isDownloading
? 'border-blue-500/50'
: isFailed
? 'border-red-500/50'
: isLoading
? 'border-yellow-500/50'
: isReady
? 'border-green-500/50'
: 'border-teal-500/50'}"
></div>
<div
class="bg-exo-dark-gray/60 border border-l-2 {isDownloading
? 'border-blue-500/30 border-l-blue-400'
: isFailed
? 'border-red-500/30 border-l-red-400'
: isLoading
? 'border-exo-yellow/30 border-l-yellow-400'
: isReady
? 'border-green-500/30 border-l-green-400'
: 'border-teal-500/30 border-l-teal-400'} p-3"
>
<div class="flex justify-between items-start mb-2 pl-2">
<div class="flex items-center gap-2">
<div
class="w-1.5 h-1.5 {isDownloading
? 'bg-blue-400 animate-pulse'
: isFailed
? 'bg-red-400'
: isLoading
? 'bg-yellow-400 animate-pulse'
: isReady
? 'bg-green-400'
: 'bg-teal-400'} rounded-full shadow-[0_0_6px_currentColor]"
></div>
<span
class="text-exo-light-gray font-mono text-sm tracking-wider"
>{id.slice(0, 8).toUpperCase()}</span
>
</div>
<button
onclick={() => deleteInstance(id)}
class="text-xs px-2 py-1 font-mono tracking-wider uppercase border border-red-500/30 text-red-400 hover:bg-red-500/20 hover:text-red-400 hover:border-red-500/50 transition-all duration-200 cursor-pointer"
>
DELETE
</button>
</div>
<div class="pl-2">
<div
class="text-exo-yellow text-xs font-mono tracking-wide truncate"
>
{getInstanceModelId(instance)}
</div>
<div
class="flex items-center gap-2 text-white/60 text-xs font-mono"
>
<span
>{instanceInfo.sharding} · {instanceInfo.instanceType}</span
>
<span
class="px-1.5 py-0.5 text-[10px] tracking-wider uppercase rounded transition-all duration-300 {isDownloading
? 'bg-blue-500/15 text-blue-400'
: isFailed
? 'bg-red-500/15 text-red-400'
: isLoading
? 'bg-yellow-500/15 text-yellow-400'
: isReady
? 'bg-green-500/15 text-green-400'
: 'bg-teal-500/15 text-teal-400'}"
>
{statusText}
</span>
</div>
{#if instanceModelId && instanceModelId !== "Unknown" && instanceModelId !== "Unknown Model"}
<a
class="inline-flex items-center gap-1 text-[11px] text-white/60 hover:text-exo-yellow transition-colors mt-1"
href={`https://huggingface.co/${instanceModelId}`}
target="_blank"
rel="noreferrer noopener"
aria-label="View model on Hugging Face"
>
<span>Hugging Face</span>
<svg
class="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14 3h7v7" />
<path d="M10 14l11-11" />
<path
d="M21 14v6a1 1 0 0 1-1 1h-16a1 1 0 0 1-1-1v-16a1 1 0 0 1 1-1h6"
/>
</svg>
</a>
{/if}
{#if instanceInfo.nodeNames.length > 0}
<div class="text-white/60 text-xs font-mono">
{instanceInfo.nodeNames.join(", ")}
</div>
{/if}
{#if debugEnabled && instanceConnections.length > 0}
<div class="mt-2 space-y-1">
{#each instanceConnections as conn}
<div
class="text-[11px] leading-snug font-mono text-white/70"
>
<span
>{conn.from} -> {conn.to}: {conn.ip}</span
>
<span
class={conn.missingIface
? "text-red-400"
: "text-white/60"}
>
({conn.ifaceLabel})</span
>
</div>
{/each}
</div>
{/if}
<!-- Download Progress -->
{#if downloadInfo.isDownloading && downloadInfo.progress}
<div class="mt-2 space-y-1">
<div
class="flex justify-between text-xs font-mono"
>
<span class="text-blue-400"
>{downloadInfo.progress.percentage.toFixed(
1,
)}%</span
>
<span class="text-exo-light-gray"
>{formatBytes(
downloadInfo.progress.downloadedBytes,
)}/{formatBytes(
downloadInfo.progress.totalBytes,
)}</span
>
</div>
<div
class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
>
<div
class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
style="width: {downloadInfo.progress
.percentage}%"
></div>
</div>
<div
class="flex justify-between text-xs font-mono text-exo-light-gray"
>
<span
>{formatSpeed(
downloadInfo.progress.speed,
)}</span
>
<span
>ETA: {formatEta(
downloadInfo.progress.etaMs,
)}</span
>
<span
>{downloadInfo.progress
.completedFiles}/{downloadInfo.progress
.totalFiles} files</span
>
</div>
</div>
{#if downloadInfo.perNode.length > 0}
<div
class="mt-2 space-y-2 max-h-48 overflow-y-auto pr-1"
>
{#each downloadInfo.perNode.filter((n) => n.status === "downloading" && n.progress) as nodeProg}
{@const nodePercent = Math.min(
100,
Math.max(0, nodeProg.percentage),
)}
{@const isExpanded =
instanceDownloadExpandedNodes.has(
nodeProg.nodeId,
)}
<div
class="rounded border border-exo-medium-gray/40 bg-exo-black/30 p-2"
>
<button
type="button"
class="w-full text-left space-y-1.5"
onclick={() =>
toggleInstanceDownloadDetails(
nodeProg.nodeId,
)}
>
<div
class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
>
<span
class="text-white/80 truncate pr-2"
>{nodeProg.nodeName}</span
>
<span
class="flex items-center gap-1 text-blue-300"
>
{nodePercent.toFixed(1)}%
<svg
class="w-3 h-3 text-exo-light-gray"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M6 8l4 4 4-4"
class={isExpanded
? "transform rotate-180 origin-center transition-transform duration-150"
: "transition-transform duration-150"}
></path>
</svg>
</span>
</div>
<div
class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
>
<div
class="absolute inset-y-0 left-0 bg-gradient-to-r from-blue-500 to-blue-400 transition-all duration-300"
style="width: {nodePercent.toFixed(
1,
)}%"
></div>
</div>
<div
class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray"
>
<span
>{formatBytes(
nodeProg.progress
?.downloadedBytes ?? 0,
)} / {formatBytes(
nodeProg.progress?.totalBytes ?? 0,
)}</span
>
<span
>{formatSpeed(
nodeProg.progress?.speed ?? 0,
)} • ETA {formatEta(
nodeProg.progress?.etaMs ?? 0,
)}</span
>
</div>
</button>
{#if isExpanded}
<div class="mt-2 space-y-1.5">
{#if nodeProg.progress?.files ?? [].length === 0}
<div
class="text-[11px] font-mono text-exo-light-gray/70"
>
No file details reported.
</div>
{:else}
{#each nodeProg.progress?.files ?? [] as f}
{@const filePercent = Math.min(
100,
Math.max(0, f.percentage ?? 0),
)}
{@const isFileComplete =
filePercent >= 100}
<div
class="rounded border border-exo-medium-gray/30 bg-exo-black/40 p-2"
>
<div
class="flex items-center justify-between text-[10px] font-mono text-exo-light-gray/90"
>
<span class="truncate pr-2"
>{f.name}</span
>
<span
class={isFileComplete
? "text-green-400"
: "text-white/80"}
>{filePercent.toFixed(
1,
)}%</span
>
</div>
<div
class="relative h-1 bg-exo-black/60 rounded-sm overflow-hidden mt-1"
>
<div
class="absolute inset-y-0 left-0 bg-gradient-to-r {isFileComplete
? 'from-green-500 to-green-400'
: 'from-exo-yellow to-exo-yellow/70'} transition-all duration-300"
style="width: {filePercent.toFixed(
1,
)}%"
></div>
</div>
<div
class="flex items-center justify-between text-[10px] text-exo-light-gray/70 mt-0.5"
>
<span
>{formatBytes(
f.downloadedBytes,
)} / {formatBytes(
f.totalBytes,
)}</span
>
<span
>{formatSpeed(f.speed)} • ETA
{formatEta(f.etaMs)}</span
>
</div>
</div>
{/each}
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
<div class="mt-2 space-y-1">
<div
class="text-xs text-blue-400 font-mono tracking-wider"
>
DOWNLOADING
</div>
<p
class="text-[11px] text-white/50 leading-relaxed"
>
Downloading model files. Model runs on your
devices so needs to be downloaded before you can
chat.
</p>
</div>
{:else}
<div class="mt-1 space-y-1">
<div
class="text-xs {getStatusColor(
downloadInfo.statusText,
)} font-mono tracking-wider"
>
{downloadInfo.statusText}
</div>
{#if isLoading}
{@const loadStatus =
deriveInstanceStatus(instance)}
{#if loadStatus.totalLayers && loadStatus.totalLayers > 0}
<div class="mt-1 space-y-1">
<div
class="flex justify-between text-xs font-mono"
>
<span class="text-yellow-400"
>{(
((loadStatus.layersLoaded ?? 0) /
loadStatus.totalLayers) *
100
).toFixed(0)}%</span
>
<span class="text-exo-light-gray"
>{loadStatus.layersLoaded ?? 0} / {loadStatus.totalLayers}
layers</span
>
</div>
<div
class="relative h-1.5 bg-exo-black/60 rounded-sm overflow-hidden"
>
<div
class="absolute inset-y-0 left-0 bg-gradient-to-r from-yellow-500 to-yellow-400 transition-all duration-300"
style="width: {((loadStatus.layersLoaded ??
0) /
loadStatus.totalLayers) *
100}%"
></div>
</div>
</div>
{:else}
<p
class="text-[11px] text-white/50 leading-relaxed"
>
Loading model into memory...
</p>
{/if}
{:else if isWarmingUp}
<p
class="text-[11px] text-white/50 leading-relaxed"
>
Warming up...
</p>
{:else if isReady || isRunning}
<p
class="text-[11px] text-green-400/70 leading-relaxed"
>
Ready to chat!
</p>
{/if}
</div>
{#if downloadInfo.isFailed && downloadInfo.errorMessage}
<div
class="text-xs text-red-400/80 font-mono mt-1 break-words"
>
{downloadInfo.errorMessage}
</div>
{/if}
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
</aside>
{/if}
</div>
{/if}
</main>
</div>
{#if !showOnboarding}
<ModelPickerModal
isOpen={isModelPickerOpen}
{models}
{selectedModelId}
favorites={favoritesSet}
{recentModelIds}
hasRecents={showRecentsTab}
existingModelIds={new Set(models.map((m) => m.id))}
canModelFit={(modelId) => {
const model = models.find((m) => m.id === modelId);
return model ? hasEnoughMemory(model) : false;
}}
getModelFitStatus={(modelId): ModelMemoryFitStatus => {
const model = models.find((m) => m.id === modelId);
return model ? getModelMemoryFitStatus(model) : "too_large";
}}
onSelect={(modelId) => {
if (modelPickerContext === "chat") {
handleChatPickerSelect(modelId);
} else {
handleModelPickerSelect(modelId);
}
}}
onClose={() => (isModelPickerOpen = false)}
onToggleFavorite={toggleFavorite}
onAddModel={addModelFromPicker}
onDeleteModel={deleteCustomModel}
totalMemoryGB={clusterMemory().total / (1024 * 1024 * 1024)}
usedMemoryGB={clusterMemory().used / (1024 * 1024 * 1024)}
{downloadsData}
topologyNodes={data?.nodes}
instanceStatuses={modelInstanceStatuses}
/>
{/if}