← back to Exo
treefmt: add Svelte file formatting
a7e205e48958b85739820ef1f7de12d0731216f7 · 2026-01-19 13:07:39 +0000 · Jake Hillion
Package prettier with Svelte support and add it to treefmt-nix to format
the dashboard. This change is brutal, I spent a long time trying to get
it nicer but it doesn't seem there's a good way to make this minimal.
Sorry for the noise!
This will make it easier for new contributors to get the formatting
right first time.
Also removes the `.prettierrc` because it turns out treefmt-nix was
ignoring it.
Test plan:
- CI
Files touched
D .prettierrcM dashboard/package-lock.jsonM dashboard/package.jsonM dashboard/parts.nixM dashboard/src/app.d.tsM dashboard/src/lib/components/ChatAttachments.svelteM dashboard/src/lib/components/ChatForm.svelteM dashboard/src/lib/components/ChatMessages.svelteM dashboard/src/lib/components/ChatSidebar.svelteM dashboard/src/lib/components/HeaderNav.svelteM dashboard/src/lib/components/ImageParamsPanel.svelteM dashboard/src/lib/components/MarkdownContent.svelteM dashboard/src/lib/components/ModelCard.svelteM dashboard/src/lib/components/TopologyGraph.svelteM dashboard/src/lib/stores/app.svelte.tsM dashboard/src/lib/types/files.tsM dashboard/src/routes/+layout.svelteM dashboard/src/routes/+page.svelteM dashboard/src/routes/downloads/+page.svelteM dashboard/vite.config.tsM flake.nix
Diff
commit a7e205e48958b85739820ef1f7de12d0731216f7
Author: Jake Hillion <jake@hillion.co.uk>
Date: Mon Jan 19 13:07:39 2026 +0000
treefmt: add Svelte file formatting
Package prettier with Svelte support and add it to treefmt-nix to format
the dashboard. This change is brutal, I spent a long time trying to get
it nicer but it doesn't seem there's a good way to make this minimal.
Sorry for the noise!
This will make it easier for new contributors to get the formatting
right first time.
Also removes the `.prettierrc` because it turns out treefmt-nix was
ignoring it.
Test plan:
- CI
---
.prettierrc | 3 -
dashboard/package-lock.json | 38 +-
dashboard/package.json | 2 +
dashboard/parts.nix | 13 +-
dashboard/src/app.d.ts | 14 +-
.../src/lib/components/ChatAttachments.svelte | 151 +-
dashboard/src/lib/components/ChatForm.svelte | 1368 +++---
dashboard/src/lib/components/ChatMessages.svelte | 1259 ++---
dashboard/src/lib/components/ChatSidebar.svelte | 1032 ++--
dashboard/src/lib/components/HeaderNav.svelte | 196 +-
.../src/lib/components/ImageParamsPanel.svelte | 1042 ++--
.../src/lib/components/MarkdownContent.svelte | 1922 ++++----
dashboard/src/lib/components/ModelCard.svelte | 1721 ++++---
dashboard/src/lib/components/TopologyGraph.svelte | 2087 ++++----
dashboard/src/lib/stores/app.svelte.ts | 3924 +++++++--------
dashboard/src/lib/types/files.ts | 348 +-
dashboard/src/routes/+layout.svelte | 13 +-
dashboard/src/routes/+page.svelte | 5020 ++++++++++++--------
dashboard/src/routes/downloads/+page.svelte | 1041 ++--
dashboard/vite.config.ts | 18 +-
flake.nix | 3 +-
21 files changed, 11673 insertions(+), 9542 deletions(-)
diff --git a/.prettierrc b/.prettierrc
deleted file mode 100644
index 661f2536..00000000
--- a/.prettierrc
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "useTabs": true
-}
diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json
index fddf99de..345c73d2 100644
--- a/dashboard/package-lock.json
+++ b/dashboard/package-lock.json
@@ -21,6 +21,8 @@
"@types/d3": "^7.4.3",
"@types/node": "^22",
"d3": "^7.9.0",
+ "prettier": "^3.4.2",
+ "prettier-plugin-svelte": "^3.3.3",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
@@ -863,7 +865,6 @@
"integrity": "sha512-oH8tXw7EZnie8FdOWYrF7Yn4IKrqTFHhXvl8YxXxbKwTMcD/5NNCryUSEXRk2ZR4ojnub0P8rNrsVGHXWqIDtA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
@@ -903,7 +904,6 @@
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
"debug": "^4.4.1",
@@ -1520,7 +1520,6 @@
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1530,7 +1529,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1943,7 +1941,6 @@
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"dev": true,
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=12"
}
@@ -2651,7 +2648,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -2688,6 +2684,33 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/prettier": {
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
+ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/prettier-plugin-svelte": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.1.tgz",
+ "integrity": "sha512-xL49LCloMoZRvSwa6IEdN2GV6cq2IqpYGstYtMT+5wmml1/dClEoI0MZR78MiVPpu6BdQFfN0/y73yO6+br5Pg==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "prettier": "^3.0.0",
+ "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
+ }
+ },
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -2839,7 +2862,6 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.3.tgz",
"integrity": "sha512-ngKXNhNvwPzF43QqEhDOue7TQTrG09em1sd4HBxVF0Wr2gopAmdEWan+rgbdgK4fhBtSOTJO8bYU4chUG7VXZQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -2984,7 +3006,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3006,7 +3027,6 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
diff --git a/dashboard/package.json b/dashboard/package.json
index 57e4be28..45ec739c 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -11,6 +11,8 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
+ "prettier": "^3.4.2",
+ "prettier-plugin-svelte": "^3.3.3",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.48.4",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
diff --git a/dashboard/parts.nix b/dashboard/parts.nix
index 80fbb156..487078d5 100644
--- a/dashboard/parts.nix
+++ b/dashboard/parts.nix
@@ -4,7 +4,7 @@
{ pkgs, lib, ... }:
let
# Filter source to only include dashboard directory
- src = lib.cleanSourceWith {
+ dashboardSrc = lib.cleanSourceWith {
src = inputs.self;
filter =
path: type:
@@ -30,7 +30,7 @@
}
# Inject the filtered source
{
- deps.dashboardSrc = lib.mkForce "${src}/dashboard";
+ deps.dashboardSrc = lib.mkForce "${dashboardSrc}/dashboard";
}
];
};
@@ -40,5 +40,14 @@
packages.dashboard = pkgs.runCommand "exo-dashboard" { } ''
cp -r ${dashboardFull}/build $out
'';
+
+ # Prettier with svelte plugin for treefmt
+ packages.prettier-svelte = pkgs.writeShellScriptBin "prettier-svelte" ''
+ export NODE_PATH="${dashboardFull}/lib/node_modules/exo-dashboard/node_modules"
+ exec ${pkgs.nodejs}/bin/node \
+ ${dashboardFull}/lib/node_modules/exo-dashboard/node_modules/prettier/bin/prettier.cjs \
+ --plugin "${dashboardFull}/lib/node_modules/exo-dashboard/node_modules/prettier-plugin-svelte/plugin.js" \
+ "$@"
+ '';
};
}
diff --git a/dashboard/src/app.d.ts b/dashboard/src/app.d.ts
index da08e6da..520c4217 100644
--- a/dashboard/src/app.d.ts
+++ b/dashboard/src/app.d.ts
@@ -1,13 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
- namespace App {
- // interface Error {}
- // interface Locals {}
- // interface PageData {}
- // interface PageState {}
- // interface Platform {}
- }
+ namespace App {
+ // interface Error {}
+ // interface Locals {}
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
}
export {};
diff --git a/dashboard/src/lib/components/ChatAttachments.svelte b/dashboard/src/lib/components/ChatAttachments.svelte
index f56e23e3..7c579a23 100644
--- a/dashboard/src/lib/components/ChatAttachments.svelte
+++ b/dashboard/src/lib/components/ChatAttachments.svelte
@@ -1,75 +1,94 @@
<script lang="ts">
- import type { ChatUploadedFile } from '$lib/types/files';
- import { formatFileSize, getFileCategory } from '$lib/types/files';
+ import type { ChatUploadedFile } from "$lib/types/files";
+ import { formatFileSize, getFileCategory } from "$lib/types/files";
- interface Props {
- files: ChatUploadedFile[];
- readonly?: boolean;
- onRemove?: (fileId: string) => void;
- }
+ interface Props {
+ files: ChatUploadedFile[];
+ readonly?: boolean;
+ onRemove?: (fileId: string) => void;
+ }
- let { files, readonly = false, onRemove }: Props = $props();
+ let { files, readonly = false, onRemove }: Props = $props();
- function getFileIcon(file: ChatUploadedFile): string {
- const category = getFileCategory(file.type, file.name);
- switch (category) {
- case 'image': return '🖼';
- case 'text': return '📄';
- case 'pdf': return '📑';
- case 'audio': return '🎵';
- default: return '📎';
- }
- }
+ function getFileIcon(file: ChatUploadedFile): string {
+ const category = getFileCategory(file.type, file.name);
+ switch (category) {
+ case "image":
+ return "🖼";
+ case "text":
+ return "📄";
+ case "pdf":
+ return "📑";
+ case "audio":
+ return "🎵";
+ default:
+ return "📎";
+ }
+ }
- function truncateName(name: string, maxLen: number = 20): string {
- if (name.length <= maxLen) return name;
- const ext = name.slice(name.lastIndexOf('.'));
- const base = name.slice(0, name.lastIndexOf('.'));
- const available = maxLen - ext.length - 3;
- return base.slice(0, available) + '...' + ext;
- }
+ function truncateName(name: string, maxLen: number = 20): string {
+ if (name.length <= maxLen) return name;
+ const ext = name.slice(name.lastIndexOf("."));
+ const base = name.slice(0, name.lastIndexOf("."));
+ const available = maxLen - ext.length - 3;
+ return base.slice(0, available) + "..." + ext;
+ }
</script>
{#if files.length > 0}
- <div class="flex flex-wrap gap-2 mb-3 px-1">
- {#each files as file (file.id)}
- <div class="group relative flex items-center gap-2 bg-exo-dark-gray/80 border border-exo-yellow/30 rounded px-2.5 py-1.5 text-xs font-mono transition-all hover:border-exo-yellow/50 hover:shadow-[0_0_10px_rgba(255,215,0,0.1)]">
- <!-- File preview or icon -->
- {#if file.preview && getFileCategory(file.type, file.name) === 'image'}
- <img
- src={file.preview}
- alt={file.name}
- class="w-8 h-8 object-cover rounded border border-exo-yellow/20"
- />
- {:else}
- <span class="text-base">{getFileIcon(file)}</span>
- {/if}
-
- <!-- File info -->
- <div class="flex flex-col min-w-0">
- <span class="text-exo-yellow truncate max-w-[120px]" title={file.name}>
- {truncateName(file.name)}
- </span>
- <span class="text-exo-light-gray text-xs">
- {formatFileSize(file.size)}
- </span>
- </div>
-
- <!-- Remove button -->
- {#if !readonly && onRemove}
- <button
- type="button"
- onclick={() => onRemove?.(file.id)}
- class="ml-1 w-4 h-4 flex items-center justify-center text-exo-light-gray hover:text-red-400 transition-colors cursor-pointer"
- title="Remove file"
- >
- <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
- </svg>
- </button>
- {/if}
- </div>
- {/each}
- </div>
-{/if}
+ <div class="flex flex-wrap gap-2 mb-3 px-1">
+ {#each files as file (file.id)}
+ <div
+ class="group relative flex items-center gap-2 bg-exo-dark-gray/80 border border-exo-yellow/30 rounded px-2.5 py-1.5 text-xs font-mono transition-all hover:border-exo-yellow/50 hover:shadow-[0_0_10px_rgba(255,215,0,0.1)]"
+ >
+ <!-- File preview or icon -->
+ {#if file.preview && getFileCategory(file.type, file.name) === "image"}
+ <img
+ src={file.preview}
+ alt={file.name}
+ class="w-8 h-8 object-cover rounded border border-exo-yellow/20"
+ />
+ {:else}
+ <span class="text-base">{getFileIcon(file)}</span>
+ {/if}
+
+ <!-- File info -->
+ <div class="flex flex-col min-w-0">
+ <span
+ class="text-exo-yellow truncate max-w-[120px]"
+ title={file.name}
+ >
+ {truncateName(file.name)}
+ </span>
+ <span class="text-exo-light-gray text-xs">
+ {formatFileSize(file.size)}
+ </span>
+ </div>
+ <!-- Remove button -->
+ {#if !readonly && onRemove}
+ <button
+ type="button"
+ onclick={() => onRemove?.(file.id)}
+ class="ml-1 w-4 h-4 flex items-center justify-center text-exo-light-gray hover:text-red-400 transition-colors cursor-pointer"
+ title="Remove file"
+ >
+ <svg
+ class="w-3 h-3"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M6 18L18 6M6 6l12 12"
+ />
+ </svg>
+ </button>
+ {/if}
+ </div>
+ {/each}
+ </div>
+{/if}
diff --git a/dashboard/src/lib/components/ChatForm.svelte b/dashboard/src/lib/components/ChatForm.svelte
index d1648480..42a9bb4c 100644
--- a/dashboard/src/lib/components/ChatForm.svelte
+++ b/dashboard/src/lib/components/ChatForm.svelte
@@ -1,695 +1,695 @@
<script lang="ts">
- import {
- isLoading,
- sendMessage,
- generateImage,
- editImage,
- editingImage,
- clearEditingImage,
- selectedChatModel,
- setSelectedChatModel,
- instances,
- ttftMs,
- tps,
- totalTokens,
- } from "$lib/stores/app.svelte";
- import ChatAttachments from "./ChatAttachments.svelte";
- import ImageParamsPanel from "./ImageParamsPanel.svelte";
- import type { ChatUploadedFile } from "$lib/types/files";
- import { processUploadedFiles, getAcceptString } from "$lib/types/files";
-
- interface Props {
- class?: string;
- placeholder?: string;
- showHelperText?: boolean;
- autofocus?: boolean;
- showModelSelector?: boolean;
- modelTasks?: Record<string, string[]>;
- }
-
- let {
- class: className = "",
- placeholder = "Ask anything",
- showHelperText = false,
- autofocus = true,
- showModelSelector = false,
- modelTasks = {},
- }: Props = $props();
-
- let message = $state("");
- let textareaRef: HTMLTextAreaElement | undefined = $state();
- let fileInputRef: HTMLInputElement | undefined = $state();
- let uploadedFiles = $state<ChatUploadedFile[]>([]);
- let isDragOver = $state(false);
- let loading = $derived(isLoading());
- const currentModel = $derived(selectedChatModel());
- const instanceData = $derived(instances());
- const currentTtft = $derived(ttftMs());
- const currentTps = $derived(tps());
- const currentTokens = $derived(totalTokens());
- const currentEditingImage = $derived(editingImage());
- const isEditMode = $derived(currentEditingImage !== null);
-
- // Custom dropdown state
- let isModelDropdownOpen = $state(false);
- let dropdownButtonRef: HTMLButtonElement | undefined = $state();
- let dropdownPosition = $derived(() => {
- if (!dropdownButtonRef || !isModelDropdownOpen)
- return { top: 0, left: 0, width: 0 };
- const rect = dropdownButtonRef.getBoundingClientRect();
- return {
- top: rect.top,
- left: rect.left,
- width: rect.width,
- };
- });
-
- // Accept all supported file types
- const acceptString = getAcceptString(["image", "text", "pdf"]);
-
- function modelSupportsImageGeneration(modelId: string): boolean {
- const tasks = modelTasks[modelId] || [];
- return tasks.includes("TextToImage") || tasks.includes("ImageToImage");
- }
-
- function modelSupportsTextToImage(modelId: string): boolean {
- const tasks = modelTasks[modelId] || [];
- return tasks.includes("TextToImage");
- }
-
- function modelSupportsOnlyImageEditing(modelId: string): boolean {
- const tasks = modelTasks[modelId] || [];
- return tasks.includes("ImageToImage") && !tasks.includes("TextToImage");
- }
-
- function modelSupportsImageEditing(modelId: string): boolean {
- const tasks = modelTasks[modelId] || [];
- return tasks.includes("ImageToImage");
- }
-
- const isImageModel = $derived(() => {
- if (!currentModel) return false;
- return modelSupportsTextToImage(currentModel);
- });
-
- const isEditOnlyWithoutImage = $derived(
- currentModel !== null &&
- modelSupportsOnlyImageEditing(currentModel) &&
- !isEditMode &&
- uploadedFiles.length === 0,
- );
-
- // Show edit mode when: explicit edit mode OR (model supports ImageToImage AND files attached)
- const shouldShowEditMode = $derived(
- isEditMode ||
- (currentModel &&
- modelSupportsImageEditing(currentModel) &&
- uploadedFiles.length > 0),
- );
-
- // Extract available models from running instances
- const availableModels = $derived(() => {
- const models: Array<{ id: string; label: string; isImageModel: boolean }> =
- [];
- for (const [, instance] of Object.entries(instanceData)) {
- const modelId = getInstanceModelId(instance);
- if (
- modelId &&
- modelId !== "Unknown" &&
- !models.some((m) => m.id === modelId)
- ) {
- models.push({
- id: modelId,
- label: modelId.split("/").pop() || modelId,
- isImageModel: modelSupportsImageGeneration(modelId),
- });
- }
- }
- return models;
- });
-
- // Track previous model IDs to detect newly added models (plain variable to avoid reactive loop)
- let previousModelIds: Set<string> = new Set();
-
- // Auto-select the first available model if none is selected, if current selection is stale, or if a new model is added
- $effect(() => {
- const models = availableModels();
- const currentModelIds = new Set(models.map((m) => m.id));
-
- if (models.length > 0) {
- // Find newly added models (in current but not in previous)
- const newModels = models.filter((m) => !previousModelIds.has(m.id));
-
- // If no model selected, select the first available
- if (!currentModel) {
- setSelectedChatModel(models[0].id);
- }
- // If current model is stale (no longer has a running instance), reset to first available
- else if (!models.some((m) => m.id === currentModel)) {
- setSelectedChatModel(models[0].id);
- }
- // If a new model was just added, select it
- else if (newModels.length > 0 && previousModelIds.size > 0) {
- setSelectedChatModel(newModels[0].id);
- }
- } else {
- // No instances running - clear the selected model
- if (currentModel) {
- setSelectedChatModel("");
- }
- }
-
- // Update previous model IDs for next comparison
- previousModelIds = currentModelIds;
- });
-
- function getInstanceModelId(instanceWrapped: unknown): string {
- if (!instanceWrapped || typeof instanceWrapped !== "object") return "";
- const keys = Object.keys(instanceWrapped as Record<string, unknown>);
- if (keys.length === 1) {
- const instance = (instanceWrapped as Record<string, unknown>)[
- keys[0]
- ] as { shardAssignments?: { modelId?: string } };
- return instance?.shardAssignments?.modelId || "";
- }
- return "";
- }
-
- async function handleFiles(files: File[]) {
- if (files.length === 0) return;
- const processed = await processUploadedFiles(files);
- uploadedFiles = [...uploadedFiles, ...processed];
- }
-
- function handleFileInputChange(event: Event) {
- const input = event.target as HTMLInputElement;
- if (input.files && input.files.length > 0) {
- handleFiles(Array.from(input.files));
- input.value = ""; // Reset for next selection
- }
- }
-
- function handleFileRemove(fileId: string) {
- uploadedFiles = uploadedFiles.filter((f) => f.id !== fileId);
- }
-
- function handlePaste(event: ClipboardEvent) {
- if (!event.clipboardData) return;
-
- const files = Array.from(event.clipboardData.items)
- .filter((item) => item.kind === "file")
- .map((item) => item.getAsFile())
- .filter((file): file is File => file !== null);
-
- if (files.length > 0) {
- event.preventDefault();
- handleFiles(files);
- return;
- }
-
- // Handle long text paste as file
- const text = event.clipboardData.getData("text/plain");
- if (text.length > 2500) {
- event.preventDefault();
- const textFile = new File([text], "pasted-text.txt", {
- type: "text/plain",
- });
- handleFiles([textFile]);
- }
- }
-
- function handleDragOver(event: DragEvent) {
- event.preventDefault();
- isDragOver = true;
- }
-
- function handleDragLeave(event: DragEvent) {
- event.preventDefault();
- isDragOver = false;
- }
-
- function handleDrop(event: DragEvent) {
- event.preventDefault();
- isDragOver = false;
-
- if (event.dataTransfer?.files) {
- handleFiles(Array.from(event.dataTransfer.files));
- }
- }
-
- function handleKeydown(event: KeyboardEvent) {
- // Prevent form submission during IME composition (e.g., Chinese, Japanese, Korean input)
- if (event.isComposing || event.keyCode === 229) {
- return;
- }
-
- if (event.key === "Enter" && !event.shiftKey) {
- event.preventDefault();
- handleSubmit();
- }
- }
-
- function handleSubmit() {
- if ((!message.trim() && uploadedFiles.length === 0) || loading) return;
-
- const content = message.trim();
- const files = [...uploadedFiles];
-
- message = "";
- uploadedFiles = [];
- resetTextareaHeight();
-
- // Use image editing if in edit mode
- if (isEditMode && currentEditingImage && content) {
- editImage(content, currentEditingImage.imageDataUrl);
- }
- // If user attached an image with an ImageToImage model, use edit endpoint
- else if (
- currentModel &&
- modelSupportsImageEditing(currentModel) &&
- files.length > 0 &&
- content
- ) {
- // Use the first attached image for editing
- const imageFile = files[0];
- if (imageFile.preview) {
- editImage(content, imageFile.preview);
- }
- } else if (isImageModel() && content) {
- // Use image generation for text-to-image models
- generateImage(content);
- } else {
- sendMessage(content, files);
- }
-
- // Refocus the textarea after sending
- setTimeout(() => textareaRef?.focus(), 10);
- }
-
- function handleInput() {
- if (!textareaRef) return;
- textareaRef.style.height = "auto";
- textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px";
- }
-
- function resetTextareaHeight() {
- if (textareaRef) {
- textareaRef.style.height = "auto";
- }
- }
-
- function openFilePicker() {
- fileInputRef?.click();
- }
-
- // Track previous loading state to detect when loading completes
- let wasLoading = $state(false);
-
- $effect(() => {
- if (autofocus && textareaRef) {
- setTimeout(() => textareaRef?.focus(), 10);
- }
- });
-
- // Refocus after loading completes (AI response finished)
- $effect(() => {
- if (wasLoading && !loading && textareaRef) {
- setTimeout(() => textareaRef?.focus(), 50);
- }
- wasLoading = loading;
- });
-
- const canSend = $derived(
- message.trim().length > 0 || uploadedFiles.length > 0,
- );
+ import {
+ isLoading,
+ sendMessage,
+ generateImage,
+ editImage,
+ editingImage,
+ clearEditingImage,
+ selectedChatModel,
+ setSelectedChatModel,
+ instances,
+ ttftMs,
+ tps,
+ totalTokens,
+ } from "$lib/stores/app.svelte";
+ import ChatAttachments from "./ChatAttachments.svelte";
+ import ImageParamsPanel from "./ImageParamsPanel.svelte";
+ import type { ChatUploadedFile } from "$lib/types/files";
+ import { processUploadedFiles, getAcceptString } from "$lib/types/files";
+
+ interface Props {
+ class?: string;
+ placeholder?: string;
+ showHelperText?: boolean;
+ autofocus?: boolean;
+ showModelSelector?: boolean;
+ modelTasks?: Record<string, string[]>;
+ }
+
+ let {
+ class: className = "",
+ placeholder = "Ask anything",
+ showHelperText = false,
+ autofocus = true,
+ showModelSelector = false,
+ modelTasks = {},
+ }: Props = $props();
+
+ let message = $state("");
+ let textareaRef: HTMLTextAreaElement | undefined = $state();
+ let fileInputRef: HTMLInputElement | undefined = $state();
+ let uploadedFiles = $state<ChatUploadedFile[]>([]);
+ let isDragOver = $state(false);
+ let loading = $derived(isLoading());
+ const currentModel = $derived(selectedChatModel());
+ const instanceData = $derived(instances());
+ const currentTtft = $derived(ttftMs());
+ const currentTps = $derived(tps());
+ const currentTokens = $derived(totalTokens());
+ const currentEditingImage = $derived(editingImage());
+ const isEditMode = $derived(currentEditingImage !== null);
+
+ // Custom dropdown state
+ let isModelDropdownOpen = $state(false);
+ let dropdownButtonRef: HTMLButtonElement | undefined = $state();
+ let dropdownPosition = $derived(() => {
+ if (!dropdownButtonRef || !isModelDropdownOpen)
+ return { top: 0, left: 0, width: 0 };
+ const rect = dropdownButtonRef.getBoundingClientRect();
+ return {
+ top: rect.top,
+ left: rect.left,
+ width: rect.width,
+ };
+ });
+
+ // Accept all supported file types
+ const acceptString = getAcceptString(["image", "text", "pdf"]);
+
+ function modelSupportsImageGeneration(modelId: string): boolean {
+ const tasks = modelTasks[modelId] || [];
+ return tasks.includes("TextToImage") || tasks.includes("ImageToImage");
+ }
+
+ function modelSupportsTextToImage(modelId: string): boolean {
+ const tasks = modelTasks[modelId] || [];
+ return tasks.includes("TextToImage");
+ }
+
+ function modelSupportsOnlyImageEditing(modelId: string): boolean {
+ const tasks = modelTasks[modelId] || [];
+ return tasks.includes("ImageToImage") && !tasks.includes("TextToImage");
+ }
+
+ function modelSupportsImageEditing(modelId: string): boolean {
+ const tasks = modelTasks[modelId] || [];
+ return tasks.includes("ImageToImage");
+ }
+
+ const isImageModel = $derived(() => {
+ if (!currentModel) return false;
+ return modelSupportsTextToImage(currentModel);
+ });
+
+ const isEditOnlyWithoutImage = $derived(
+ currentModel !== null &&
+ modelSupportsOnlyImageEditing(currentModel) &&
+ !isEditMode &&
+ uploadedFiles.length === 0,
+ );
+
+ // Show edit mode when: explicit edit mode OR (model supports ImageToImage AND files attached)
+ const shouldShowEditMode = $derived(
+ isEditMode ||
+ (currentModel &&
+ modelSupportsImageEditing(currentModel) &&
+ uploadedFiles.length > 0),
+ );
+
+ // Extract available models from running instances
+ const availableModels = $derived(() => {
+ const models: Array<{ id: string; label: string; isImageModel: boolean }> =
+ [];
+ for (const [, instance] of Object.entries(instanceData)) {
+ const modelId = getInstanceModelId(instance);
+ if (
+ modelId &&
+ modelId !== "Unknown" &&
+ !models.some((m) => m.id === modelId)
+ ) {
+ models.push({
+ id: modelId,
+ label: modelId.split("/").pop() || modelId,
+ isImageModel: modelSupportsImageGeneration(modelId),
+ });
+ }
+ }
+ return models;
+ });
+
+ // Track previous model IDs to detect newly added models (plain variable to avoid reactive loop)
+ let previousModelIds: Set<string> = new Set();
+
+ // Auto-select the first available model if none is selected, if current selection is stale, or if a new model is added
+ $effect(() => {
+ const models = availableModels();
+ const currentModelIds = new Set(models.map((m) => m.id));
+
+ if (models.length > 0) {
+ // Find newly added models (in current but not in previous)
+ const newModels = models.filter((m) => !previousModelIds.has(m.id));
+
+ // If no model selected, select the first available
+ if (!currentModel) {
+ setSelectedChatModel(models[0].id);
+ }
+ // If current model is stale (no longer has a running instance), reset to first available
+ else if (!models.some((m) => m.id === currentModel)) {
+ setSelectedChatModel(models[0].id);
+ }
+ // If a new model was just added, select it
+ else if (newModels.length > 0 && previousModelIds.size > 0) {
+ setSelectedChatModel(newModels[0].id);
+ }
+ } else {
+ // No instances running - clear the selected model
+ if (currentModel) {
+ setSelectedChatModel("");
+ }
+ }
+
+ // Update previous model IDs for next comparison
+ previousModelIds = currentModelIds;
+ });
+
+ function getInstanceModelId(instanceWrapped: unknown): string {
+ if (!instanceWrapped || typeof instanceWrapped !== "object") return "";
+ const keys = Object.keys(instanceWrapped as Record<string, unknown>);
+ if (keys.length === 1) {
+ const instance = (instanceWrapped as Record<string, unknown>)[
+ keys[0]
+ ] as { shardAssignments?: { modelId?: string } };
+ return instance?.shardAssignments?.modelId || "";
+ }
+ return "";
+ }
+
+ async function handleFiles(files: File[]) {
+ if (files.length === 0) return;
+ const processed = await processUploadedFiles(files);
+ uploadedFiles = [...uploadedFiles, ...processed];
+ }
+
+ function handleFileInputChange(event: Event) {
+ const input = event.target as HTMLInputElement;
+ if (input.files && input.files.length > 0) {
+ handleFiles(Array.from(input.files));
+ input.value = ""; // Reset for next selection
+ }
+ }
+
+ function handleFileRemove(fileId: string) {
+ uploadedFiles = uploadedFiles.filter((f) => f.id !== fileId);
+ }
+
+ function handlePaste(event: ClipboardEvent) {
+ if (!event.clipboardData) return;
+
+ const files = Array.from(event.clipboardData.items)
+ .filter((item) => item.kind === "file")
+ .map((item) => item.getAsFile())
+ .filter((file): file is File => file !== null);
+
+ if (files.length > 0) {
+ event.preventDefault();
+ handleFiles(files);
+ return;
+ }
+
+ // Handle long text paste as file
+ const text = event.clipboardData.getData("text/plain");
+ if (text.length > 2500) {
+ event.preventDefault();
+ const textFile = new File([text], "pasted-text.txt", {
+ type: "text/plain",
+ });
+ handleFiles([textFile]);
+ }
+ }
+
+ function handleDragOver(event: DragEvent) {
+ event.preventDefault();
+ isDragOver = true;
+ }
+
+ function handleDragLeave(event: DragEvent) {
+ event.preventDefault();
+ isDragOver = false;
+ }
+
+ function handleDrop(event: DragEvent) {
+ event.preventDefault();
+ isDragOver = false;
+
+ if (event.dataTransfer?.files) {
+ handleFiles(Array.from(event.dataTransfer.files));
+ }
+ }
+
+ function handleKeydown(event: KeyboardEvent) {
+ // Prevent form submission during IME composition (e.g., Chinese, Japanese, Korean input)
+ if (event.isComposing || event.keyCode === 229) {
+ return;
+ }
+
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+ handleSubmit();
+ }
+ }
+
+ function handleSubmit() {
+ if ((!message.trim() && uploadedFiles.length === 0) || loading) return;
+
+ const content = message.trim();
+ const files = [...uploadedFiles];
+
+ message = "";
+ uploadedFiles = [];
+ resetTextareaHeight();
+
+ // Use image editing if in edit mode
+ if (isEditMode && currentEditingImage && content) {
+ editImage(content, currentEditingImage.imageDataUrl);
+ }
+ // If user attached an image with an ImageToImage model, use edit endpoint
+ else if (
+ currentModel &&
+ modelSupportsImageEditing(currentModel) &&
+ files.length > 0 &&
+ content
+ ) {
+ // Use the first attached image for editing
+ const imageFile = files[0];
+ if (imageFile.preview) {
+ editImage(content, imageFile.preview);
+ }
+ } else if (isImageModel() && content) {
+ // Use image generation for text-to-image models
+ generateImage(content);
+ } else {
+ sendMessage(content, files);
+ }
+
+ // Refocus the textarea after sending
+ setTimeout(() => textareaRef?.focus(), 10);
+ }
+
+ function handleInput() {
+ if (!textareaRef) return;
+ textareaRef.style.height = "auto";
+ textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px";
+ }
+
+ function resetTextareaHeight() {
+ if (textareaRef) {
+ textareaRef.style.height = "auto";
+ }
+ }
+
+ function openFilePicker() {
+ fileInputRef?.click();
+ }
+
+ // Track previous loading state to detect when loading completes
+ let wasLoading = $state(false);
+
+ $effect(() => {
+ if (autofocus && textareaRef) {
+ setTimeout(() => textareaRef?.focus(), 10);
+ }
+ });
+
+ // Refocus after loading completes (AI response finished)
+ $effect(() => {
+ if (wasLoading && !loading && textareaRef) {
+ setTimeout(() => textareaRef?.focus(), 50);
+ }
+ wasLoading = loading;
+ });
+
+ const canSend = $derived(
+ message.trim().length > 0 || uploadedFiles.length > 0,
+ );
</script>
<!-- Hidden file input -->
<input
- bind:this={fileInputRef}
- type="file"
- accept={acceptString}
- multiple
- class="hidden"
- onchange={handleFileInputChange}
+ bind:this={fileInputRef}
+ type="file"
+ accept={acceptString}
+ multiple
+ class="hidden"
+ onchange={handleFileInputChange}
/>
<form
- onsubmit={(e) => {
- e.preventDefault();
- handleSubmit();
- }}
- class="w-full {className}"
- ondragover={handleDragOver}
- ondragleave={handleDragLeave}
- ondrop={handleDrop}
+ onsubmit={(e) => {
+ e.preventDefault();
+ handleSubmit();
+ }}
+ class="w-full {className}"
+ ondragover={handleDragOver}
+ ondragleave={handleDragLeave}
+ ondrop={handleDrop}
>
- <div
- class="relative command-panel rounded overflow-hidden transition-all duration-200 {isDragOver
- ? 'ring-2 ring-exo-yellow ring-opacity-50'
- : ''}"
- >
- <!-- Top accent line -->
- <div
- class="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-exo-yellow/50 to-transparent"
- ></div>
-
- <!-- Drag overlay -->
- {#if isDragOver}
- <div
- class="absolute inset-0 bg-exo-dark-gray/80 z-10 flex items-center justify-center"
- >
- <div class="text-exo-yellow text-sm font-mono tracking-wider uppercase">
- DROP FILES HERE
- </div>
- </div>
- {/if}
-
- <!-- Edit mode banner -->
- {#if isEditMode && currentEditingImage}
- <div
- class="flex items-center gap-3 px-3 py-2 bg-exo-yellow/10 border-b border-exo-yellow/30"
- >
- <img
- src={currentEditingImage.imageDataUrl}
- alt="Source for editing"
- class="w-10 h-10 object-cover rounded border border-exo-yellow/30"
- />
- <div class="flex-1">
- <span
- class="text-xs font-mono tracking-wider uppercase text-exo-yellow"
- >EDITING IMAGE</span
- >
- </div>
- <button
- type="button"
- onclick={() => clearEditingImage()}
- class="px-2 py-1 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50 rounded hover:bg-exo-medium-gray/50 hover:text-exo-yellow transition-colors cursor-pointer"
- >
- CANCEL
- </button>
- </div>
- {/if}
-
- <!-- Model selector (when enabled) -->
- {#if showModelSelector && availableModels().length > 0}
- <div
- class="flex items-center justify-between gap-2 px-3 py-2 border-b border-exo-medium-gray/30"
- >
- <div class="flex items-center gap-2 flex-1">
- <span
- class="text-xs text-exo-light-gray uppercase tracking-wider flex-shrink-0"
- >MODEL:</span
- >
- <!-- Custom dropdown -->
- <div class="relative flex-1 max-w-xs">
- <button
- bind:this={dropdownButtonRef}
- type="button"
- onclick={() => (isModelDropdownOpen = !isModelDropdownOpen)}
- class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-3 pr-8 py-1.5 text-xs 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 {isModelDropdownOpen
- ? 'border-exo-yellow/70'
- : ''}"
- >
- {#if availableModels().find((m) => m.id === currentModel)}
- <span class="text-exo-yellow truncate"
- >{availableModels().find((m) => m.id === currentModel)
- ?.label}</span
- >
- {:else if availableModels().length > 0}
- <span class="text-exo-yellow truncate"
- >{availableModels()[0].label}</span
- >
- {:else}
- <span class="text-exo-light-gray/50">— SELECT MODEL —</span>
- {/if}
- </button>
- <div
- class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isModelDropdownOpen
- ? 'rotate-180'
- : ''}"
- >
- <svg
- class="w-3 h-3 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>
- </div>
-
- {#if isModelDropdownOpen}
- <!-- Backdrop to close dropdown -->
- <button
- type="button"
- class="fixed inset-0 z-[9998] cursor-default"
- onclick={() => (isModelDropdownOpen = false)}
- aria-label="Close dropdown"
- ></button>
-
- <!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
- <div
- class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto"
- style="bottom: calc(100vh - {dropdownPosition()
- .top}px + 4px); left: {dropdownPosition()
- .left}px; width: {dropdownPosition().width}px;"
- >
- <div class="py-1">
- {#each availableModels() as model}
- <button
- type="button"
- onclick={() => {
- setSelectedChatModel(model.id);
- isModelDropdownOpen = false;
- }}
- class="w-full px-3 py-2 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {currentModel ===
- model.id
- ? 'bg-transparent text-exo-yellow'
- : 'text-exo-light-gray hover:text-exo-yellow'}"
- >
- {#if currentModel === model.id}
- <svg
- class="w-3 h-3 flex-shrink-0"
- fill="currentColor"
- viewBox="0 0 20 20"
- >
- <path
- fill-rule="evenodd"
- d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
- clip-rule="evenodd"
- />
- </svg>
- {:else}
- <span class="w-3"></span>
- {/if}
- {#if model.isImageModel}
- <svg
- class="w-3.5 h-3.5 flex-shrink-0 text-exo-yellow"
- fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
- stroke-width="2"
- aria-label="Image generation model"
- >
- <rect
- x="3"
- y="3"
- width="18"
- height="18"
- rx="2"
- ry="2"
- />
- <circle cx="8.5" cy="8.5" r="1.5" />
- <polyline points="21 15 16 10 5 21" />
- </svg>
- {/if}
- <span class="truncate flex-1">{model.label}</span>
- </button>
- {/each}
- </div>
- </div>
- {/if}
- </div>
- <!-- Performance stats -->
- {#if currentTtft !== null || currentTps !== null}
- <div class="flex items-center gap-4 text-xs font-mono flex-shrink-0">
- {#if currentTtft !== null}
- <span class="text-exo-light-gray">
- <span class="text-white/70">TTFT</span>
- <span class="text-exo-yellow">{currentTtft.toFixed(1)}ms</span>
- </span>
- {/if}
- {#if currentTps !== null}
- <span class="text-exo-light-gray">
- <span class="text-white/70">TPS</span>
- <span class="text-exo-yellow">{currentTps.toFixed(1)}</span>
- <span class="text-white/60">tok/s</span>
- <span class="text-white/50"
- >({(1000 / currentTps).toFixed(1)} ms/tok)</span
- >
- </span>
- {/if}
- </div>
- {/if}
- </div>
- {/if}
-
- <!-- Image params panel (shown for image models or edit mode) -->
- {#if showModelSelector && (isImageModel() || isEditMode)}
- <ImageParamsPanel {isEditMode} />
- {/if}
-
- <!-- Attached files preview -->
- {#if uploadedFiles.length > 0}
- <div class="px-3 pt-3">
- <ChatAttachments files={uploadedFiles} onRemove={handleFileRemove} />
- </div>
- {/if}
-
- <!-- Input area -->
- <div class="flex items-start gap-2 sm:gap-3 py-3 px-3 sm:px-4">
- <!-- Attach file button -->
- <button
- type="button"
- onclick={openFilePicker}
- disabled={loading}
- class="flex items-center justify-center w-7 h-7 rounded text-exo-light-gray hover:text-exo-yellow transition-all disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0 cursor-pointer"
- title="Attach file"
- >
- <svg
- class="w-4 h-4"
- fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- stroke-width="2"
- d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
- />
- </svg>
- </button>
-
- <!-- Terminal prompt -->
- <span class="text-exo-yellow text-sm font-bold flex-shrink-0 leading-7"
- >▶</span
- >
-
- <textarea
- bind:this={textareaRef}
- bind:value={message}
- onkeydown={handleKeydown}
- oninput={handleInput}
- onpaste={handlePaste}
- placeholder={isEditOnlyWithoutImage
- ? "Attach an image to edit..."
- : isEditMode
- ? "Describe how to edit this image..."
- : isImageModel()
- ? "Describe the image you want to generate..."
- : placeholder}
- disabled={loading}
- rows={1}
- class="flex-1 resize-none bg-transparent text-foreground placeholder:text-exo-light-gray/60 placeholder:text-sm placeholder:tracking-[0.15em] placeholder:leading-7 focus:outline-none focus:ring-0 focus:border-none disabled:opacity-50 text-sm leading-7 font-mono"
- style="min-height: 28px; max-height: 150px;"
- ></textarea>
-
- <button
- type="submit"
- disabled={!canSend || loading || isEditOnlyWithoutImage}
- class="px-2.5 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-xs tracking-[0.1em] sm:tracking-[0.15em] uppercase font-medium transition-all duration-200 whitespace-nowrap
+ <div
+ class="relative command-panel rounded overflow-hidden transition-all duration-200 {isDragOver
+ ? 'ring-2 ring-exo-yellow ring-opacity-50'
+ : ''}"
+ >
+ <!-- Top accent line -->
+ <div
+ class="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-exo-yellow/50 to-transparent"
+ ></div>
+
+ <!-- Drag overlay -->
+ {#if isDragOver}
+ <div
+ class="absolute inset-0 bg-exo-dark-gray/80 z-10 flex items-center justify-center"
+ >
+ <div class="text-exo-yellow text-sm font-mono tracking-wider uppercase">
+ DROP FILES HERE
+ </div>
+ </div>
+ {/if}
+
+ <!-- Edit mode banner -->
+ {#if isEditMode && currentEditingImage}
+ <div
+ class="flex items-center gap-3 px-3 py-2 bg-exo-yellow/10 border-b border-exo-yellow/30"
+ >
+ <img
+ src={currentEditingImage.imageDataUrl}
+ alt="Source for editing"
+ class="w-10 h-10 object-cover rounded border border-exo-yellow/30"
+ />
+ <div class="flex-1">
+ <span
+ class="text-xs font-mono tracking-wider uppercase text-exo-yellow"
+ >EDITING IMAGE</span
+ >
+ </div>
+ <button
+ type="button"
+ onclick={() => clearEditingImage()}
+ class="px-2 py-1 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50 rounded hover:bg-exo-medium-gray/50 hover:text-exo-yellow transition-colors cursor-pointer"
+ >
+ CANCEL
+ </button>
+ </div>
+ {/if}
+
+ <!-- Model selector (when enabled) -->
+ {#if showModelSelector && availableModels().length > 0}
+ <div
+ class="flex items-center justify-between gap-2 px-3 py-2 border-b border-exo-medium-gray/30"
+ >
+ <div class="flex items-center gap-2 flex-1">
+ <span
+ class="text-xs text-exo-light-gray uppercase tracking-wider flex-shrink-0"
+ >MODEL:</span
+ >
+ <!-- Custom dropdown -->
+ <div class="relative flex-1 max-w-xs">
+ <button
+ bind:this={dropdownButtonRef}
+ type="button"
+ onclick={() => (isModelDropdownOpen = !isModelDropdownOpen)}
+ class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-3 pr-8 py-1.5 text-xs 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 {isModelDropdownOpen
+ ? 'border-exo-yellow/70'
+ : ''}"
+ >
+ {#if availableModels().find((m) => m.id === currentModel)}
+ <span class="text-exo-yellow truncate"
+ >{availableModels().find((m) => m.id === currentModel)
+ ?.label}</span
+ >
+ {:else if availableModels().length > 0}
+ <span class="text-exo-yellow truncate"
+ >{availableModels()[0].label}</span
+ >
+ {:else}
+ <span class="text-exo-light-gray/50">— SELECT MODEL —</span>
+ {/if}
+ </button>
+ <div
+ class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isModelDropdownOpen
+ ? 'rotate-180'
+ : ''}"
+ >
+ <svg
+ class="w-3 h-3 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>
+ </div>
+
+ {#if isModelDropdownOpen}
+ <!-- Backdrop to close dropdown -->
+ <button
+ type="button"
+ class="fixed inset-0 z-[9998] cursor-default"
+ onclick={() => (isModelDropdownOpen = false)}
+ aria-label="Close dropdown"
+ ></button>
+
+ <!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
+ <div
+ class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto"
+ style="bottom: calc(100vh - {dropdownPosition()
+ .top}px + 4px); left: {dropdownPosition()
+ .left}px; width: {dropdownPosition().width}px;"
+ >
+ <div class="py-1">
+ {#each availableModels() as model}
+ <button
+ type="button"
+ onclick={() => {
+ setSelectedChatModel(model.id);
+ isModelDropdownOpen = false;
+ }}
+ class="w-full px-3 py-2 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {currentModel ===
+ model.id
+ ? 'bg-transparent text-exo-yellow'
+ : 'text-exo-light-gray hover:text-exo-yellow'}"
+ >
+ {#if currentModel === model.id}
+ <svg
+ class="w-3 h-3 flex-shrink-0"
+ fill="currentColor"
+ viewBox="0 0 20 20"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ {:else}
+ <span class="w-3"></span>
+ {/if}
+ {#if model.isImageModel}
+ <svg
+ class="w-3.5 h-3.5 flex-shrink-0 text-exo-yellow"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+ aria-label="Image generation model"
+ >
+ <rect
+ x="3"
+ y="3"
+ width="18"
+ height="18"
+ rx="2"
+ ry="2"
+ />
+ <circle cx="8.5" cy="8.5" r="1.5" />
+ <polyline points="21 15 16 10 5 21" />
+ </svg>
+ {/if}
+ <span class="truncate flex-1">{model.label}</span>
+ </button>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+ <!-- Performance stats -->
+ {#if currentTtft !== null || currentTps !== null}
+ <div class="flex items-center gap-4 text-xs font-mono flex-shrink-0">
+ {#if currentTtft !== null}
+ <span class="text-exo-light-gray">
+ <span class="text-white/70">TTFT</span>
+ <span class="text-exo-yellow">{currentTtft.toFixed(1)}ms</span>
+ </span>
+ {/if}
+ {#if currentTps !== null}
+ <span class="text-exo-light-gray">
+ <span class="text-white/70">TPS</span>
+ <span class="text-exo-yellow">{currentTps.toFixed(1)}</span>
+ <span class="text-white/60">tok/s</span>
+ <span class="text-white/50"
+ >({(1000 / currentTps).toFixed(1)} ms/tok)</span
+ >
+ </span>
+ {/if}
+ </div>
+ {/if}
+ </div>
+ {/if}
+
+ <!-- Image params panel (shown for image models or edit mode) -->
+ {#if showModelSelector && (isImageModel() || isEditMode)}
+ <ImageParamsPanel {isEditMode} />
+ {/if}
+
+ <!-- Attached files preview -->
+ {#if uploadedFiles.length > 0}
+ <div class="px-3 pt-3">
+ <ChatAttachments files={uploadedFiles} onRemove={handleFileRemove} />
+ </div>
+ {/if}
+
+ <!-- Input area -->
+ <div class="flex items-start gap-2 sm:gap-3 py-3 px-3 sm:px-4">
+ <!-- Attach file button -->
+ <button
+ type="button"
+ onclick={openFilePicker}
+ disabled={loading}
+ class="flex items-center justify-center w-7 h-7 rounded text-exo-light-gray hover:text-exo-yellow transition-all disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0 cursor-pointer"
+ title="Attach file"
+ >
+ <svg
+ class="w-4 h-4"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
+ />
+ </svg>
+ </button>
+
+ <!-- Terminal prompt -->
+ <span class="text-exo-yellow text-sm font-bold flex-shrink-0 leading-7"
+ >▶</span
+ >
+
+ <textarea
+ bind:this={textareaRef}
+ bind:value={message}
+ onkeydown={handleKeydown}
+ oninput={handleInput}
+ onpaste={handlePaste}
+ placeholder={isEditOnlyWithoutImage
+ ? "Attach an image to edit..."
+ : isEditMode
+ ? "Describe how to edit this image..."
+ : isImageModel()
+ ? "Describe the image you want to generate..."
+ : placeholder}
+ disabled={loading}
+ rows={1}
+ class="flex-1 resize-none bg-transparent text-foreground placeholder:text-exo-light-gray/60 placeholder:text-sm placeholder:tracking-[0.15em] placeholder:leading-7 focus:outline-none focus:ring-0 focus:border-none disabled:opacity-50 text-sm leading-7 font-mono"
+ style="min-height: 28px; max-height: 150px;"
+ ></textarea>
+
+ <button
+ type="submit"
+ disabled={!canSend || loading || isEditOnlyWithoutImage}
+ class="px-2.5 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-xs tracking-[0.1em] sm:tracking-[0.15em] uppercase font-medium transition-all duration-200 whitespace-nowrap
{!canSend || loading || isEditOnlyWithoutImage
- ? 'bg-exo-medium-gray/50 text-exo-light-gray cursor-not-allowed'
- : 'bg-exo-yellow text-exo-black hover:bg-exo-yellow-darker hover:shadow-[0_0_20px_rgba(255,215,0,0.3)]'}"
- aria-label={shouldShowEditMode
- ? "Edit image"
- : isImageModel()
- ? "Generate image"
- : "Send message"}
- >
- {#if loading}
- <span class="inline-flex items-center gap-1 sm:gap-2">
- <span
- class="w-2.5 h-2.5 sm:w-3 sm:h-3 border-2 border-current border-t-transparent rounded-full animate-spin"
- ></span>
- <span class="hidden sm:inline"
- >{shouldShowEditMode
- ? "EDITING"
- : isImageModel()
- ? "GENERATING"
- : "PROCESSING"}</span
- >
- <span class="sm:hidden">...</span>
- </span>
- {:else if shouldShowEditMode}
- <span class="inline-flex items-center gap-1.5">
- <svg
- class="w-3.5 h-3.5"
- fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
- stroke-width="2"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
- />
- </svg>
- <span>EDIT</span>
- </span>
- {:else if isImageModel()}
- <span class="inline-flex items-center gap-1.5">
- <svg
- class="w-3.5 h-3.5"
- fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
- stroke-width="2"
- >
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
- <circle cx="8.5" cy="8.5" r="1.5" />
- <polyline points="21 15 16 10 5 21" />
- </svg>
- <span>GENERATE</span>
- </span>
- {:else}
- SEND
- {/if}
- </button>
- </div>
-
- <!-- Bottom accent line -->
- <div
- class="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-exo-yellow/30 to-transparent"
- ></div>
- </div>
-
- {#if showHelperText}
- <p
- class="mt-2 sm:mt-3 text-center text-xs sm:text-xs text-exo-light-gray tracking-[0.1em] sm:tracking-[0.15em] uppercase"
- >
- <kbd
- class="px-1 sm:px-1.5 py-0.5 rounded bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50"
- >ENTER</kbd
- >
- <span class="mx-0.5 sm:mx-1">TO SEND</span>
- <span class="text-exo-medium-gray mx-1 sm:mx-2">|</span>
- <kbd
- class="px-1 sm:px-1.5 py-0.5 rounded bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50"
- >SHIFT+ENTER</kbd
- >
- <span class="mx-0.5 sm:mx-1">NEW LINE</span>
- <span class="text-exo-medium-gray mx-1 sm:mx-2">|</span>
- <span class="text-exo-light-gray">DRAG & DROP OR PASTE FILES</span>
- </p>
- {/if}
+ ? 'bg-exo-medium-gray/50 text-exo-light-gray cursor-not-allowed'
+ : 'bg-exo-yellow text-exo-black hover:bg-exo-yellow-darker hover:shadow-[0_0_20px_rgba(255,215,0,0.3)]'}"
+ aria-label={shouldShowEditMode
+ ? "Edit image"
+ : isImageModel()
+ ? "Generate image"
+ : "Send message"}
+ >
+ {#if loading}
+ <span class="inline-flex items-center gap-1 sm:gap-2">
+ <span
+ class="w-2.5 h-2.5 sm:w-3 sm:h-3 border-2 border-current border-t-transparent rounded-full animate-spin"
+ ></span>
+ <span class="hidden sm:inline"
+ >{shouldShowEditMode
+ ? "EDITING"
+ : isImageModel()
+ ? "GENERATING"
+ : "PROCESSING"}</span
+ >
+ <span class="sm:hidden">...</span>
+ </span>
+ {:else if shouldShowEditMode}
+ <span class="inline-flex items-center gap-1.5">
+ <svg
+ class="w-3.5 h-3.5"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
+ />
+ </svg>
+ <span>EDIT</span>
+ </span>
+ {:else if isImageModel()}
+ <span class="inline-flex items-center gap-1.5">
+ <svg
+ class="w-3.5 h-3.5"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+ >
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
+ <circle cx="8.5" cy="8.5" r="1.5" />
+ <polyline points="21 15 16 10 5 21" />
+ </svg>
+ <span>GENERATE</span>
+ </span>
+ {:else}
+ SEND
+ {/if}
+ </button>
+ </div>
+
+ <!-- Bottom accent line -->
+ <div
+ class="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-exo-yellow/30 to-transparent"
+ ></div>
+ </div>
+
+ {#if showHelperText}
+ <p
+ class="mt-2 sm:mt-3 text-center text-xs sm:text-xs text-exo-light-gray tracking-[0.1em] sm:tracking-[0.15em] uppercase"
+ >
+ <kbd
+ class="px-1 sm:px-1.5 py-0.5 rounded bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50"
+ >ENTER</kbd
+ >
+ <span class="mx-0.5 sm:mx-1">TO SEND</span>
+ <span class="text-exo-medium-gray mx-1 sm:mx-2">|</span>
+ <kbd
+ class="px-1 sm:px-1.5 py-0.5 rounded bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50"
+ >SHIFT+ENTER</kbd
+ >
+ <span class="mx-0.5 sm:mx-1">NEW LINE</span>
+ <span class="text-exo-medium-gray mx-1 sm:mx-2">|</span>
+ <span class="text-exo-light-gray">DRAG & DROP OR PASTE FILES</span>
+ </p>
+ {/if}
</form>
diff --git a/dashboard/src/lib/components/ChatMessages.svelte b/dashboard/src/lib/components/ChatMessages.svelte
index 90e03128..15ea088d 100644
--- a/dashboard/src/lib/components/ChatMessages.svelte
+++ b/dashboard/src/lib/components/ChatMessages.svelte
@@ -1,536 +1,733 @@
<script lang="ts">
- import {
- messages,
- currentResponse,
- isLoading,
- deleteMessage,
- editAndRegenerate,
- regenerateLastResponse,
- setEditingImage
- } from '$lib/stores/app.svelte';
- import type { Message } from '$lib/stores/app.svelte';
- import type { MessageAttachment } from '$lib/stores/app.svelte';
- import MarkdownContent from './MarkdownContent.svelte';
-
- interface Props {
- class?: string;
- scrollParent?: HTMLElement | null;
- }
-
- let { class: className = '', scrollParent = null }: Props = $props();
-
- const messageList = $derived(messages());
- const response = $derived(currentResponse());
- const loading = $derived(isLoading());
-
- // Scroll management - user controls scroll, show button when not at bottom
- const SCROLL_THRESHOLD = 100;
- let showScrollButton = $state(false);
- let lastMessageCount = 0;
- let containerRef: HTMLDivElement | undefined = $state();
-
- function getScrollContainer(): HTMLElement | null {
- if (scrollParent) return scrollParent;
- return containerRef?.parentElement ?? null;
- }
-
- function isNearBottom(el: HTMLElement): boolean {
- return el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD;
- }
-
- function scrollToBottom() {
- const el = getScrollContainer();
- if (el) {
- el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
- }
- }
-
- function updateScrollButtonVisibility() {
- const el = getScrollContainer();
- if (!el) return;
- showScrollButton = !isNearBottom(el);
- }
-
- // Attach scroll listener
- $effect(() => {
- const el = scrollParent ?? containerRef?.parentElement;
- if (!el) return;
-
- el.addEventListener('scroll', updateScrollButtonVisibility, { passive: true });
- // Initial check
- updateScrollButtonVisibility();
- return () => el.removeEventListener('scroll', updateScrollButtonVisibility);
- });
-
- // Auto-scroll when user sends a new message
- $effect(() => {
- const count = messageList.length;
- if (count > lastMessageCount) {
- const el = getScrollContainer();
- if (el) {
- requestAnimationFrame(() => {
- el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
- });
- }
- }
- lastMessageCount = count;
- });
-
- // Update scroll button visibility when content changes
- $effect(() => {
- // Track response to trigger re-check during streaming
- const _ = response;
-
- // Small delay to let DOM update
- requestAnimationFrame(() => updateScrollButtonVisibility());
- });
-
- // Edit state
- let editingMessageId = $state<string | null>(null);
- let editContent = $state('');
- let editTextareaRef: HTMLTextAreaElement | undefined = $state();
-
- // Delete confirmation state
- let deleteConfirmId = $state<string | null>(null);
-
-// Copied state for feedback
-let copiedMessageId = $state<string | null>(null);
-let expandedThinkingMessageIds = $state<Set<string>>(new Set());
-
- function formatTimestamp(timestamp: number): string {
- return new Date(timestamp).toLocaleTimeString('en-US', {
- hour12: false,
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit'
- });
- }
-
- function getAttachmentIcon(attachment: MessageAttachment): string {
- switch (attachment.type) {
- case 'image': return '🖼';
- case 'text': return '📄';
- default: return '📎';
- }
- }
-
- function truncateName(name: string, maxLen: number = 25): string {
- if (name.length <= maxLen) return name;
- const ext = name.slice(name.lastIndexOf('.'));
- const base = name.slice(0, name.lastIndexOf('.'));
- const available = maxLen - ext.length - 3;
- return base.slice(0, available) + '...' + ext;
- }
-
- async function handleCopy(content: string, messageId: string) {
- try {
- await navigator.clipboard.writeText(content);
- copiedMessageId = messageId;
- setTimeout(() => {
- copiedMessageId = null;
- }, 2000);
- } catch (error) {
- console.error('Failed to copy:', error);
- }
- }
-
-function toggleThinkingVisibility(messageId: string) {
- const next = new Set(expandedThinkingMessageIds);
- if (next.has(messageId)) {
- next.delete(messageId);
- } else {
- next.add(messageId);
- }
- expandedThinkingMessageIds = next;
-}
-
-function isThinkingExpanded(messageId: string): boolean {
- return expandedThinkingMessageIds.has(messageId);
-}
-
- function handleStartEdit(messageId: string, content: string) {
- editingMessageId = messageId;
- editContent = content;
- setTimeout(() => {
- if (editTextareaRef) {
- editTextareaRef.focus();
- editTextareaRef.setSelectionRange(editTextareaRef.value.length, editTextareaRef.value.length);
- // Auto-resize
- editTextareaRef.style.height = 'auto';
- editTextareaRef.style.height = Math.min(editTextareaRef.scrollHeight, 200) + 'px';
- }
- }, 10);
- }
-
- function handleCancelEdit() {
- editingMessageId = null;
- editContent = '';
- }
-
- function handleSaveEdit() {
- if (editingMessageId && editContent.trim()) {
- editAndRegenerate(editingMessageId, editContent.trim());
- }
- editingMessageId = null;
- editContent = '';
- }
-
- function handleEditKeydown(event: KeyboardEvent) {
- if (event.key === 'Enter' && !event.shiftKey) {
- event.preventDefault();
- handleSaveEdit();
- } else if (event.key === 'Escape') {
- handleCancelEdit();
- }
- }
-
- function handleEditInput() {
- if (editTextareaRef) {
- editTextareaRef.style.height = 'auto';
- editTextareaRef.style.height = Math.min(editTextareaRef.scrollHeight, 200) + 'px';
- }
- }
-
- function handleDeleteClick(messageId: string) {
- deleteConfirmId = messageId;
- }
-
- function handleConfirmDelete() {
- if (deleteConfirmId) {
- deleteMessage(deleteConfirmId);
- deleteConfirmId = null;
- }
- }
-
- function handleCancelDelete() {
- deleteConfirmId = null;
- }
-
- function handleRegenerate() {
- regenerateLastResponse();
- }
-
- // Check if a message is the last assistant message
- function isLastAssistantMessage(messageId: string): boolean {
- for (let i = messageList.length - 1; i >= 0; i--) {
- if (messageList[i].role === 'assistant') {
- return messageList[i].id === messageId;
- }
- }
- return false;
- }
+ import {
+ messages,
+ currentResponse,
+ isLoading,
+ deleteMessage,
+ editAndRegenerate,
+ regenerateLastResponse,
+ setEditingImage,
+ } from "$lib/stores/app.svelte";
+ import type { Message } from "$lib/stores/app.svelte";
+ import type { MessageAttachment } from "$lib/stores/app.svelte";
+ import MarkdownContent from "./MarkdownContent.svelte";
+
+ interface Props {
+ class?: string;
+ scrollParent?: HTMLElement | null;
+ }
+
+ let { class: className = "", scrollParent = null }: Props = $props();
+
+ const messageList = $derived(messages());
+ const response = $derived(currentResponse());
+ const loading = $derived(isLoading());
+
+ // Scroll management - user controls scroll, show button when not at bottom
+ const SCROLL_THRESHOLD = 100;
+ let showScrollButton = $state(false);
+ let lastMessageCount = 0;
+ let containerRef: HTMLDivElement | undefined = $state();
+
+ function getScrollContainer(): HTMLElement | null {
+ if (scrollParent) return scrollParent;
+ return containerRef?.parentElement ?? null;
+ }
+
+ function isNearBottom(el: HTMLElement): boolean {
+ return el.scrollHeight - el.scrollTop - el.clientHeight < SCROLL_THRESHOLD;
+ }
+
+ function scrollToBottom() {
+ const el = getScrollContainer();
+ if (el) {
+ el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
+ }
+ }
+
+ function updateScrollButtonVisibility() {
+ const el = getScrollContainer();
+ if (!el) return;
+ showScrollButton = !isNearBottom(el);
+ }
+
+ // Attach scroll listener
+ $effect(() => {
+ const el = scrollParent ?? containerRef?.parentElement;
+ if (!el) return;
+
+ el.addEventListener("scroll", updateScrollButtonVisibility, {
+ passive: true,
+ });
+ // Initial check
+ updateScrollButtonVisibility();
+ return () => el.removeEventListener("scroll", updateScrollButtonVisibility);
+ });
+
+ // Auto-scroll when user sends a new message
+ $effect(() => {
+ const count = messageList.length;
+ if (count > lastMessageCount) {
+ const el = getScrollContainer();
+ if (el) {
+ requestAnimationFrame(() => {
+ el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
+ });
+ }
+ }
+ lastMessageCount = count;
+ });
+
+ // Update scroll button visibility when content changes
+ $effect(() => {
+ // Track response to trigger re-check during streaming
+ const _ = response;
+
+ // Small delay to let DOM update
+ requestAnimationFrame(() => updateScrollButtonVisibility());
+ });
+
+ // Edit state
+ let editingMessageId = $state<string | null>(null);
+ let editContent = $state("");
+ let editTextareaRef: HTMLTextAreaElement | undefined = $state();
+
+ // Delete confirmation state
+ let deleteConfirmId = $state<string | null>(null);
+
+ // Copied state for feedback
+ let copiedMessageId = $state<string | null>(null);
+ let expandedThinkingMessageIds = $state<Set<string>>(new Set());
+
+ function formatTimestamp(timestamp: number): string {
+ return new Date(timestamp).toLocaleTimeString("en-US", {
+ hour12: false,
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
+ }
+
+ function getAttachmentIcon(attachment: MessageAttachment): string {
+ switch (attachment.type) {
+ case "image":
+ return "🖼";
+ case "text":
+ return "📄";
+ default:
+ return "📎";
+ }
+ }
+
+ function truncateName(name: string, maxLen: number = 25): string {
+ if (name.length <= maxLen) return name;
+ const ext = name.slice(name.lastIndexOf("."));
+ const base = name.slice(0, name.lastIndexOf("."));
+ const available = maxLen - ext.length - 3;
+ return base.slice(0, available) + "..." + ext;
+ }
+
+ async function handleCopy(content: string, messageId: string) {
+ try {
+ await navigator.clipboard.writeText(content);
+ copiedMessageId = messageId;
+ setTimeout(() => {
+ copiedMessageId = null;
+ }, 2000);
+ } catch (error) {
+ console.error("Failed to copy:", error);
+ }
+ }
+
+ function toggleThinkingVisibility(messageId: string) {
+ const next = new Set(expandedThinkingMessageIds);
+ if (next.has(messageId)) {
+ next.delete(messageId);
+ } else {
+ next.add(messageId);
+ }
+ expandedThinkingMessageIds = next;
+ }
+
+ function isThinkingExpanded(messageId: string): boolean {
+ return expandedThinkingMessageIds.has(messageId);
+ }
+
+ function handleStartEdit(messageId: string, content: string) {
+ editingMessageId = messageId;
+ editContent = content;
+ setTimeout(() => {
+ if (editTextareaRef) {
+ editTextareaRef.focus();
+ editTextareaRef.setSelectionRange(
+ editTextareaRef.value.length,
+ editTextareaRef.value.length,
+ );
+ // Auto-resize
+ editTextareaRef.style.height = "auto";
+ editTextareaRef.style.height =
+ Math.min(editTextareaRef.scrollHeight, 200) + "px";
+ }
+ }, 10);
+ }
+
+ function handleCancelEdit() {
+ editingMessageId = null;
+ editContent = "";
+ }
+
+ function handleSaveEdit() {
+ if (editingMessageId && editContent.trim()) {
+ editAndRegenerate(editingMessageId, editContent.trim());
+ }
+ editingMessageId = null;
+ editContent = "";
+ }
+
+ function handleEditKeydown(event: KeyboardEvent) {
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+ handleSaveEdit();
+ } else if (event.key === "Escape") {
+ handleCancelEdit();
+ }
+ }
+
+ function handleEditInput() {
+ if (editTextareaRef) {
+ editTextareaRef.style.height = "auto";
+ editTextareaRef.style.height =
+ Math.min(editTextareaRef.scrollHeight, 200) + "px";
+ }
+ }
+
+ function handleDeleteClick(messageId: string) {
+ deleteConfirmId = messageId;
+ }
+
+ function handleConfirmDelete() {
+ if (deleteConfirmId) {
+ deleteMessage(deleteConfirmId);
+ deleteConfirmId = null;
+ }
+ }
+
+ function handleCancelDelete() {
+ deleteConfirmId = null;
+ }
+
+ function handleRegenerate() {
+ regenerateLastResponse();
+ }
+
+ // Check if a message is the last assistant message
+ function isLastAssistantMessage(messageId: string): boolean {
+ for (let i = messageList.length - 1; i >= 0; i--) {
+ if (messageList[i].role === "assistant") {
+ return messageList[i].id === messageId;
+ }
+ }
+ return false;
+ }
</script>
<div class="flex flex-col gap-4 sm:gap-6 {className}">
- {#each messageList as message (message.id)}
- <div class="group flex {message.role === 'user' ? 'justify-end' : 'justify-start'}">
- <div class="{message.role === 'user' ? 'max-w-[85%] sm:max-w-[70%] flex flex-col items-end' : 'w-full max-w-[98%] sm:max-w-[95%]'}">
- {#if message.role === 'assistant'}
- <!-- Assistant message header -->
- <div class="flex items-center gap-1.5 sm:gap-2 mb-1.5 sm:mb-2">
- <div class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-yellow rounded-full shadow-[0_0_10px_rgba(255,215,0,0.5)]"></div>
- <span class="text-sm sm:text-xs text-exo-yellow tracking-[0.15em] sm:tracking-[0.2em] uppercase font-medium">EXO</span>
- <span class="text-xs sm:text-sm text-exo-light-gray tracking-wider tabular-nums">{formatTimestamp(message.timestamp)}</span>
- {#if message.ttftMs || message.tps}
- <span class="text-xs text-exo-light-gray/80 font-mono ml-2">
- {#if message.ttftMs}<span class="text-exo-light-gray/50">TTFT</span> {message.ttftMs.toFixed(0)}ms{/if}{#if message.ttftMs && message.tps}<span class="text-exo-light-gray/30 mx-1">•</span>{/if}{#if message.tps}{message.tps.toFixed(1)} <span class="text-exo-light-gray/50">tok/s</span>{/if}
- </span>
- {/if}
- </div>
- {:else}
- <!-- User message header -->
- <div class="flex items-center justify-end gap-1.5 sm:gap-2 mb-1.5 sm:mb-2">
- <span class="text-xs sm:text-sm text-exo-light-gray tracking-wider tabular-nums">{formatTimestamp(message.timestamp)}</span>
- <span class="text-sm sm:text-xs text-exo-light-gray tracking-[0.1em] sm:tracking-[0.15em] uppercase">QUERY</span>
- <div class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-light-gray/50 rounded-full"></div>
- </div>
- {/if}
-
- {#if deleteConfirmId === message.id}
- <!-- Delete confirmation -->
- <div class="bg-red-500/10 border border-red-500/30 rounded-lg p-3">
- <p class="text-xs text-red-400 mb-3">Delete this message{message.role === 'user' ? ' and all responses after it' : ''}?</p>
- <div class="flex gap-2 justify-end">
- <button
- onclick={handleCancelDelete}
- class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
- >
- CANCEL
- </button>
- <button
- onclick={handleConfirmDelete}
- class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 transition-colors cursor-pointer"
- >
- DELETE
- </button>
- </div>
- </div>
- {:else if editingMessageId === message.id}
- <!-- Edit mode -->
- <div class="command-panel rounded-lg p-3">
- <textarea
- bind:this={editTextareaRef}
- bind:value={editContent}
- onkeydown={handleEditKeydown}
- oninput={handleEditInput}
- class="w-full bg-exo-black/60 border border-exo-yellow/30 rounded px-3 py-2 text-sm text-foreground font-mono focus:outline-none focus:border-exo-yellow/50 resize-none"
- style="min-height: 60px; max-height: 200px;"
- ></textarea>
- <div class="flex gap-2 justify-end mt-2">
- <button
- onclick={handleCancelEdit}
- class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
- >
- CANCEL
- </button>
- <button
- onclick={handleSaveEdit}
- disabled={!editContent.trim()}
- class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-transparent text-exo-yellow border border-exo-yellow/30 rounded hover:border-exo-yellow/50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5 cursor-pointer"
- >
- <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
- </svg>
- SEND
- </button>
- </div>
- </div>
- {:else}
- <div class="{message.role === 'user'
- ? 'command-panel rounded-lg rounded-tr-sm inline-block'
- : 'command-panel rounded-lg rounded-tl-sm border-l-2 border-l-exo-yellow/50 block w-full'}">
-
- {#if message.role === 'user'}
- <!-- User message styling -->
- <div class="px-4 py-3">
- <!-- Attachments -->
- {#if message.attachments && message.attachments.length > 0}
- <div class="flex flex-wrap gap-2 mb-3">
- {#each message.attachments as attachment}
- <div class="flex items-center gap-2 bg-exo-dark-gray/60 border border-exo-yellow/20 rounded px-2 py-1 text-xs font-mono">
- {#if attachment.type === 'image' && attachment.preview}
- <img
- src={attachment.preview}
- alt={attachment.name}
- class="w-12 h-12 object-cover rounded border border-exo-yellow/20"
- />
- {:else}
- <span>{getAttachmentIcon(attachment)}</span>
- {/if}
- <span class="text-exo-yellow" title={attachment.name}>{truncateName(attachment.name)}</span>
- </div>
- {/each}
- </div>
- {/if}
-
- {#if message.content}
- <div class="text-xs text-foreground font-mono tracking-wide whitespace-pre-wrap break-words leading-relaxed">
- {message.content}
- </div>
- {/if}
- </div>
- {:else}
- <!-- Assistant message styling -->
- <div class="p-3 sm:p-4">
- {#if message.thinking && message.thinking.trim().length > 0}
- <div class="mb-3 rounded border border-exo-yellow/20 bg-exo-black/40">
- <button
- type="button"
- class="w-full flex items-center justify-between px-3 py-2 text-xs font-mono uppercase tracking-[0.2em] text-exo-light-gray/80 hover:text-exo-yellow transition-colors cursor-pointer"
- onclick={() => toggleThinkingVisibility(message.id)}
- aria-expanded={isThinkingExpanded(message.id)}
- aria-controls={`thinking-panel-${message.id}`}
- >
- <span class="flex items-center gap-2 tracking-[0.25em]">
- <svg
- class={`w-3.5 h-3.5 text-current transition-transform duration-200 ${isThinkingExpanded(message.id) ? 'rotate-90' : ''}`}
- fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
- aria-hidden="true"
- >
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
- </svg>
- <span>Thinking...</span>
- </span>
- <span class="text-[10px] tracking-[0.2em] text-exo-light-gray/60 ml-4">
- {isThinkingExpanded(message.id) ? 'HIDE' : 'SHOW'}
- </span>
- </button>
- {#if isThinkingExpanded(message.id)}
- <div
- id={`thinking-panel-${message.id}`}
- class="px-3 pb-3 text-xs text-exo-light-gray/90 font-mono whitespace-pre-wrap break-words leading-relaxed"
- >
- {message.thinking.trim()}
- </div>
- {/if}
- </div>
- {/if}
-
- <!-- Generated Images -->
- {#if message.attachments?.some(a => a.type === 'generated-image')}
- <div class="mb-3">
- {#each message.attachments.filter(a => a.type === 'generated-image') as attachment}
- <div class="relative group/img inline-block">
- <img
- src={attachment.preview}
- alt=""
- class="max-w-full max-h-[512px] rounded-lg border border-exo-yellow/20 shadow-lg shadow-black/20"
- />
- <!-- Button overlay -->
- <div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover/img:opacity-100 transition-opacity">
- <!-- Edit button -->
- <button
- type="button"
- class="p-2 rounded-lg bg-exo-dark-gray/80 border border-exo-yellow/30 text-exo-yellow hover:bg-exo-dark-gray hover:border-exo-yellow/50 cursor-pointer"
- onclick={() => {
- if (attachment.preview) {
- setEditingImage(attachment.preview, message);
- }
- }}
- title="Edit image"
- >
- <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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
- </svg>
- </button>
- <!-- Download button -->
- <button
- type="button"
- class="p-2 rounded-lg bg-exo-dark-gray/80 border border-exo-yellow/30 text-exo-yellow hover:bg-exo-dark-gray hover:border-exo-yellow/50 cursor-pointer"
- onclick={() => {
- if (attachment.preview) {
- const link = document.createElement('a');
- link.href = attachment.preview;
- const ext = attachment.name?.split('.').pop() || 'png';
- link.download = `generated-image-${Date.now()}.${ext}`;
- link.click();
- }
- }}
- title="Download image"
- >
- <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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
- </svg>
- </button>
- </div>
- </div>
- {/each}
- </div>
- {/if}
-
- <div class="text-xs text-foreground">
- {#if message.content === 'Generating image...' || message.content === 'Editing image...' || message.content?.startsWith('Generating...') || message.content?.startsWith('Editing...')}
- <div class="flex items-center gap-3 text-exo-yellow">
- <div class="relative">
- <div class="w-8 h-8 border-2 border-exo-yellow/30 border-t-exo-yellow rounded-full animate-spin"></div>
- <svg class="absolute inset-0 w-8 h-8 p-1.5 text-exo-yellow/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
- <circle cx="8.5" cy="8.5" r="1.5"/>
- <polyline points="21 15 16 10 5 21"/>
- </svg>
- </div>
- <span class="font-mono tracking-wider uppercase text-sm">{message.content}</span>
- </div>
- {:else if message.content || (loading && !message.attachments?.some(a => a.type === 'generated-image'))}
- <MarkdownContent content={message.content || (loading ? response : '')} />
- {#if loading && !message.content}
- <span class="inline-block w-2 h-4 bg-exo-yellow/70 ml-1 cursor-blink"></span>
- {/if}
- {/if}
- </div>
- </div>
- {/if}
- </div>
-
- <!-- Action buttons -->
- <div class="flex items-center gap-1 mt-1.5 opacity-0 group-hover:opacity-100 transition-opacity {message.role === 'user' ? 'justify-end' : 'justify-start'}">
- <!-- Copy button -->
- <button
- onclick={() => handleCopy(message.content, message.id)}
- class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
- title="Copy message"
- >
- {#if copiedMessageId === message.id}
- <svg class="w-3.5 h-3.5 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
- </svg>
- {:else}
- <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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" />
- </svg>
- {/if}
- </button>
-
- <!-- Edit button (user messages only) -->
- {#if message.role === 'user'}
- <button
- onclick={() => handleStartEdit(message.id, message.content)}
- class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
- title="Edit message"
- >
- <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
- </svg>
- </button>
- {/if}
-
- <!-- Regenerate button (last assistant message only) -->
- {#if message.role === 'assistant' && isLastAssistantMessage(message.id) && !loading}
- <button
- onclick={handleRegenerate}
- class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
- title="Regenerate response"
- >
- <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
- </svg>
- </button>
- {/if}
-
- <!-- Delete button -->
- <button
- onclick={() => handleDeleteClick(message.id)}
- class="p-1.5 text-exo-light-gray hover:text-red-400 transition-colors rounded hover:bg-red-500/10 cursor-pointer"
- title="Delete message"
- >
- <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
- </svg>
- </button>
- </div>
- {/if}
- </div>
- </div>
- {/each}
-
- {#if messageList.length === 0}
- <div class="flex-1 flex flex-col items-center justify-center text-center pt-[20vh]">
- <div class="w-12 h-12 sm:w-16 sm:h-16 border border-exo-yellow/20 rounded-full flex items-center justify-center mb-3 sm:mb-4">
- <div class="w-6 h-6 sm:w-8 sm:h-8 border border-exo-yellow/40 rounded-full flex items-center justify-center">
- <div class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-yellow/60 rounded-full"></div>
- </div>
- </div>
- <p class="text-xs sm:text-sm text-exo-light-gray tracking-[0.15em] sm:tracking-[0.2em] uppercase">AWAITING INPUT</p>
- <p class="text-sm sm:text-xs text-exo-light-gray tracking-wider mt-1">ENTER A QUERY TO BEGIN</p>
- </div>
- {/if}
-
- <!-- Invisible element for container reference -->
- <div bind:this={containerRef}></div>
-
- <!-- Scroll to bottom button -->
- {#if showScrollButton}
- <button
- type="button"
- onclick={scrollToBottom}
- class="sticky bottom-4 left-1/2 -translate-x-1/2 w-10 h-10 rounded-full bg-exo-dark-gray/90 border border-exo-medium-gray/50 flex items-center justify-center text-exo-light-gray hover:text-exo-yellow hover:border-exo-yellow/50 transition-all shadow-lg cursor-pointer z-10"
- title="Scroll to bottom"
- >
- <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
- </svg>
- </button>
- {/if}
+ {#each messageList as message (message.id)}
+ <div
+ class="group flex {message.role === 'user'
+ ? 'justify-end'
+ : 'justify-start'}"
+ >
+ <div
+ class={message.role === "user"
+ ? "max-w-[85%] sm:max-w-[70%] flex flex-col items-end"
+ : "w-full max-w-[98%] sm:max-w-[95%]"}
+ >
+ {#if message.role === "assistant"}
+ <!-- Assistant message header -->
+ <div class="flex items-center gap-1.5 sm:gap-2 mb-1.5 sm:mb-2">
+ <div
+ class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-yellow rounded-full shadow-[0_0_10px_rgba(255,215,0,0.5)]"
+ ></div>
+ <span
+ class="text-sm sm:text-xs text-exo-yellow tracking-[0.15em] sm:tracking-[0.2em] uppercase font-medium"
+ >EXO</span
+ >
+ <span
+ class="text-xs sm:text-sm text-exo-light-gray tracking-wider tabular-nums"
+ >{formatTimestamp(message.timestamp)}</span
+ >
+ {#if message.ttftMs || message.tps}
+ <span class="text-xs text-exo-light-gray/80 font-mono ml-2">
+ {#if message.ttftMs}<span class="text-exo-light-gray/50"
+ >TTFT</span
+ >
+ {message.ttftMs.toFixed(
+ 0,
+ )}ms{/if}{#if message.ttftMs && message.tps}<span
+ class="text-exo-light-gray/30 mx-1">•</span
+ >{/if}{#if message.tps}{message.tps.toFixed(1)}
+ <span class="text-exo-light-gray/50">tok/s</span>{/if}
+ </span>
+ {/if}
+ </div>
+ {:else}
+ <!-- User message header -->
+ <div
+ class="flex items-center justify-end gap-1.5 sm:gap-2 mb-1.5 sm:mb-2"
+ >
+ <span
+ class="text-xs sm:text-sm text-exo-light-gray tracking-wider tabular-nums"
+ >{formatTimestamp(message.timestamp)}</span
+ >
+ <span
+ class="text-sm sm:text-xs text-exo-light-gray tracking-[0.1em] sm:tracking-[0.15em] uppercase"
+ >QUERY</span
+ >
+ <div
+ class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-light-gray/50 rounded-full"
+ ></div>
+ </div>
+ {/if}
+
+ {#if deleteConfirmId === message.id}
+ <!-- Delete confirmation -->
+ <div class="bg-red-500/10 border border-red-500/30 rounded-lg p-3">
+ <p class="text-xs text-red-400 mb-3">
+ Delete this message{message.role === "user"
+ ? " and all responses after it"
+ : ""}?
+ </p>
+ <div class="flex gap-2 justify-end">
+ <button
+ onclick={handleCancelDelete}
+ class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
+ >
+ CANCEL
+ </button>
+ <button
+ onclick={handleConfirmDelete}
+ class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 transition-colors cursor-pointer"
+ >
+ DELETE
+ </button>
+ </div>
+ </div>
+ {:else if editingMessageId === message.id}
+ <!-- Edit mode -->
+ <div class="command-panel rounded-lg p-3">
+ <textarea
+ bind:this={editTextareaRef}
+ bind:value={editContent}
+ onkeydown={handleEditKeydown}
+ oninput={handleEditInput}
+ class="w-full bg-exo-black/60 border border-exo-yellow/30 rounded px-3 py-2 text-sm text-foreground font-mono focus:outline-none focus:border-exo-yellow/50 resize-none"
+ style="min-height: 60px; max-height: 200px;"
+ ></textarea>
+ <div class="flex gap-2 justify-end mt-2">
+ <button
+ onclick={handleCancelEdit}
+ class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
+ >
+ CANCEL
+ </button>
+ <button
+ onclick={handleSaveEdit}
+ disabled={!editContent.trim()}
+ class="px-3 py-1.5 text-sm font-mono tracking-wider uppercase bg-transparent text-exo-yellow border border-exo-yellow/30 rounded hover:border-exo-yellow/50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5 cursor-pointer"
+ >
+ <svg
+ class="w-3 h-3"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
+ />
+ </svg>
+ SEND
+ </button>
+ </div>
+ </div>
+ {:else}
+ <div
+ class={message.role === "user"
+ ? "command-panel rounded-lg rounded-tr-sm inline-block"
+ : "command-panel rounded-lg rounded-tl-sm border-l-2 border-l-exo-yellow/50 block w-full"}
+ >
+ {#if message.role === "user"}
+ <!-- User message styling -->
+ <div class="px-4 py-3">
+ <!-- Attachments -->
+ {#if message.attachments && message.attachments.length > 0}
+ <div class="flex flex-wrap gap-2 mb-3">
+ {#each message.attachments as attachment}
+ <div
+ class="flex items-center gap-2 bg-exo-dark-gray/60 border border-exo-yellow/20 rounded px-2 py-1 text-xs font-mono"
+ >
+ {#if attachment.type === "image" && attachment.preview}
+ <img
+ src={attachment.preview}
+ alt={attachment.name}
+ class="w-12 h-12 object-cover rounded border border-exo-yellow/20"
+ />
+ {:else}
+ <span>{getAttachmentIcon(attachment)}</span>
+ {/if}
+ <span class="text-exo-yellow" title={attachment.name}
+ >{truncateName(attachment.name)}</span
+ >
+ </div>
+ {/each}
+ </div>
+ {/if}
+
+ {#if message.content}
+ <div
+ class="text-xs text-foreground font-mono tracking-wide whitespace-pre-wrap break-words leading-relaxed"
+ >
+ {message.content}
+ </div>
+ {/if}
+ </div>
+ {:else}
+ <!-- Assistant message styling -->
+ <div class="p-3 sm:p-4">
+ {#if message.thinking && message.thinking.trim().length > 0}
+ <div
+ class="mb-3 rounded border border-exo-yellow/20 bg-exo-black/40"
+ >
+ <button
+ type="button"
+ class="w-full flex items-center justify-between px-3 py-2 text-xs font-mono uppercase tracking-[0.2em] text-exo-light-gray/80 hover:text-exo-yellow transition-colors cursor-pointer"
+ onclick={() => toggleThinkingVisibility(message.id)}
+ aria-expanded={isThinkingExpanded(message.id)}
+ aria-controls={`thinking-panel-${message.id}`}
+ >
+ <span class="flex items-center gap-2 tracking-[0.25em]">
+ <svg
+ class={`w-3.5 h-3.5 text-current transition-transform duration-200 ${isThinkingExpanded(message.id) ? "rotate-90" : ""}`}
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M9 5l7 7-7 7"
+ />
+ </svg>
+ <span>Thinking...</span>
+ </span>
+ <span
+ class="text-[10px] tracking-[0.2em] text-exo-light-gray/60 ml-4"
+ >
+ {isThinkingExpanded(message.id) ? "HIDE" : "SHOW"}
+ </span>
+ </button>
+ {#if isThinkingExpanded(message.id)}
+ <div
+ id={`thinking-panel-${message.id}`}
+ class="px-3 pb-3 text-xs text-exo-light-gray/90 font-mono whitespace-pre-wrap break-words leading-relaxed"
+ >
+ {message.thinking.trim()}
+ </div>
+ {/if}
+ </div>
+ {/if}
+
+ <!-- Generated Images -->
+ {#if message.attachments?.some((a) => a.type === "generated-image")}
+ <div class="mb-3">
+ {#each message.attachments.filter((a) => a.type === "generated-image") as attachment}
+ <div class="relative group/img inline-block">
+ <img
+ src={attachment.preview}
+ alt=""
+ class="max-w-full max-h-[512px] rounded-lg border border-exo-yellow/20 shadow-lg shadow-black/20"
+ />
+ <!-- Button overlay -->
+ <div
+ class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover/img:opacity-100 transition-opacity"
+ >
+ <!-- Edit button -->
+ <button
+ type="button"
+ class="p-2 rounded-lg bg-exo-dark-gray/80 border border-exo-yellow/30 text-exo-yellow hover:bg-exo-dark-gray hover:border-exo-yellow/50 cursor-pointer"
+ onclick={() => {
+ if (attachment.preview) {
+ setEditingImage(attachment.preview, message);
+ }
+ }}
+ title="Edit image"
+ >
+ <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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
+ />
+ </svg>
+ </button>
+ <!-- Download button -->
+ <button
+ type="button"
+ class="p-2 rounded-lg bg-exo-dark-gray/80 border border-exo-yellow/30 text-exo-yellow hover:bg-exo-dark-gray hover:border-exo-yellow/50 cursor-pointer"
+ onclick={() => {
+ if (attachment.preview) {
+ const link = document.createElement("a");
+ link.href = attachment.preview;
+ const ext =
+ attachment.name?.split(".").pop() || "png";
+ link.download = `generated-image-${Date.now()}.${ext}`;
+ link.click();
+ }
+ }}
+ title="Download image"
+ >
+ <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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
+ />
+ </svg>
+ </button>
+ </div>
+ </div>
+ {/each}
+ </div>
+ {/if}
+
+ <div class="text-xs text-foreground">
+ {#if message.content === "Generating image..." || message.content === "Editing image..." || message.content?.startsWith("Generating...") || message.content?.startsWith("Editing...")}
+ <div class="flex items-center gap-3 text-exo-yellow">
+ <div class="relative">
+ <div
+ class="w-8 h-8 border-2 border-exo-yellow/30 border-t-exo-yellow rounded-full animate-spin"
+ ></div>
+ <svg
+ class="absolute inset-0 w-8 h-8 p-1.5 text-exo-yellow/60"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+ >
+ <rect
+ x="3"
+ y="3"
+ width="18"
+ height="18"
+ rx="2"
+ ry="2"
+ />
+ <circle cx="8.5" cy="8.5" r="1.5" />
+ <polyline points="21 15 16 10 5 21" />
+ </svg>
+ </div>
+ <span class="font-mono tracking-wider uppercase text-sm"
+ >{message.content}</span
+ >
+ </div>
+ {:else if message.content || (loading && !message.attachments?.some((a) => a.type === "generated-image"))}
+ <MarkdownContent
+ content={message.content || (loading ? response : "")}
+ />
+ {#if loading && !message.content}
+ <span
+ class="inline-block w-2 h-4 bg-exo-yellow/70 ml-1 cursor-blink"
+ ></span>
+ {/if}
+ {/if}
+ </div>
+ </div>
+ {/if}
+ </div>
+
+ <!-- Action buttons -->
+ <div
+ class="flex items-center gap-1 mt-1.5 opacity-0 group-hover:opacity-100 transition-opacity {message.role ===
+ 'user'
+ ? 'justify-end'
+ : 'justify-start'}"
+ >
+ <!-- Copy button -->
+ <button
+ onclick={() => handleCopy(message.content, message.id)}
+ class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
+ title="Copy message"
+ >
+ {#if copiedMessageId === message.id}
+ <svg
+ class="w-3.5 h-3.5 text-green-400"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M5 13l4 4L19 7"
+ />
+ </svg>
+ {:else}
+ <svg
+ class="w-3.5 h-3.5"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ 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"
+ />
+ </svg>
+ {/if}
+ </button>
+
+ <!-- Edit button (user messages only) -->
+ {#if message.role === "user"}
+ <button
+ onclick={() => handleStartEdit(message.id, message.content)}
+ class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
+ title="Edit message"
+ >
+ <svg
+ class="w-3.5 h-3.5"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
+ />
+ </svg>
+ </button>
+ {/if}
+
+ <!-- Regenerate button (last assistant message only) -->
+ {#if message.role === "assistant" && isLastAssistantMessage(message.id) && !loading}
+ <button
+ onclick={handleRegenerate}
+ class="p-1.5 text-exo-light-gray hover:text-exo-yellow transition-colors rounded cursor-pointer"
+ title="Regenerate response"
+ >
+ <svg
+ class="w-3.5 h-3.5"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
+ />
+ </svg>
+ </button>
+ {/if}
+
+ <!-- Delete button -->
+ <button
+ onclick={() => handleDeleteClick(message.id)}
+ class="p-1.5 text-exo-light-gray hover:text-red-400 transition-colors rounded hover:bg-red-500/10 cursor-pointer"
+ title="Delete message"
+ >
+ <svg
+ class="w-3.5 h-3.5"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
+ />
+ </svg>
+ </button>
+ </div>
+ {/if}
+ </div>
+ </div>
+ {/each}
+
+ {#if messageList.length === 0}
+ <div
+ class="flex-1 flex flex-col items-center justify-center text-center pt-[20vh]"
+ >
+ <div
+ class="w-12 h-12 sm:w-16 sm:h-16 border border-exo-yellow/20 rounded-full flex items-center justify-center mb-3 sm:mb-4"
+ >
+ <div
+ class="w-6 h-6 sm:w-8 sm:h-8 border border-exo-yellow/40 rounded-full flex items-center justify-center"
+ >
+ <div
+ class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-exo-yellow/60 rounded-full"
+ ></div>
+ </div>
+ </div>
+ <p
+ class="text-xs sm:text-sm text-exo-light-gray tracking-[0.15em] sm:tracking-[0.2em] uppercase"
+ >
+ AWAITING INPUT
+ </p>
+ <p class="text-sm sm:text-xs text-exo-light-gray tracking-wider mt-1">
+ ENTER A QUERY TO BEGIN
+ </p>
+ </div>
+ {/if}
+
+ <!-- Invisible element for container reference -->
+ <div bind:this={containerRef}></div>
+
+ <!-- Scroll to bottom button -->
+ {#if showScrollButton}
+ <button
+ type="button"
+ onclick={scrollToBottom}
+ class="sticky bottom-4 left-1/2 -translate-x-1/2 w-10 h-10 rounded-full bg-exo-dark-gray/90 border border-exo-medium-gray/50 flex items-center justify-center text-exo-light-gray hover:text-exo-yellow hover:border-exo-yellow/50 transition-all shadow-lg cursor-pointer z-10"
+ title="Scroll to bottom"
+ >
+ <svg
+ class="w-5 h-5"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M19 14l-7 7m0 0l-7-7m7 7V3"
+ />
+ </svg>
+ </button>
+ {/if}
</div>
diff --git a/dashboard/src/lib/components/ChatSidebar.svelte b/dashboard/src/lib/components/ChatSidebar.svelte
index d369819d..b721b033 100644
--- a/dashboard/src/lib/components/ChatSidebar.svelte
+++ b/dashboard/src/lib/components/ChatSidebar.svelte
@@ -1,446 +1,594 @@
<script lang="ts">
-import {
- conversations,
- activeConversationId,
- createConversation,
- loadConversation,
- deleteConversation,
- deleteAllConversations,
- renameConversation,
- clearChat,
- instances,
- debugMode,
- toggleDebugMode,
- topologyOnlyMode,
- toggleTopologyOnlyMode
- } from '$lib/stores/app.svelte';
-
- interface Props {
- class?: string;
- }
-
- let { class: className = '' }: Props = $props();
-
- const conversationList = $derived(conversations());
- const activeId = $derived(activeConversationId());
-const instanceData = $derived(instances());
-const debugEnabled = $derived(debugMode());
-const topologyOnlyEnabled = $derived(topologyOnlyMode());
-
- let searchQuery = $state('');
- let editingId = $state<string | null>(null);
- let editingName = $state('');
- let deleteConfirmId = $state<string | null>(null);
- let showDeleteAllConfirm = $state(false);
-
- const filteredConversations = $derived(
- searchQuery.trim()
- ? conversationList.filter(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()))
- : conversationList
- );
-
- function handleNewChat() {
- createConversation();
- }
-
- function handleSelectConversation(id: string) {
- loadConversation(id);
- }
-
- function handleStartEdit(id: string, name: string, event: MouseEvent) {
- event.stopPropagation();
- editingId = id;
- editingName = name;
- }
-
- function handleSaveEdit() {
- if (editingId && editingName.trim()) {
- renameConversation(editingId, editingName.trim());
- }
- editingId = null;
- editingName = '';
- }
-
- function handleCancelEdit() {
- editingId = null;
- editingName = '';
- }
-
- function handleEditKeydown(event: KeyboardEvent) {
- if (event.key === 'Enter') {
- handleSaveEdit();
- } else if (event.key === 'Escape') {
- handleCancelEdit();
- }
- }
-
- function handleDeleteClick(id: string, event: MouseEvent) {
- event.stopPropagation();
- deleteConfirmId = id;
- }
-
- function handleConfirmDelete() {
- if (deleteConfirmId) {
- deleteConversation(deleteConfirmId);
- deleteConfirmId = null;
- }
- }
-
- function handleCancelDelete() {
- deleteConfirmId = null;
- }
-
- function handleDeleteAllClick() {
- showDeleteAllConfirm = true;
- }
-
- function handleConfirmDeleteAll() {
- deleteAllConversations();
- showDeleteAllConfirm = false;
- }
-
- function handleCancelDeleteAll() {
- showDeleteAllConfirm = false;
- }
-
- function formatDate(timestamp: number): string {
- const date = new Date(timestamp);
- const now = new Date();
- const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
-
- if (diffDays === 0) {
- return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
- } else if (diffDays === 1) {
- return 'Yesterday';
- } else if (diffDays < 7) {
- return date.toLocaleDateString('en-US', { weekday: 'short' });
- } else {
- return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
- }
- }
-
- function getLastAssistantStats(conversation: typeof conversationList[0]): { ttftMs?: number; tps?: number } | null {
- // Find the last assistant message with stats
- for (let i = conversation.messages.length - 1; i >= 0; i--) {
- const msg = conversation.messages[i];
- if (msg.role === 'assistant' && (msg.ttftMs || msg.tps)) {
- return { ttftMs: msg.ttftMs, tps: msg.tps };
- }
- }
- return null;
- }
-
- function formatModelName(modelId: string | null | undefined): string {
- if (!modelId) return 'Unknown Model';
- const parts = modelId.split('/');
- const tail = parts[parts.length - 1] || modelId;
- return tail || modelId;
- }
-
- function formatStrategy(sharding: string | null | undefined, instanceType: string | null | undefined): string {
- const shardLabel = sharding ?? 'Unknown';
- const typeLabel = instanceType ?? null;
- return typeLabel ? `${shardLabel} (${typeLabel})` : shardLabel;
- }
-
- function getTaggedValue(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];
- }
-
- function extractInstanceModelId(instanceWrapped: unknown): string | null {
- const [, instance] = getTaggedValue(instanceWrapped);
- if (!instance || typeof instance !== 'object') return null;
- const inst = instance as { shardAssignments?: { modelId?: string } };
- return inst.shardAssignments?.modelId ?? null;
- }
-
- function describeInstance(instanceWrapped: unknown): { sharding: string | null; instanceType: string | null } {
- const [instanceTag, instance] = getTaggedValue(instanceWrapped);
- if (!instance || typeof instance !== 'object') {
- return { sharding: null, instanceType: null };
- }
-
- let instanceType: string | null = null;
- if (instanceTag === 'MlxRingInstance') instanceType = 'MLX Ring';
- else if (instanceTag === 'MlxIbvInstance' || instanceTag === 'MlxJacclInstance') instanceType = 'MLX RDMA';
-
- let sharding: string | null = null;
- const inst = instance as { shardAssignments?: { runnerToShard?: Record<string, unknown> } };
- const runnerToShard = inst.shardAssignments?.runnerToShard || {};
- const firstShardWrapped = Object.values(runnerToShard)[0];
- if (firstShardWrapped) {
- const [shardTag] = getTaggedValue(firstShardWrapped);
- if (shardTag === 'PipelineShardMetadata') sharding = 'Pipeline';
- else if (shardTag === 'TensorShardMetadata') sharding = 'Tensor';
- else if (shardTag === 'PrefillDecodeShardMetadata') sharding = 'Prefill/Decode';
- }
-
- return { sharding, instanceType };
- }
-
- function resolveConversationInfo(conversation: typeof conversationList[0]): { modelLabel: string; strategyLabel: string } {
- // Attempt to match conversation model to an instance
- let matchedInstance: unknown = null;
- let modelId = conversation.modelId ?? null;
-
- if (modelId) {
- for (const [, instanceWrapper] of Object.entries(instanceData)) {
- const candidate = extractInstanceModelId(instanceWrapper);
- if (candidate === modelId) {
- matchedInstance = instanceWrapper;
- break;
- }
- }
- }
-
- // Fallback: use the first available instance if no explicit match
- if (!matchedInstance) {
- const firstInstance = Object.values(instanceData)[0];
- if (firstInstance) {
- matchedInstance = firstInstance;
- modelId = modelId ?? extractInstanceModelId(firstInstance);
- }
- }
-
- const instanceDetails = matchedInstance ? describeInstance(matchedInstance) : { sharding: null, instanceType: null };
- const displayModel = modelId ?? conversation.modelId ?? null;
- const sharding = conversation.sharding ?? instanceDetails.sharding ?? 'Unknown';
- const instanceType = conversation.instanceType ?? instanceDetails.instanceType;
-
- return {
- modelLabel: formatModelName(displayModel),
- strategyLabel: formatStrategy(sharding, instanceType)
- };
- }
+ import {
+ conversations,
+ activeConversationId,
+ createConversation,
+ loadConversation,
+ deleteConversation,
+ deleteAllConversations,
+ renameConversation,
+ clearChat,
+ instances,
+ debugMode,
+ toggleDebugMode,
+ topologyOnlyMode,
+ toggleTopologyOnlyMode,
+ } from "$lib/stores/app.svelte";
+
+ interface Props {
+ class?: string;
+ }
+
+ let { class: className = "" }: Props = $props();
+
+ const conversationList = $derived(conversations());
+ const activeId = $derived(activeConversationId());
+ const instanceData = $derived(instances());
+ const debugEnabled = $derived(debugMode());
+ const topologyOnlyEnabled = $derived(topologyOnlyMode());
+
+ let searchQuery = $state("");
+ let editingId = $state<string | null>(null);
+ let editingName = $state("");
+ let deleteConfirmId = $state<string | null>(null);
+ let showDeleteAllConfirm = $state(false);
+
+ const filteredConversations = $derived(
+ searchQuery.trim()
+ ? conversationList.filter((c) =>
+ c.name.toLowerCase().includes(searchQuery.toLowerCase()),
+ )
+ : conversationList,
+ );
+
+ function handleNewChat() {
+ createConversation();
+ }
+
+ function handleSelectConversation(id: string) {
+ loadConversation(id);
+ }
+
+ function handleStartEdit(id: string, name: string, event: MouseEvent) {
+ event.stopPropagation();
+ editingId = id;
+ editingName = name;
+ }
+
+ function handleSaveEdit() {
+ if (editingId && editingName.trim()) {
+ renameConversation(editingId, editingName.trim());
+ }
+ editingId = null;
+ editingName = "";
+ }
+
+ function handleCancelEdit() {
+ editingId = null;
+ editingName = "";
+ }
+
+ function handleEditKeydown(event: KeyboardEvent) {
+ if (event.key === "Enter") {
+ handleSaveEdit();
+ } else if (event.key === "Escape") {
+ handleCancelEdit();
+ }
+ }
+
+ function handleDeleteClick(id: string, event: MouseEvent) {
+ event.stopPropagation();
+ deleteConfirmId = id;
+ }
+
+ function handleConfirmDelete() {
+ if (deleteConfirmId) {
+ deleteConversation(deleteConfirmId);
+ deleteConfirmId = null;
+ }
+ }
+
+ function handleCancelDelete() {
+ deleteConfirmId = null;
+ }
+
+ function handleDeleteAllClick() {
+ showDeleteAllConfirm = true;
+ }
+
+ function handleConfirmDeleteAll() {
+ deleteAllConversations();
+ showDeleteAllConfirm = false;
+ }
+
+ function handleCancelDeleteAll() {
+ showDeleteAllConfirm = false;
+ }
+
+ function formatDate(timestamp: number): string {
+ const date = new Date(timestamp);
+ const now = new Date();
+ const diffDays = Math.floor(
+ (now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24),
+ );
+
+ if (diffDays === 0) {
+ return date.toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ } else if (diffDays === 1) {
+ return "Yesterday";
+ } else if (diffDays < 7) {
+ return date.toLocaleDateString("en-US", { weekday: "short" });
+ } else {
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ });
+ }
+ }
+
+ function getLastAssistantStats(
+ conversation: (typeof conversationList)[0],
+ ): { ttftMs?: number; tps?: number } | null {
+ // Find the last assistant message with stats
+ for (let i = conversation.messages.length - 1; i >= 0; i--) {
+ const msg = conversation.messages[i];
+ if (msg.role === "assistant" && (msg.ttftMs || msg.tps)) {
+ return { ttftMs: msg.ttftMs, tps: msg.tps };
+ }
+ }
+ return null;
+ }
+
+ function formatModelName(modelId: string | null | undefined): string {
+ if (!modelId) return "Unknown Model";
+ const parts = modelId.split("/");
+ const tail = parts[parts.length - 1] || modelId;
+ return tail || modelId;
+ }
+
+ function formatStrategy(
+ sharding: string | null | undefined,
+ instanceType: string | null | undefined,
+ ): string {
+ const shardLabel = sharding ?? "Unknown";
+ const typeLabel = instanceType ?? null;
+ return typeLabel ? `${shardLabel} (${typeLabel})` : shardLabel;
+ }
+
+ function getTaggedValue(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];
+ }
+
+ function extractInstanceModelId(instanceWrapped: unknown): string | null {
+ const [, instance] = getTaggedValue(instanceWrapped);
+ if (!instance || typeof instance !== "object") return null;
+ const inst = instance as { shardAssignments?: { modelId?: string } };
+ return inst.shardAssignments?.modelId ?? null;
+ }
+
+ function describeInstance(instanceWrapped: unknown): {
+ sharding: string | null;
+ instanceType: string | null;
+ } {
+ const [instanceTag, instance] = getTaggedValue(instanceWrapped);
+ if (!instance || typeof instance !== "object") {
+ return { sharding: null, instanceType: null };
+ }
+
+ let instanceType: string | null = null;
+ if (instanceTag === "MlxRingInstance") instanceType = "MLX Ring";
+ else if (
+ instanceTag === "MlxIbvInstance" ||
+ instanceTag === "MlxJacclInstance"
+ )
+ instanceType = "MLX RDMA";
+
+ let sharding: string | null = null;
+ const inst = instance as {
+ shardAssignments?: { runnerToShard?: Record<string, unknown> };
+ };
+ const runnerToShard = inst.shardAssignments?.runnerToShard || {};
+ const firstShardWrapped = Object.values(runnerToShard)[0];
+ if (firstShardWrapped) {
+ const [shardTag] = getTaggedValue(firstShardWrapped);
+ if (shardTag === "PipelineShardMetadata") sharding = "Pipeline";
+ else if (shardTag === "TensorShardMetadata") sharding = "Tensor";
+ else if (shardTag === "PrefillDecodeShardMetadata")
+ sharding = "Prefill/Decode";
+ }
+
+ return { sharding, instanceType };
+ }
+
+ function resolveConversationInfo(
+ conversation: (typeof conversationList)[0],
+ ): { modelLabel: string; strategyLabel: string } {
+ // Attempt to match conversation model to an instance
+ let matchedInstance: unknown = null;
+ let modelId = conversation.modelId ?? null;
+
+ if (modelId) {
+ for (const [, instanceWrapper] of Object.entries(instanceData)) {
+ const candidate = extractInstanceModelId(instanceWrapper);
+ if (candidate === modelId) {
+ matchedInstance = instanceWrapper;
+ break;
+ }
+ }
+ }
+
+ // Fallback: use the first available instance if no explicit match
+ if (!matchedInstance) {
+ const firstInstance = Object.values(instanceData)[0];
+ if (firstInstance) {
+ matchedInstance = firstInstance;
+ modelId = modelId ?? extractInstanceModelId(firstInstance);
+ }
+ }
+
+ const instanceDetails = matchedInstance
+ ? describeInstance(matchedInstance)
+ : { sharding: null, instanceType: null };
+ const displayModel = modelId ?? conversation.modelId ?? null;
+ const sharding =
+ conversation.sharding ?? instanceDetails.sharding ?? "Unknown";
+ const instanceType =
+ conversation.instanceType ?? instanceDetails.instanceType;
+
+ return {
+ modelLabel: formatModelName(displayModel),
+ strategyLabel: formatStrategy(sharding, instanceType),
+ };
+ }
</script>
-<aside class="flex flex-col h-full bg-exo-dark-gray border-r border-exo-yellow/10 {className}">
- <!-- Header -->
- <div class="p-4">
- <button
- onclick={handleNewChat}
- class="w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-transparent border border-exo-yellow/30 text-exo-yellow text-xs font-mono tracking-wider uppercase hover:border-exo-yellow/50 transition-all cursor-pointer"
- >
- <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
- </svg>
- NEW CHAT
- </button>
- </div>
-
- <!-- Search -->
- <div class="px-4 py-3">
- <div class="relative">
- <svg class="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
- </svg>
- <input
- type="text"
- bind:value={searchQuery}
- placeholder="Search conversations..."
- class="w-full bg-exo-black/40 border border-exo-medium-gray/30 rounded px-3 py-2 pl-9 text-xs text-white/90 placeholder:text-white/40 focus:outline-none focus:border-exo-yellow/30"
- />
- </div>
- </div>
-
- <!-- Conversation List -->
- <div class="flex-1 overflow-y-auto">
- {#if filteredConversations.length > 0}
- <div class="py-2">
- <div class="px-4 py-2">
- <span class="text-sm text-white/70 font-mono tracking-wider uppercase">
- {searchQuery ? 'SEARCH RESULTS' : 'CONVERSATIONS'}
- </span>
- </div>
-
- {#each filteredConversations as conversation (conversation.id)}
- {@const info = resolveConversationInfo(conversation)}
- <div class="px-2">
- {#if editingId === conversation.id}
- <!-- Edit mode -->
- <div class="p-2 bg-transparent border border-exo-yellow/20 rounded mb-1">
- <input
- type="text"
- bind:value={editingName}
- onkeydown={handleEditKeydown}
- class="w-full bg-exo-black/60 border border-exo-yellow/30 rounded px-2 py-1.5 text-xs text-exo-light-gray focus:outline-none focus:border-exo-yellow/50 mb-2"
- autofocus
- />
- <div class="flex gap-2">
- <button
- onclick={handleSaveEdit}
- class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-transparent text-exo-yellow border border-exo-yellow/30 rounded hover:border-exo-yellow/50 cursor-pointer"
- >
- SAVE
- </button>
- <button
- onclick={handleCancelEdit}
- class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 cursor-pointer"
- >
- CANCEL
- </button>
- </div>
- </div>
- {:else if deleteConfirmId === conversation.id}
- <!-- Delete confirmation -->
- <div class="p-2 bg-red-500/10 border border-red-500/30 rounded mb-1">
- <p class="text-xs text-red-400 mb-2">Delete "{conversation.name}"?</p>
- <div class="flex gap-2">
- <button
- onclick={handleConfirmDelete}
- class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 cursor-pointer"
- >
- DELETE
- </button>
- <button
- onclick={handleCancelDelete}
- class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 cursor-pointer"
- >
- CANCEL
- </button>
- </div>
- </div>
- {:else}
- <!-- Normal view -->
- {@const stats = getLastAssistantStats(conversation)}
- <div
- role="button"
- tabindex="0"
- onclick={() => handleSelectConversation(conversation.id)}
- onkeydown={(e) => e.key === 'Enter' && handleSelectConversation(conversation.id)}
- class="group w-full flex items-center justify-between p-2 rounded mb-1 transition-all text-left cursor-pointer
- {activeId === conversation.id
- ? 'bg-transparent border border-exo-yellow/30'
- : 'hover:border-exo-yellow/20 border border-transparent'}"
- >
- <div class="flex-1 min-w-0 pr-2">
- <div class="text-sm truncate {activeId === conversation.id ? 'text-exo-yellow' : 'text-white/90'}">
- {conversation.name}
- </div>
- <div class="text-sm text-white/50 mt-0.5">
- {formatDate(conversation.updatedAt)}
- </div>
- <div class="text-sm text-white/70 truncate">
- {info.modelLabel}
- </div>
- <div class="text-xs text-white/60 font-mono">
- Strategy: <span class="text-white/80">{info.strategyLabel}</span>
- </div>
- {#if stats}
- <div class="text-xs text-white/60 font-mono mt-1">
- {#if stats.ttftMs}<span class="text-white/40">TTFT</span> {stats.ttftMs.toFixed(0)}ms{/if}{#if stats.ttftMs && stats.tps}<span class="text-white/30 mx-1.5">•</span>{/if}{#if stats.tps}{stats.tps.toFixed(1)} <span class="text-white/40">tok/s</span>{/if}
- </div>
- {/if}
- </div>
-
- <div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
- <button
- type="button"
- onclick={(e) => handleStartEdit(conversation.id, conversation.name, e)}
- class="p-1 text-exo-light-gray hover:text-exo-yellow transition-colors cursor-pointer"
- title="Rename"
- >
- <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
- </svg>
- </button>
- <button
- type="button"
- onclick={(e) => handleDeleteClick(conversation.id, e)}
- class="p-1 text-exo-light-gray hover:text-red-400 transition-colors cursor-pointer"
- title="Delete"
- >
- <svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
- </svg>
- </button>
- </div>
- </div>
- {/if}
- </div>
- {/each}
- </div>
- {:else}
- <div class="flex flex-col items-center justify-center h-full p-4 text-center">
- <div class="w-12 h-12 border border-exo-yellow/20 rounded-full flex items-center justify-center mb-3">
- <svg class="w-6 h-6 text-exo-yellow/40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
- </svg>
- </div>
- <p class="text-xs text-white/70 font-mono tracking-wider uppercase mb-1">
- {searchQuery ? 'NO RESULTS' : 'NO CONVERSATIONS'}
- </p>
- <p class="text-sm text-white/50">
- {searchQuery ? 'Try a different search' : 'Start a new chat to begin'}
- </p>
- </div>
- {/if}
- </div>
-
- <!-- Footer -->
- <div class="p-3 border-t border-exo-yellow/10">
- {#if showDeleteAllConfirm}
- <div class="bg-red-500/10 border border-red-500/30 rounded p-2 mb-2">
- <p class="text-xs text-red-400 text-center mb-2">Delete all {conversationList.length} conversations?</p>
- <div class="flex gap-2">
- <button
- onclick={handleConfirmDeleteAll}
- class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 transition-colors cursor-pointer"
- >
- DELETE ALL
- </button>
- <button
- onclick={handleCancelDeleteAll}
- class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
- >
- CANCEL
- </button>
- </div>
- </div>
- {:else if conversationList.length > 0}
- <button
- onclick={handleDeleteAllClick}
- class="w-full flex items-center justify-center gap-2 py-1.5 text-sm font-mono tracking-wider uppercase text-white/70 hover:text-red-400 hover:bg-red-500/10 border border-transparent hover:border-red-500/20 rounded transition-all cursor-pointer"
- >
- <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
- </svg>
- DELETE ALL CHATS
- </button>
- {/if}
- <div class="flex items-center justify-center gap-3 {conversationList.length > 0 && !showDeleteAllConfirm ? 'mt-2' : ''}">
- <button
- type="button"
- onclick={toggleDebugMode}
- class="p-1.5 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
- title="Toggle debug mode"
- >
- <svg class="w-4 h-4 {debugEnabled ? 'text-exo-yellow' : 'text-exo-medium-gray'}" fill="currentColor" viewBox="0 0 24 24">
- <path d="M19 8h-1.81A6.002 6.002 0 0 0 12 2a6.002 6.002 0 0 0-5.19 3H5a1 1 0 0 0 0 2h1v2H5a1 1 0 0 0 0 2h1v2H5a1 1 0 0 0 0 2h1.81A6.002 6.002 0 0 0 12 22a6.002 6.002 0 0 0 5.19-3H19a1 1 0 0 0 0-2h-1v-2h1a1 1 0 0 0 0-2h-1v-2h1a1 1 0 1 0 0-2Zm-5 10.32V19a1 1 0 1 1-2 0v-.68a3.999 3.999 0 0 1-3-3.83V9.32a3.999 3.999 0 0 1 3-3.83V5a1 1 0 0 1 2 0v.49a3.999 3.999 0 0 1 3 3.83v5.17a3.999 3.999 0 0 1-3 3.83Z"/>
- </svg>
- </button>
- <div class="text-xs text-white/60 font-mono tracking-wider text-center">
- {conversationList.length} CONVERSATION{conversationList.length !== 1 ? 'S' : ''}
- </div>
- <button
- type="button"
- onclick={toggleTopologyOnlyMode}
- class="p-1.5 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
- title="Toggle topology only mode"
- >
- <svg class="w-4 h-4 {topologyOnlyEnabled ? 'text-exo-yellow' : 'text-exo-medium-gray'}" 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>
+<aside
+ class="flex flex-col h-full bg-exo-dark-gray border-r border-exo-yellow/10 {className}"
+>
+ <!-- Header -->
+ <div class="p-4">
+ <button
+ onclick={handleNewChat}
+ class="w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-transparent border border-exo-yellow/30 text-exo-yellow text-xs font-mono tracking-wider uppercase hover:border-exo-yellow/50 transition-all cursor-pointer"
+ >
+ <svg
+ class="w-4 h-4"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M12 4v16m8-8H4"
+ />
+ </svg>
+ NEW CHAT
+ </button>
+ </div>
+
+ <!-- Search -->
+ <div class="px-4 py-3">
+ <div class="relative">
+ <svg
+ class="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/50"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
+ />
+ </svg>
+ <input
+ type="text"
+ bind:value={searchQuery}
+ placeholder="Search conversations..."
+ class="w-full bg-exo-black/40 border border-exo-medium-gray/30 rounded px-3 py-2 pl-9 text-xs text-white/90 placeholder:text-white/40 focus:outline-none focus:border-exo-yellow/30"
+ />
+ </div>
+ </div>
+
+ <!-- Conversation List -->
+ <div class="flex-1 overflow-y-auto">
+ {#if filteredConversations.length > 0}
+ <div class="py-2">
+ <div class="px-4 py-2">
+ <span
+ class="text-sm text-white/70 font-mono tracking-wider uppercase"
+ >
+ {searchQuery ? "SEARCH RESULTS" : "CONVERSATIONS"}
+ </span>
+ </div>
+
+ {#each filteredConversations as conversation (conversation.id)}
+ {@const info = resolveConversationInfo(conversation)}
+ <div class="px-2">
+ {#if editingId === conversation.id}
+ <!-- Edit mode -->
+ <div
+ class="p-2 bg-transparent border border-exo-yellow/20 rounded mb-1"
+ >
+ <input
+ type="text"
+ bind:value={editingName}
+ onkeydown={handleEditKeydown}
+ class="w-full bg-exo-black/60 border border-exo-yellow/30 rounded px-2 py-1.5 text-xs text-exo-light-gray focus:outline-none focus:border-exo-yellow/50 mb-2"
+ autofocus
+ />
+ <div class="flex gap-2">
+ <button
+ onclick={handleSaveEdit}
+ class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-transparent text-exo-yellow border border-exo-yellow/30 rounded hover:border-exo-yellow/50 cursor-pointer"
+ >
+ SAVE
+ </button>
+ <button
+ onclick={handleCancelEdit}
+ class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 cursor-pointer"
+ >
+ CANCEL
+ </button>
+ </div>
+ </div>
+ {:else if deleteConfirmId === conversation.id}
+ <!-- Delete confirmation -->
+ <div
+ class="p-2 bg-red-500/10 border border-red-500/30 rounded mb-1"
+ >
+ <p class="text-xs text-red-400 mb-2">
+ Delete "{conversation.name}"?
+ </p>
+ <div class="flex gap-2">
+ <button
+ onclick={handleConfirmDelete}
+ class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 cursor-pointer"
+ >
+ DELETE
+ </button>
+ <button
+ onclick={handleCancelDelete}
+ class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 cursor-pointer"
+ >
+ CANCEL
+ </button>
+ </div>
+ </div>
+ {:else}
+ <!-- Normal view -->
+ {@const stats = getLastAssistantStats(conversation)}
+ <div
+ role="button"
+ tabindex="0"
+ onclick={() => handleSelectConversation(conversation.id)}
+ onkeydown={(e) =>
+ e.key === "Enter" &&
+ handleSelectConversation(conversation.id)}
+ class="group w-full flex items-center justify-between p-2 rounded mb-1 transition-all text-left cursor-pointer
+ {activeId === conversation.id
+ ? 'bg-transparent border border-exo-yellow/30'
+ : 'hover:border-exo-yellow/20 border border-transparent'}"
+ >
+ <div class="flex-1 min-w-0 pr-2">
+ <div
+ class="text-sm truncate {activeId === conversation.id
+ ? 'text-exo-yellow'
+ : 'text-white/90'}"
+ >
+ {conversation.name}
+ </div>
+ <div class="text-sm text-white/50 mt-0.5">
+ {formatDate(conversation.updatedAt)}
+ </div>
+ <div class="text-sm text-white/70 truncate">
+ {info.modelLabel}
+ </div>
+ <div class="text-xs text-white/60 font-mono">
+ Strategy: <span class="text-white/80"
+ >{info.strategyLabel}</span
+ >
+ </div>
+ {#if stats}
+ <div class="text-xs text-white/60 font-mono mt-1">
+ {#if stats.ttftMs}<span class="text-white/40">TTFT</span>
+ {stats.ttftMs.toFixed(
+ 0,
+ )}ms{/if}{#if stats.ttftMs && stats.tps}<span
+ class="text-white/30 mx-1.5">•</span
+ >{/if}{#if stats.tps}{stats.tps.toFixed(1)}
+ <span class="text-white/40">tok/s</span>{/if}
+ </div>
+ {/if}
+ </div>
+
+ <div
+ class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
+ >
+ <button
+ type="button"
+ onclick={(e) =>
+ handleStartEdit(conversation.id, conversation.name, e)}
+ class="p-1 text-exo-light-gray hover:text-exo-yellow transition-colors cursor-pointer"
+ title="Rename"
+ >
+ <svg
+ class="w-3 h-3"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
+ />
+ </svg>
+ </button>
+ <button
+ type="button"
+ onclick={(e) => handleDeleteClick(conversation.id, e)}
+ class="p-1 text-exo-light-gray hover:text-red-400 transition-colors cursor-pointer"
+ title="Delete"
+ >
+ <svg
+ class="w-3 h-3"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
+ />
+ </svg>
+ </button>
+ </div>
+ </div>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ {:else}
+ <div
+ class="flex flex-col items-center justify-center h-full p-4 text-center"
+ >
+ <div
+ class="w-12 h-12 border border-exo-yellow/20 rounded-full flex items-center justify-center mb-3"
+ >
+ <svg
+ class="w-6 h-6 text-exo-yellow/40"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="1.5"
+ d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
+ />
+ </svg>
+ </div>
+ <p
+ class="text-xs text-white/70 font-mono tracking-wider uppercase mb-1"
+ >
+ {searchQuery ? "NO RESULTS" : "NO CONVERSATIONS"}
+ </p>
+ <p class="text-sm text-white/50">
+ {searchQuery ? "Try a different search" : "Start a new chat to begin"}
+ </p>
+ </div>
+ {/if}
+ </div>
+
+ <!-- Footer -->
+ <div class="p-3 border-t border-exo-yellow/10">
+ {#if showDeleteAllConfirm}
+ <div class="bg-red-500/10 border border-red-500/30 rounded p-2 mb-2">
+ <p class="text-xs text-red-400 text-center mb-2">
+ Delete all {conversationList.length} conversations?
+ </p>
+ <div class="flex gap-2">
+ <button
+ onclick={handleConfirmDeleteAll}
+ class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 transition-colors cursor-pointer"
+ >
+ DELETE ALL
+ </button>
+ <button
+ onclick={handleCancelDeleteAll}
+ class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
+ >
+ CANCEL
+ </button>
+ </div>
+ </div>
+ {:else if conversationList.length > 0}
+ <button
+ onclick={handleDeleteAllClick}
+ class="w-full flex items-center justify-center gap-2 py-1.5 text-sm font-mono tracking-wider uppercase text-white/70 hover:text-red-400 hover:bg-red-500/10 border border-transparent hover:border-red-500/20 rounded transition-all cursor-pointer"
+ >
+ <svg
+ class="w-3.5 h-3.5"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
+ />
+ </svg>
+ DELETE ALL CHATS
+ </button>
+ {/if}
+ <div
+ class="flex items-center justify-center gap-3 {conversationList.length >
+ 0 && !showDeleteAllConfirm
+ ? 'mt-2'
+ : ''}"
+ >
+ <button
+ type="button"
+ onclick={toggleDebugMode}
+ class="p-1.5 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
+ title="Toggle debug mode"
+ >
+ <svg
+ class="w-4 h-4 {debugEnabled
+ ? 'text-exo-yellow'
+ : 'text-exo-medium-gray'}"
+ fill="currentColor"
+ viewBox="0 0 24 24"
+ >
+ <path
+ d="M19 8h-1.81A6.002 6.002 0 0 0 12 2a6.002 6.002 0 0 0-5.19 3H5a1 1 0 0 0 0 2h1v2H5a1 1 0 0 0 0 2h1v2H5a1 1 0 0 0 0 2h1.81A6.002 6.002 0 0 0 12 22a6.002 6.002 0 0 0 5.19-3H19a1 1 0 0 0 0-2h-1v-2h1a1 1 0 0 0 0-2h-1v-2h1a1 1 0 1 0 0-2Zm-5 10.32V19a1 1 0 1 1-2 0v-.68a3.999 3.999 0 0 1-3-3.83V9.32a3.999 3.999 0 0 1 3-3.83V5a1 1 0 0 1 2 0v.49a3.999 3.999 0 0 1 3 3.83v5.17a3.999 3.999 0 0 1-3 3.83Z"
+ />
+ </svg>
+ </button>
+ <div class="text-xs text-white/60 font-mono tracking-wider text-center">
+ {conversationList.length} CONVERSATION{conversationList.length !== 1
+ ? "S"
+ : ""}
+ </div>
+ <button
+ type="button"
+ onclick={toggleTopologyOnlyMode}
+ class="p-1.5 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
+ title="Toggle topology only mode"
+ >
+ <svg
+ class="w-4 h-4 {topologyOnlyEnabled
+ ? 'text-exo-yellow'
+ : 'text-exo-medium-gray'}"
+ 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>
</aside>
-
diff --git a/dashboard/src/lib/components/HeaderNav.svelte b/dashboard/src/lib/components/HeaderNav.svelte
index 2e1a3c9d..5bdcf1ba 100644
--- a/dashboard/src/lib/components/HeaderNav.svelte
+++ b/dashboard/src/lib/components/HeaderNav.svelte
@@ -1,85 +1,129 @@
<script lang="ts">
- import { browser } from '$app/environment';
+ import { browser } from "$app/environment";
- export let showHome = true;
- export let onHome: (() => void) | null = null;
- export let showSidebarToggle = false;
- export let sidebarVisible = true;
- export let onToggleSidebar: (() => void) | null = null;
+ export let showHome = true;
+ export let onHome: (() => void) | null = null;
+ export let showSidebarToggle = false;
+ export let sidebarVisible = true;
+ export let onToggleSidebar: (() => void) | null = null;
- function handleHome(): void {
- if (onHome) {
- onHome();
- return;
- }
- if (browser) {
- // Hash router: send to root
- window.location.hash = '/';
- }
- }
+ function handleHome(): void {
+ if (onHome) {
+ onHome();
+ return;
+ }
+ if (browser) {
+ // Hash router: send to root
+ window.location.hash = "/";
+ }
+ }
- function handleToggleSidebar(): void {
- if (onToggleSidebar) {
- onToggleSidebar();
- }
- }
+ function handleToggleSidebar(): void {
+ if (onToggleSidebar) {
+ onToggleSidebar();
+ }
+ }
</script>
-<header class="relative z-20 flex items-center justify-center px-6 pt-8 pb-4 bg-exo-dark-gray">
- <!-- Left: Sidebar Toggle -->
- {#if showSidebarToggle}
- <div class="absolute left-6 top-1/2 -translate-y-1/2">
- <button
- onclick={handleToggleSidebar}
- class="p-2 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
- title={sidebarVisible ? 'Hide sidebar' : 'Show sidebar'}
- >
- <svg class="w-5 h-5 {sidebarVisible ? 'text-exo-yellow' : 'text-exo-medium-gray'}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
- {#if sidebarVisible}
- <path stroke-linecap="round" stroke-linejoin="round" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
- {:else}
- <path stroke-linecap="round" stroke-linejoin="round" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
- {/if}
- </svg>
- </button>
- </div>
- {/if}
+<header
+ class="relative z-20 flex items-center justify-center px-6 pt-8 pb-4 bg-exo-dark-gray"
+>
+ <!-- Left: Sidebar Toggle -->
+ {#if showSidebarToggle}
+ <div class="absolute left-6 top-1/2 -translate-y-1/2">
+ <button
+ onclick={handleToggleSidebar}
+ class="p-2 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
+ title={sidebarVisible ? "Hide sidebar" : "Show sidebar"}
+ >
+ <svg
+ class="w-5 h-5 {sidebarVisible
+ ? 'text-exo-yellow'
+ : 'text-exo-medium-gray'}"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+ >
+ {#if sidebarVisible}
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
+ />
+ {:else}
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M13 5l7 7-7 7M5 5l7 7-7 7"
+ />
+ {/if}
+ </svg>
+ </button>
+ </div>
+ {/if}
- <!-- Center: Logo (clickable to go home) -->
- <button
- onclick={handleHome}
- class="bg-transparent border-none outline-none focus:outline-none transition-opacity duration-200 hover:opacity-90 {showHome ? 'cursor-pointer' : 'cursor-default'}"
- title={showHome ? 'Go to home' : ''}
- disabled={!showHome}
- >
- <img src="/exo-logo.png" alt="EXO" class="h-18 drop-shadow-[0_0_20px_rgba(255,215,0,0.5)]" />
- </button>
+ <!-- Center: Logo (clickable to go home) -->
+ <button
+ onclick={handleHome}
+ class="bg-transparent border-none outline-none focus:outline-none transition-opacity duration-200 hover:opacity-90 {showHome
+ ? 'cursor-pointer'
+ : 'cursor-default'}"
+ title={showHome ? "Go to home" : ""}
+ disabled={!showHome}
+ >
+ <img
+ src="/exo-logo.png"
+ alt="EXO"
+ class="h-18 drop-shadow-[0_0_20px_rgba(255,215,0,0.5)]"
+ />
+ </button>
- <!-- Right: Home + Downloads -->
- <div class="absolute right-6 top-1/2 -translate-y-1/2 flex items-center gap-4">
- {#if showHome}
- <button
- onclick={handleHome}
- class="text-sm text-exo-light-gray hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-2 cursor-pointer"
- title="Back to topology view"
- >
- <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
- </svg>
- Home
- </button>
- {/if}
- <a
- href="/#/downloads"
- class="text-sm text-exo-light-gray hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-2 cursor-pointer"
- title="View downloads overview"
- >
- <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
- <path d="M12 3v12" />
- <path d="M7 12l5 5 5-5" />
- <path d="M5 21h14" />
- </svg>
- Downloads
- </a>
- </div>
+ <!-- Right: Home + Downloads -->
+ <div
+ class="absolute right-6 top-1/2 -translate-y-1/2 flex items-center gap-4"
+ >
+ {#if showHome}
+ <button
+ onclick={handleHome}
+ class="text-sm text-exo-light-gray hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-2 cursor-pointer"
+ title="Back to topology view"
+ >
+ <svg
+ class="w-4 h-4"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
+ />
+ </svg>
+ Home
+ </button>
+ {/if}
+ <a
+ href="/#/downloads"
+ class="text-sm text-exo-light-gray hover:text-exo-yellow transition-colors tracking-wider uppercase flex items-center gap-2 cursor-pointer"
+ title="View downloads overview"
+ >
+ <svg
+ class="w-4 h-4"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ >
+ <path d="M12 3v12" />
+ <path d="M7 12l5 5 5-5" />
+ <path d="M5 21h14" />
+ </svg>
+ Downloads
+ </a>
+ </div>
</header>
diff --git a/dashboard/src/lib/components/ImageParamsPanel.svelte b/dashboard/src/lib/components/ImageParamsPanel.svelte
index 39742b22..a7d23798 100644
--- a/dashboard/src/lib/components/ImageParamsPanel.svelte
+++ b/dashboard/src/lib/components/ImageParamsPanel.svelte
@@ -1,502 +1,556 @@
<script lang="ts">
- import {
- imageGenerationParams,
- setImageGenerationParams,
- resetImageGenerationParams,
- type ImageGenerationParams,
- } from "$lib/stores/app.svelte";
-
- interface Props {
- isEditMode?: boolean;
- }
-
- let { isEditMode = false }: Props = $props();
-
- let showAdvanced = $state(false);
-
- // Custom dropdown state
- let isSizeDropdownOpen = $state(false);
- let isQualityDropdownOpen = $state(false);
- let sizeButtonRef: HTMLButtonElement | undefined = $state();
- let qualityButtonRef: HTMLButtonElement | undefined = $state();
-
- const sizeDropdownPosition = $derived(() => {
- if (!sizeButtonRef || !isSizeDropdownOpen) return { top: 0, left: 0, width: 0 };
- const rect = sizeButtonRef.getBoundingClientRect();
- return { top: rect.top, left: rect.left, width: rect.width };
- });
-
- const qualityDropdownPosition = $derived(() => {
- if (!qualityButtonRef || !isQualityDropdownOpen) return { top: 0, left: 0, width: 0 };
- const rect = qualityButtonRef.getBoundingClientRect();
- return { top: rect.top, left: rect.left, width: rect.width };
- });
-
- const params = $derived(imageGenerationParams());
-
- const inputFidelityOptions: ImageGenerationParams["inputFidelity"][] = [
- "low",
- "high",
- ];
-
- const outputFormatOptions: ImageGenerationParams["outputFormat"][] = [
- "png",
- "jpeg",
- ];
-
- function handleInputFidelityChange(value: ImageGenerationParams["inputFidelity"]) {
- setImageGenerationParams({ inputFidelity: value });
- }
-
- function handleOutputFormatChange(value: ImageGenerationParams["outputFormat"]) {
- setImageGenerationParams({ outputFormat: value });
- }
-
- const sizeOptions: ImageGenerationParams["size"][] = [
- "512x512",
- "768x768",
- "1024x1024",
- "1024x768",
- "768x1024",
- ];
-
- const qualityOptions: ImageGenerationParams["quality"][] = [
- "low",
- "medium",
- "high",
- ];
-
- function selectSize(value: ImageGenerationParams["size"]) {
- setImageGenerationParams({ size: value });
- isSizeDropdownOpen = false;
- }
-
- function selectQuality(value: ImageGenerationParams["quality"]) {
- setImageGenerationParams({ quality: value });
- isQualityDropdownOpen = false;
- }
-
- function handleSeedChange(event: Event) {
- const input = event.target as HTMLInputElement;
- const value = input.value.trim();
- if (value === "") {
- setImageGenerationParams({ seed: null });
- } else {
- const num = parseInt(value, 10);
- if (!isNaN(num) && num >= 0) {
- setImageGenerationParams({ seed: num });
- }
- }
- }
-
- function handleStepsChange(event: Event) {
- const value = parseInt((event.target as HTMLInputElement).value, 10);
- setImageGenerationParams({ numInferenceSteps: value });
- }
-
- function handleGuidanceChange(event: Event) {
- const value = parseFloat((event.target as HTMLInputElement).value);
- setImageGenerationParams({ guidance: value });
- }
-
- function handleNegativePromptChange(event: Event) {
- const value = (event.target as HTMLTextAreaElement).value;
- setImageGenerationParams({ negativePrompt: value || null });
- }
-
- function clearSteps() {
- setImageGenerationParams({ numInferenceSteps: null });
- }
-
- function clearGuidance() {
- setImageGenerationParams({ guidance: null });
- }
-
- function handleReset() {
- resetImageGenerationParams();
- showAdvanced = false;
- }
-
- const hasAdvancedParams = $derived(
- params.seed !== null ||
- params.numInferenceSteps !== null ||
- params.guidance !== null ||
- (params.negativePrompt !== null && params.negativePrompt.trim() !== ""),
- );
+ import {
+ imageGenerationParams,
+ setImageGenerationParams,
+ resetImageGenerationParams,
+ type ImageGenerationParams,
+ } from "$lib/stores/app.svelte";
+
+ interface Props {
+ isEditMode?: boolean;
+ }
+
+ let { isEditMode = false }: Props = $props();
+
+ let showAdvanced = $state(false);
+
+ // Custom dropdown state
+ let isSizeDropdownOpen = $state(false);
+ let isQualityDropdownOpen = $state(false);
+ let sizeButtonRef: HTMLButtonElement | undefined = $state();
+ let qualityButtonRef: HTMLButtonElement | undefined = $state();
+
+ const sizeDropdownPosition = $derived(() => {
+ if (!sizeButtonRef || !isSizeDropdownOpen)
+ return { top: 0, left: 0, width: 0 };
+ const rect = sizeButtonRef.getBoundingClientRect();
+ return { top: rect.top, left: rect.left, width: rect.width };
+ });
+
+ const qualityDropdownPosition = $derived(() => {
+ if (!qualityButtonRef || !isQualityDropdownOpen)
+ return { top: 0, left: 0, width: 0 };
+ const rect = qualityButtonRef.getBoundingClientRect();
+ return { top: rect.top, left: rect.left, width: rect.width };
+ });
+
+ const params = $derived(imageGenerationParams());
+
+ const inputFidelityOptions: ImageGenerationParams["inputFidelity"][] = [
+ "low",
+ "high",
+ ];
+
+ const outputFormatOptions: ImageGenerationParams["outputFormat"][] = [
+ "png",
+ "jpeg",
+ ];
+
+ function handleInputFidelityChange(
+ value: ImageGenerationParams["inputFidelity"],
+ ) {
+ setImageGenerationParams({ inputFidelity: value });
+ }
+
+ function handleOutputFormatChange(
+ value: ImageGenerationParams["outputFormat"],
+ ) {
+ setImageGenerationParams({ outputFormat: value });
+ }
+
+ const sizeOptions: ImageGenerationParams["size"][] = [
+ "512x512",
+ "768x768",
+ "1024x1024",
+ "1024x768",
+ "768x1024",
+ ];
+
+ const qualityOptions: ImageGenerationParams["quality"][] = [
+ "low",
+ "medium",
+ "high",
+ ];
+
+ function selectSize(value: ImageGenerationParams["size"]) {
+ setImageGenerationParams({ size: value });
+ isSizeDropdownOpen = false;
+ }
+
+ function selectQuality(value: ImageGenerationParams["quality"]) {
+ setImageGenerationParams({ quality: value });
+ isQualityDropdownOpen = false;
+ }
+
+ function handleSeedChange(event: Event) {
+ const input = event.target as HTMLInputElement;
+ const value = input.value.trim();
+ if (value === "") {
+ setImageGenerationParams({ seed: null });
+ } else {
+ const num = parseInt(value, 10);
+ if (!isNaN(num) && num >= 0) {
+ setImageGenerationParams({ seed: num });
+ }
+ }
+ }
+
+ function handleStepsChange(event: Event) {
+ const value = parseInt((event.target as HTMLInputElement).value, 10);
+ setImageGenerationParams({ numInferenceSteps: value });
+ }
+
+ function handleGuidanceChange(event: Event) {
+ const value = parseFloat((event.target as HTMLInputElement).value);
+ setImageGenerationParams({ guidance: value });
+ }
+
+ function handleNegativePromptChange(event: Event) {
+ const value = (event.target as HTMLTextAreaElement).value;
+ setImageGenerationParams({ negativePrompt: value || null });
+ }
+
+ function clearSteps() {
+ setImageGenerationParams({ numInferenceSteps: null });
+ }
+
+ function clearGuidance() {
+ setImageGenerationParams({ guidance: null });
+ }
+
+ function handleReset() {
+ resetImageGenerationParams();
+ showAdvanced = false;
+ }
+
+ const hasAdvancedParams = $derived(
+ params.seed !== null ||
+ params.numInferenceSteps !== null ||
+ params.guidance !== null ||
+ (params.negativePrompt !== null && params.negativePrompt.trim() !== ""),
+ );
</script>
<div class="border-b border-exo-medium-gray/30 px-3 py-2">
- <!-- Basic params row -->
- <div class="flex items-center gap-3 flex-wrap">
- <!-- Size -->
- <div class="flex items-center gap-1.5">
- <span class="text-xs text-exo-light-gray uppercase tracking-wider"
- >SIZE:</span
- >
- <div class="relative">
- <button
- bind:this={sizeButtonRef}
- type="button"
- onclick={() => isSizeDropdownOpen = !isSizeDropdownOpen}
- class="bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-2 pr-6 py-1 text-xs font-mono text-exo-yellow cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isSizeDropdownOpen ? 'border-exo-yellow/70' : ''}"
- >
- {params.size}
- </button>
- <div class="absolute right-1.5 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isSizeDropdownOpen ? 'rotate-180' : ''}">
- <svg class="w-3 h-3 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>
- </div>
-
- {#if isSizeDropdownOpen}
- <!-- Backdrop to close dropdown -->
- <button
- type="button"
- class="fixed inset-0 z-[9998] cursor-default"
- onclick={() => isSizeDropdownOpen = false}
- aria-label="Close dropdown"
- ></button>
-
- <!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
- <div
- class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto min-w-max"
- style="bottom: calc(100vh - {sizeDropdownPosition().top}px + 4px); left: {sizeDropdownPosition().left}px;"
- >
- <div class="py-1">
- {#each sizeOptions as size}
- <button
- type="button"
- onclick={() => selectSize(size)}
- class="w-full px-3 py-1.5 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {
- params.size === size
- ? 'bg-transparent text-exo-yellow'
- : 'text-exo-light-gray hover:text-exo-yellow'
- }"
- >
- {#if params.size === size}
- <svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
- <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
- </svg>
- {:else}
- <span class="w-3"></span>
- {/if}
- <span>{size}</span>
- </button>
- {/each}
- </div>
- </div>
- {/if}
- </div>
-
- <!-- Quality -->
- <div class="flex items-center gap-1.5">
- <span class="text-xs text-exo-light-gray uppercase tracking-wider"
- >QUALITY:</span
- >
- <div class="relative">
- <button
- bind:this={qualityButtonRef}
- type="button"
- onclick={() => isQualityDropdownOpen = !isQualityDropdownOpen}
- class="bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-2 pr-6 py-1 text-xs font-mono text-exo-yellow cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isQualityDropdownOpen ? 'border-exo-yellow/70' : ''}"
- >
- {params.quality.toUpperCase()}
- </button>
- <div class="absolute right-1.5 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isQualityDropdownOpen ? 'rotate-180' : ''}">
- <svg class="w-3 h-3 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>
- </div>
-
- {#if isQualityDropdownOpen}
- <!-- Backdrop to close dropdown -->
- <button
- type="button"
- class="fixed inset-0 z-[9998] cursor-default"
- onclick={() => isQualityDropdownOpen = false}
- aria-label="Close dropdown"
- ></button>
-
- <!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
- <div
- class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto min-w-max"
- style="bottom: calc(100vh - {qualityDropdownPosition().top}px + 4px); left: {qualityDropdownPosition().left}px;"
- >
- <div class="py-1">
- {#each qualityOptions as quality}
- <button
- type="button"
- onclick={() => selectQuality(quality)}
- class="w-full px-3 py-1.5 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {
- params.quality === quality
- ? 'bg-transparent text-exo-yellow'
- : 'text-exo-light-gray hover:text-exo-yellow'
- }"
- >
- {#if params.quality === quality}
- <svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
- <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
- </svg>
- {:else}
- <span class="w-3"></span>
- {/if}
- <span>{quality.toUpperCase()}</span>
- </button>
- {/each}
- </div>
- </div>
- {/if}
- </div>
-
- <!-- Format -->
- <div class="flex items-center gap-1.5">
- <span class="text-xs text-exo-light-gray uppercase tracking-wider"
- >FORMAT:</span
- >
- <div class="flex rounded overflow-hidden border border-exo-yellow/30">
- {#each outputFormatOptions as format}
- <button
- type="button"
- onclick={() => handleOutputFormatChange(format)}
- class="px-2 py-1 text-xs font-mono uppercase transition-all duration-200 cursor-pointer {
- params.outputFormat === format
- ? 'bg-exo-yellow text-exo-black'
- : 'bg-exo-medium-gray/50 text-exo-light-gray hover:text-exo-yellow'
- }"
- >
- {format}
- </button>
- {/each}
- </div>
- </div>
-
- <!-- Input Fidelity (edit mode only) -->
- {#if isEditMode}
- <div class="flex items-center gap-1.5">
- <span class="text-xs text-exo-light-gray uppercase tracking-wider"
- >FIDELITY:</span
- >
- <div class="flex rounded overflow-hidden border border-exo-yellow/30">
- {#each inputFidelityOptions as fidelity}
- <button
- type="button"
- onclick={() => handleInputFidelityChange(fidelity)}
- class="px-2 py-1 text-xs font-mono uppercase transition-all duration-200 cursor-pointer {
- params.inputFidelity === fidelity
- ? 'bg-exo-yellow text-exo-black'
- : 'bg-exo-medium-gray/50 text-exo-light-gray hover:text-exo-yellow'
- }"
- title={fidelity === 'low' ? 'More creative variation' : 'Closer to original'}
- >
- {fidelity}
- </button>
- {/each}
- </div>
- </div>
- {/if}
-
- <!-- Spacer -->
- <div class="flex-1"></div>
-
- <!-- Advanced toggle -->
- <button
- type="button"
- onclick={() => (showAdvanced = !showAdvanced)}
- class="flex items-center gap-1 text-xs font-mono tracking-wider uppercase transition-colors duration-200 {showAdvanced ||
- hasAdvancedParams
- ? 'text-exo-yellow'
- : 'text-exo-light-gray hover:text-exo-yellow'}"
- >
- <span>ADVANCED</span>
- <svg
- class="w-3 h-3 transition-transform duration-200 {showAdvanced
- ? 'rotate-180'
- : ''}"
- 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>
- {#if hasAdvancedParams && !showAdvanced}
- <span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"></span>
- {/if}
- </button>
- </div>
-
- <!-- Advanced params section -->
- {#if showAdvanced}
- <div class="mt-3 pt-3 border-t border-exo-medium-gray/20 space-y-3">
- <!-- Row 1: Seed and Steps -->
- <div class="flex items-center gap-4 flex-wrap">
- <!-- Seed -->
- <div class="flex items-center gap-1.5">
- <span class="text-xs text-exo-light-gray uppercase tracking-wider"
- >SEED:</span
- >
- <input
- type="number"
- min="0"
- value={params.seed ?? ""}
- oninput={handleSeedChange}
- placeholder="Random"
- class="w-24 bg-exo-medium-gray/50 border border-exo-yellow/30 rounded px-2 py-1 text-xs font-mono text-exo-yellow placeholder:text-exo-light-gray/50 transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70"
- />
- </div>
-
- <!-- Steps Slider -->
- <div class="flex items-center gap-1.5 flex-1 min-w-[200px]">
- <span
- class="text-xs text-exo-light-gray uppercase tracking-wider whitespace-nowrap"
- >STEPS:</span
- >
- <div class="flex items-center gap-2 flex-1">
- <input
- type="range"
- min="1"
- max="100"
- value={params.numInferenceSteps ?? 50}
- oninput={handleStepsChange}
- class="flex-1 h-1 bg-exo-medium-gray/50 rounded appearance-none cursor-pointer accent-exo-yellow"
- />
- <span class="text-xs font-mono text-exo-yellow w-8 text-right">
- {params.numInferenceSteps ?? "--"}
- </span>
- {#if params.numInferenceSteps !== null}
- <button
- type="button"
- onclick={clearSteps}
- class="text-exo-light-gray hover:text-exo-yellow transition-colors"
- title="Clear"
- >
- <svg
- class="w-3 h-3"
- fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- stroke-width="2"
- d="M6 18L18 6M6 6l12 12"
- />
- </svg>
- </button>
- {/if}
- </div>
- </div>
- </div>
-
- <!-- Row 2: Guidance -->
- <div class="flex items-center gap-1.5">
- <span
- class="text-xs text-exo-light-gray uppercase tracking-wider whitespace-nowrap"
- >GUIDANCE:</span
- >
- <div class="flex items-center gap-2 flex-1 max-w-xs">
- <input
- type="range"
- min="1"
- max="20"
- step="0.5"
- value={params.guidance ?? 7.5}
- oninput={handleGuidanceChange}
- class="flex-1 h-1 bg-exo-medium-gray/50 rounded appearance-none cursor-pointer accent-exo-yellow"
- />
- <span class="text-xs font-mono text-exo-yellow w-8 text-right">
- {params.guidance !== null ? params.guidance.toFixed(1) : "--"}
- </span>
- {#if params.guidance !== null}
- <button
- type="button"
- onclick={clearGuidance}
- class="text-exo-light-gray hover:text-exo-yellow transition-colors"
- title="Clear"
- >
- <svg
- class="w-3 h-3"
- fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- stroke-width="2"
- d="M6 18L18 6M6 6l12 12"
- />
- </svg>
- </button>
- {/if}
- </div>
- </div>
-
- <!-- Row 3: Negative Prompt -->
- <div class="flex flex-col gap-1.5">
- <span class="text-xs text-exo-light-gray uppercase tracking-wider"
- >NEGATIVE PROMPT:</span
- >
- <textarea
- value={params.negativePrompt ?? ""}
- oninput={handleNegativePromptChange}
- placeholder="Things to avoid in the image..."
- rows={2}
- class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded px-2 py-1.5 text-xs font-mono text-exo-yellow placeholder:text-exo-light-gray/50 resize-none transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70"
- ></textarea>
- </div>
-
- <!-- Reset Button -->
- <div class="flex justify-end pt-1">
- <button
- type="button"
- onclick={handleReset}
- class="text-xs font-mono tracking-wider uppercase text-exo-light-gray hover:text-exo-yellow transition-colors duration-200"
- >
- RESET TO DEFAULTS
- </button>
- </div>
- </div>
- {/if}
+ <!-- Basic params row -->
+ <div class="flex items-center gap-3 flex-wrap">
+ <!-- Size -->
+ <div class="flex items-center gap-1.5">
+ <span class="text-xs text-exo-light-gray uppercase tracking-wider"
+ >SIZE:</span
+ >
+ <div class="relative">
+ <button
+ bind:this={sizeButtonRef}
+ type="button"
+ onclick={() => (isSizeDropdownOpen = !isSizeDropdownOpen)}
+ class="bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-2 pr-6 py-1 text-xs font-mono text-exo-yellow cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isSizeDropdownOpen
+ ? 'border-exo-yellow/70'
+ : ''}"
+ >
+ {params.size}
+ </button>
+ <div
+ class="absolute right-1.5 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isSizeDropdownOpen
+ ? 'rotate-180'
+ : ''}"
+ >
+ <svg
+ class="w-3 h-3 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>
+ </div>
+
+ {#if isSizeDropdownOpen}
+ <!-- Backdrop to close dropdown -->
+ <button
+ type="button"
+ class="fixed inset-0 z-[9998] cursor-default"
+ onclick={() => (isSizeDropdownOpen = false)}
+ aria-label="Close dropdown"
+ ></button>
+
+ <!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
+ <div
+ class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto min-w-max"
+ style="bottom: calc(100vh - {sizeDropdownPosition()
+ .top}px + 4px); left: {sizeDropdownPosition().left}px;"
+ >
+ <div class="py-1">
+ {#each sizeOptions as size}
+ <button
+ type="button"
+ onclick={() => selectSize(size)}
+ class="w-full px-3 py-1.5 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {params.size ===
+ size
+ ? 'bg-transparent text-exo-yellow'
+ : 'text-exo-light-gray hover:text-exo-yellow'}"
+ >
+ {#if params.size === size}
+ <svg
+ class="w-3 h-3 flex-shrink-0"
+ fill="currentColor"
+ viewBox="0 0 20 20"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ {:else}
+ <span class="w-3"></span>
+ {/if}
+ <span>{size}</span>
+ </button>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+
+ <!-- Quality -->
+ <div class="flex items-center gap-1.5">
+ <span class="text-xs text-exo-light-gray uppercase tracking-wider"
+ >QUALITY:</span
+ >
+ <div class="relative">
+ <button
+ bind:this={qualityButtonRef}
+ type="button"
+ onclick={() => (isQualityDropdownOpen = !isQualityDropdownOpen)}
+ class="bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-2 pr-6 py-1 text-xs font-mono text-exo-yellow cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isQualityDropdownOpen
+ ? 'border-exo-yellow/70'
+ : ''}"
+ >
+ {params.quality.toUpperCase()}
+ </button>
+ <div
+ class="absolute right-1.5 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isQualityDropdownOpen
+ ? 'rotate-180'
+ : ''}"
+ >
+ <svg
+ class="w-3 h-3 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>
+ </div>
+
+ {#if isQualityDropdownOpen}
+ <!-- Backdrop to close dropdown -->
+ <button
+ type="button"
+ class="fixed inset-0 z-[9998] cursor-default"
+ onclick={() => (isQualityDropdownOpen = false)}
+ aria-label="Close dropdown"
+ ></button>
+
+ <!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
+ <div
+ class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto min-w-max"
+ style="bottom: calc(100vh - {qualityDropdownPosition()
+ .top}px + 4px); left: {qualityDropdownPosition().left}px;"
+ >
+ <div class="py-1">
+ {#each qualityOptions as quality}
+ <button
+ type="button"
+ onclick={() => selectQuality(quality)}
+ class="w-full px-3 py-1.5 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {params.quality ===
+ quality
+ ? 'bg-transparent text-exo-yellow'
+ : 'text-exo-light-gray hover:text-exo-yellow'}"
+ >
+ {#if params.quality === quality}
+ <svg
+ class="w-3 h-3 flex-shrink-0"
+ fill="currentColor"
+ viewBox="0 0 20 20"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ {:else}
+ <span class="w-3"></span>
+ {/if}
+ <span>{quality.toUpperCase()}</span>
+ </button>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+
+ <!-- Format -->
+ <div class="flex items-center gap-1.5">
+ <span class="text-xs text-exo-light-gray uppercase tracking-wider"
+ >FORMAT:</span
+ >
+ <div class="flex rounded overflow-hidden border border-exo-yellow/30">
+ {#each outputFormatOptions as format}
+ <button
+ type="button"
+ onclick={() => handleOutputFormatChange(format)}
+ class="px-2 py-1 text-xs font-mono uppercase transition-all duration-200 cursor-pointer {params.outputFormat ===
+ format
+ ? 'bg-exo-yellow text-exo-black'
+ : 'bg-exo-medium-gray/50 text-exo-light-gray hover:text-exo-yellow'}"
+ >
+ {format}
+ </button>
+ {/each}
+ </div>
+ </div>
+
+ <!-- Input Fidelity (edit mode only) -->
+ {#if isEditMode}
+ <div class="flex items-center gap-1.5">
+ <span class="text-xs text-exo-light-gray uppercase tracking-wider"
+ >FIDELITY:</span
+ >
+ <div class="flex rounded overflow-hidden border border-exo-yellow/30">
+ {#each inputFidelityOptions as fidelity}
+ <button
+ type="button"
+ onclick={() => handleInputFidelityChange(fidelity)}
+ class="px-2 py-1 text-xs font-mono uppercase transition-all duration-200 cursor-pointer {params.inputFidelity ===
+ fidelity
+ ? 'bg-exo-yellow text-exo-black'
+ : 'bg-exo-medium-gray/50 text-exo-light-gray hover:text-exo-yellow'}"
+ title={fidelity === "low"
+ ? "More creative variation"
+ : "Closer to original"}
+ >
+ {fidelity}
+ </button>
+ {/each}
+ </div>
+ </div>
+ {/if}
+
+ <!-- Spacer -->
+ <div class="flex-1"></div>
+
+ <!-- Advanced toggle -->
+ <button
+ type="button"
+ onclick={() => (showAdvanced = !showAdvanced)}
+ class="flex items-center gap-1 text-xs font-mono tracking-wider uppercase transition-colors duration-200 {showAdvanced ||
+ hasAdvancedParams
+ ? 'text-exo-yellow'
+ : 'text-exo-light-gray hover:text-exo-yellow'}"
+ >
+ <span>ADVANCED</span>
+ <svg
+ class="w-3 h-3 transition-transform duration-200 {showAdvanced
+ ? 'rotate-180'
+ : ''}"
+ 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>
+ {#if hasAdvancedParams && !showAdvanced}
+ <span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"></span>
+ {/if}
+ </button>
+ </div>
+
+ <!-- Advanced params section -->
+ {#if showAdvanced}
+ <div class="mt-3 pt-3 border-t border-exo-medium-gray/20 space-y-3">
+ <!-- Row 1: Seed and Steps -->
+ <div class="flex items-center gap-4 flex-wrap">
+ <!-- Seed -->
+ <div class="flex items-center gap-1.5">
+ <span class="text-xs text-exo-light-gray uppercase tracking-wider"
+ >SEED:</span
+ >
+ <input
+ type="number"
+ min="0"
+ value={params.seed ?? ""}
+ oninput={handleSeedChange}
+ placeholder="Random"
+ class="w-24 bg-exo-medium-gray/50 border border-exo-yellow/30 rounded px-2 py-1 text-xs font-mono text-exo-yellow placeholder:text-exo-light-gray/50 transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70"
+ />
+ </div>
+
+ <!-- Steps Slider -->
+ <div class="flex items-center gap-1.5 flex-1 min-w-[200px]">
+ <span
+ class="text-xs text-exo-light-gray uppercase tracking-wider whitespace-nowrap"
+ >STEPS:</span
+ >
+ <div class="flex items-center gap-2 flex-1">
+ <input
+ type="range"
+ min="1"
+ max="100"
+ value={params.numInferenceSteps ?? 50}
+ oninput={handleStepsChange}
+ class="flex-1 h-1 bg-exo-medium-gray/50 rounded appearance-none cursor-pointer accent-exo-yellow"
+ />
+ <span class="text-xs font-mono text-exo-yellow w-8 text-right">
+ {params.numInferenceSteps ?? "--"}
+ </span>
+ {#if params.numInferenceSteps !== null}
+ <button
+ type="button"
+ onclick={clearSteps}
+ class="text-exo-light-gray hover:text-exo-yellow transition-colors"
+ title="Clear"
+ >
+ <svg
+ class="w-3 h-3"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M6 18L18 6M6 6l12 12"
+ />
+ </svg>
+ </button>
+ {/if}
+ </div>
+ </div>
+ </div>
+
+ <!-- Row 2: Guidance -->
+ <div class="flex items-center gap-1.5">
+ <span
+ class="text-xs text-exo-light-gray uppercase tracking-wider whitespace-nowrap"
+ >GUIDANCE:</span
+ >
+ <div class="flex items-center gap-2 flex-1 max-w-xs">
+ <input
+ type="range"
+ min="1"
+ max="20"
+ step="0.5"
+ value={params.guidance ?? 7.5}
+ oninput={handleGuidanceChange}
+ class="flex-1 h-1 bg-exo-medium-gray/50 rounded appearance-none cursor-pointer accent-exo-yellow"
+ />
+ <span class="text-xs font-mono text-exo-yellow w-8 text-right">
+ {params.guidance !== null ? params.guidance.toFixed(1) : "--"}
+ </span>
+ {#if params.guidance !== null}
+ <button
+ type="button"
+ onclick={clearGuidance}
+ class="text-exo-light-gray hover:text-exo-yellow transition-colors"
+ title="Clear"
+ >
+ <svg
+ class="w-3 h-3"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M6 18L18 6M6 6l12 12"
+ />
+ </svg>
+ </button>
+ {/if}
+ </div>
+ </div>
+
+ <!-- Row 3: Negative Prompt -->
+ <div class="flex flex-col gap-1.5">
+ <span class="text-xs text-exo-light-gray uppercase tracking-wider"
+ >NEGATIVE PROMPT:</span
+ >
+ <textarea
+ value={params.negativePrompt ?? ""}
+ oninput={handleNegativePromptChange}
+ placeholder="Things to avoid in the image..."
+ rows={2}
+ class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded px-2 py-1.5 text-xs font-mono text-exo-yellow placeholder:text-exo-light-gray/50 resize-none transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70"
+ ></textarea>
+ </div>
+
+ <!-- Reset Button -->
+ <div class="flex justify-end pt-1">
+ <button
+ type="button"
+ onclick={handleReset}
+ class="text-xs font-mono tracking-wider uppercase text-exo-light-gray hover:text-exo-yellow transition-colors duration-200"
+ >
+ RESET TO DEFAULTS
+ </button>
+ </div>
+ </div>
+ {/if}
</div>
<style>
- /* Custom range slider styling */
- input[type="range"]::-webkit-slider-thumb {
- -webkit-appearance: none;
- appearance: none;
- width: 12px;
- height: 12px;
- border-radius: 50%;
- background: #ffd700;
- cursor: pointer;
- border: none;
- }
-
- input[type="range"]::-moz-range-thumb {
- width: 12px;
- height: 12px;
- border-radius: 50%;
- background: #ffd700;
- cursor: pointer;
- border: none;
- }
-
- /* Hide number input spinners */
- input[type="number"]::-webkit-inner-spin-button,
- input[type="number"]::-webkit-outer-spin-button {
- -webkit-appearance: none;
- margin: 0;
- }
-
- input[type="number"] {
- -moz-appearance: textfield;
- }
+ /* Custom range slider styling */
+ input[type="range"]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: #ffd700;
+ cursor: pointer;
+ border: none;
+ }
+
+ input[type="range"]::-moz-range-thumb {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: #ffd700;
+ cursor: pointer;
+ border: none;
+ }
+
+ /* Hide number input spinners */
+ input[type="number"]::-webkit-inner-spin-button,
+ input[type="number"]::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+ }
+
+ input[type="number"] {
+ -moz-appearance: textfield;
+ }
</style>
diff --git a/dashboard/src/lib/components/MarkdownContent.svelte b/dashboard/src/lib/components/MarkdownContent.svelte
index 7ac3b66b..1f83ac0c 100644
--- a/dashboard/src/lib/components/MarkdownContent.svelte
+++ b/dashboard/src/lib/components/MarkdownContent.svelte
@@ -1,35 +1,35 @@
<script lang="ts">
- import { marked } from 'marked';
- import hljs from 'highlight.js';
- import katex from 'katex';
- import 'katex/dist/katex.min.css';
- import { browser } from '$app/environment';
+ import { marked } from "marked";
+ import hljs from "highlight.js";
+ import katex from "katex";
+ import "katex/dist/katex.min.css";
+ import { browser } from "$app/environment";
- interface Props {
- content: string;
- class?: string;
- }
+ interface Props {
+ content: string;
+ class?: string;
+ }
- let { content, class: className = '' }: Props = $props();
+ let { content, class: className = "" }: Props = $props();
- let containerRef = $state<HTMLDivElement>();
- let processedHtml = $state('');
+ let containerRef = $state<HTMLDivElement>();
+ let processedHtml = $state("");
- // Configure marked with syntax highlighting
- marked.setOptions({
- gfm: true,
- breaks: true
- });
+ // Configure marked with syntax highlighting
+ marked.setOptions({
+ gfm: true,
+ breaks: true,
+ });
- // Custom renderer for code blocks
- const renderer = new marked.Renderer();
+ // Custom renderer for code blocks
+ const renderer = new marked.Renderer();
- renderer.code = function ({ text, lang }: { text: string; lang?: string }) {
- const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext';
- const highlighted = hljs.highlight(text, { language }).value;
- const codeId = `code-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
+ renderer.code = function ({ text, lang }: { text: string; lang?: string }) {
+ const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
+ const highlighted = hljs.highlight(text, { language }).value;
+ const codeId = `code-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
- return `
+ return `
<div class="code-block-wrapper">
<div class="code-block-header">
<span class="code-language">${language}</span>
@@ -43,264 +43,334 @@
<pre><code class="hljs language-${language}" data-code-id="${codeId}">${highlighted}</code></pre>
</div>
`;
- };
-
- // Inline code
- renderer.codespan = function ({ text }: { text: string }) {
- return `<code class="inline-code">${text}</code>`;
- };
-
- marked.use({ renderer });
-
- /**
- * Unescape HTML entities that marked may have escaped
- */
- function unescapeHtmlEntities(text: string): string {
- return text
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/&/g, '&')
- .replace(/"/g, '"')
- .replace(/'/g, "'");
- }
-
- // Storage for math expressions extracted before markdown processing
- const mathExpressions: Map<string, { content: string; displayMode: boolean }> = new Map();
- let mathCounter = 0;
-
- // Storage for HTML snippets that need protection from markdown
- const htmlSnippets: Map<string, string> = new Map();
- let htmlCounter = 0;
-
- // Use alphanumeric placeholders that won't be interpreted as HTML tags
- const MATH_PLACEHOLDER_PREFIX = 'MATHPLACEHOLDER';
- const CODE_PLACEHOLDER_PREFIX = 'CODEPLACEHOLDER';
- const HTML_PLACEHOLDER_PREFIX = 'HTMLPLACEHOLDER';
-
- /**
- * Preprocess LaTeX: extract math, handle LaTeX document commands, and protect content
- */
- function preprocessLaTeX(text: string): string {
- // Reset storage
- mathExpressions.clear();
- mathCounter = 0;
- htmlSnippets.clear();
- htmlCounter = 0;
-
- // Protect code blocks first
- const codeBlocks: string[] = [];
- let processed = text.replace(/```[\s\S]*?```|`[^`]+`/g, (match) => {
- codeBlocks.push(match);
- return `${CODE_PLACEHOLDER_PREFIX}${codeBlocks.length - 1}END`;
- });
-
- // Remove LaTeX document commands
- processed = processed.replace(/\\documentclass(\[[^\]]*\])?\{[^}]*\}/g, '');
- processed = processed.replace(/\\usepackage(\[[^\]]*\])?\{[^}]*\}/g, '');
- processed = processed.replace(/\\begin\{document\}/g, '');
- processed = processed.replace(/\\end\{document\}/g, '');
- processed = processed.replace(/\\maketitle/g, '');
- processed = processed.replace(/\\title\{[^}]*\}/g, '');
- processed = processed.replace(/\\author\{[^}]*\}/g, '');
- processed = processed.replace(/\\date\{[^}]*\}/g, '');
-
- // Remove \require{...} commands (MathJax-specific, not supported by KaTeX)
- processed = processed.replace(/\$\\require\{[^}]*\}\$/g, '');
- processed = processed.replace(/\\require\{[^}]*\}/g, '');
-
- // Remove unsupported LaTeX commands/environments (tikzpicture, figure, center, etc.)
- processed = processed.replace(/\\begin\{tikzpicture\}[\s\S]*?\\end\{tikzpicture\}/g, () => {
- const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
- htmlSnippets.set(placeholder, '<div class="latex-diagram-placeholder"><span class="latex-diagram-icon">📐</span><span class="latex-diagram-text">Diagram</span></div>');
- htmlCounter++;
- return placeholder;
- });
- processed = processed.replace(/\\begin\{figure\}[\s\S]*?\\end\{figure\}/g, () => {
- const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
- htmlSnippets.set(placeholder, '<div class="latex-diagram-placeholder"><span class="latex-diagram-icon">🖼️</span><span class="latex-diagram-text">Figure</span></div>');
- htmlCounter++;
- return placeholder;
- });
- // Strip center environment (layout only, no content change)
- processed = processed.replace(/\\begin\{center\}/g, '');
- processed = processed.replace(/\\end\{center\}/g, '');
- // Strip other layout environments
- processed = processed.replace(/\\begin\{flushleft\}/g, '');
- processed = processed.replace(/\\end\{flushleft\}/g, '');
- processed = processed.replace(/\\begin\{flushright\}/g, '');
- processed = processed.replace(/\\end\{flushright\}/g, '');
- processed = processed.replace(/\\label\{[^}]*\}/g, '');
- processed = processed.replace(/\\caption\{[^}]*\}/g, '');
-
- // Protect escaped dollar signs (e.g., \$50 should become $50, not LaTeX)
- processed = processed.replace(/\\\$/g, 'ESCAPEDDOLLARPLACEHOLDER');
-
- // Convert LaTeX math environments to display math (both bare and wrapped in $...$)
- const mathEnvs = ['align', 'align\\*', 'equation', 'equation\\*', 'gather', 'gather\\*', 'multline', 'multline\\*', 'eqnarray', 'eqnarray\\*', 'array', 'matrix', 'pmatrix', 'bmatrix', 'vmatrix', 'cases'];
- for (const env of mathEnvs) {
- // Handle $\begin{env}...\end{env}$ (with dollar signs, possibly multiline)
- const wrappedRegex = new RegExp(`\\$\\\\begin\\{${env}\\}(\\{[^}]*\\})?([\\s\\S]*?)\\\\end\\{${env}\\}\\$`, 'g');
- processed = processed.replace(wrappedRegex, (_, args, content) => {
- const cleanEnv = env.replace('\\*', '*');
- const mathContent = `\\begin{${cleanEnv}}${args || ''}${content}\\end{${cleanEnv}}`;
- const placeholder = `${MATH_PLACEHOLDER_PREFIX}DISPLAY${mathCounter}END`;
- mathExpressions.set(placeholder, { content: mathContent, displayMode: true });
- mathCounter++;
- return placeholder;
- });
-
- // Handle bare \begin{env}...\end{env} (without dollar signs)
- const bareRegex = new RegExp(`\\\\begin\\{${env}\\}(\\{[^}]*\\})?([\\s\\S]*?)\\\\end\\{${env}\\}`, 'g');
- processed = processed.replace(bareRegex, (_, args, content) => {
- const cleanEnv = env.replace('\\*', '*');
- const mathContent = `\\begin{${cleanEnv}}${args || ''}${content}\\end{${cleanEnv}}`;
- const placeholder = `${MATH_PLACEHOLDER_PREFIX}DISPLAY${mathCounter}END`;
- mathExpressions.set(placeholder, { content: mathContent, displayMode: true });
- mathCounter++;
- return placeholder;
- });
- }
-
- // Convert LaTeX proof environments to styled blocks (use placeholders for HTML)
- processed = processed.replace(
- /\\begin\{proof\}([\s\S]*?)\\end\{proof\}/g,
- (_, content) => {
- const html = `<div class="latex-proof"><div class="latex-proof-header">Proof</div><div class="latex-proof-content">${content}</div></div>`;
- const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
- htmlSnippets.set(placeholder, html);
- htmlCounter++;
- return placeholder;
- }
- );
-
- // Convert LaTeX theorem-like environments
- const theoremEnvs = ['theorem', 'lemma', 'corollary', 'proposition', 'definition', 'remark', 'example'];
- for (const env of theoremEnvs) {
- const envRegex = new RegExp(`\\\\begin\\{${env}\\}([\\s\\S]*?)\\\\end\\{${env}\\}`, 'gi');
- const envName = env.charAt(0).toUpperCase() + env.slice(1);
- processed = processed.replace(envRegex, (_, content) => {
- const html = `<div class="latex-theorem"><div class="latex-theorem-header">${envName}</div><div class="latex-theorem-content">${content}</div></div>`;
- const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
- htmlSnippets.set(placeholder, html);
- htmlCounter++;
- return placeholder;
- });
- }
-
- // Convert LaTeX text formatting commands (use placeholders to protect from markdown)
- processed = processed.replace(/\\emph\{([^}]*)\}/g, (_, content) => {
- const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
- htmlSnippets.set(placeholder, `<em>${content}</em>`);
- htmlCounter++;
- return placeholder;
- });
- processed = processed.replace(/\\textit\{([^}]*)\}/g, (_, content) => {
- const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
- htmlSnippets.set(placeholder, `<em>${content}</em>`);
- htmlCounter++;
- return placeholder;
- });
- processed = processed.replace(/\\textbf\{([^}]*)\}/g, (_, content) => {
- const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
- htmlSnippets.set(placeholder, `<strong>${content}</strong>`);
- htmlCounter++;
- return placeholder;
- });
- processed = processed.replace(/\\texttt\{([^}]*)\}/g, (_, content) => {
- const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
- htmlSnippets.set(placeholder, `<code class="inline-code">${content}</code>`);
- htmlCounter++;
- return placeholder;
- });
- processed = processed.replace(/\\underline\{([^}]*)\}/g, (_, content) => {
- const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
- htmlSnippets.set(placeholder, `<u>${content}</u>`);
- htmlCounter++;
- return placeholder;
- });
-
- // Handle LaTeX line breaks and spacing
- processed = processed.replace(/\\\\(?:\s*\n)?/g, '\n'); // \\ -> newline
- processed = processed.replace(/\\newline/g, '\n');
- processed = processed.replace(/\\par\b/g, '\n\n');
- processed = processed.replace(/\\quad/g, ' ');
- processed = processed.replace(/\\qquad/g, ' ');
- processed = processed.replace(/~~/g, ' '); // non-breaking space
-
- // Remove other common LaTeX commands that don't render
- processed = processed.replace(/\\centering/g, '');
- processed = processed.replace(/\\noindent/g, '');
- processed = processed.replace(/\\hfill/g, '');
- processed = processed.replace(/\\vspace\{[^}]*\}/g, '');
- processed = processed.replace(/\\hspace\{[^}]*\}/g, ' ');
-
- // Convert \(...\) to placeholder (display: false)
- processed = processed.replace(/\\\(([\s\S]+?)\\\)/g, (_, content) => {
- const placeholder = `${MATH_PLACEHOLDER_PREFIX}INLINE${mathCounter}END`;
- mathExpressions.set(placeholder, { content, displayMode: false });
- mathCounter++;
- return placeholder;
- });
-
- // Convert \[...\] to placeholder (display: true)
- processed = processed.replace(/\\\[([\s\S]*?)\\\]/g, (_, content) => {
- const placeholder = `${MATH_PLACEHOLDER_PREFIX}DISPLAY${mathCounter}END`;
- mathExpressions.set(placeholder, { content, displayMode: true });
- mathCounter++;
- return placeholder;
- });
-
- // Extract display math ($$...$$) BEFORE markdown processing
- processed = processed.replace(/\$\$([\s\S]*?)\$\$/g, (_, content) => {
- const placeholder = `${MATH_PLACEHOLDER_PREFIX}DISPLAY${mathCounter}END`;
- mathExpressions.set(placeholder, { content: content.trim(), displayMode: true });
- mathCounter++;
- return placeholder;
- });
-
- // Extract inline math ($...$) BEFORE markdown processing
- // Allow single-line only, skip currency patterns like $5 or $50
- processed = processed.replace(/\$([^\$\n]+?)\$/g, (match, content) => {
- if (/^\d/.test(content.trim())) {
- return match; // Keep as-is for currency
- }
- const placeholder = `${MATH_PLACEHOLDER_PREFIX}INLINE${mathCounter}END`;
- mathExpressions.set(placeholder, { content: content.trim(), displayMode: false });
- mathCounter++;
- return placeholder;
- });
-
- // Restore escaped dollar signs
- processed = processed.replace(/ESCAPEDDOLLARPLACEHOLDER/g, '$');
-
- // Restore code blocks
- processed = processed.replace(new RegExp(`${CODE_PLACEHOLDER_PREFIX}(\\d+)END`, 'g'), (_, index) => codeBlocks[parseInt(index)]);
-
- // Clean up any remaining stray backslashes from unrecognized commands
- processed = processed.replace(/\\(?=[a-zA-Z])/g, ''); // Remove \ before letters (unrecognized commands)
-
- return processed;
- }
-
- /**
- * Render math expressions with KaTeX and restore HTML placeholders
- */
- function renderMath(html: string): string {
- // Replace all math placeholders with rendered KaTeX
- for (const [placeholder, { content, displayMode }] of mathExpressions) {
- const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- const regex = new RegExp(escapedPlaceholder, 'g');
-
- html = html.replace(regex, () => {
- try {
- const rendered = katex.renderToString(content, {
- displayMode,
- throwOnError: false,
- output: 'html'
- });
-
- if (displayMode) {
- return `
+ };
+
+ // Inline code
+ renderer.codespan = function ({ text }: { text: string }) {
+ return `<code class="inline-code">${text}</code>`;
+ };
+
+ marked.use({ renderer });
+
+ /**
+ * Unescape HTML entities that marked may have escaped
+ */
+ function unescapeHtmlEntities(text: string): string {
+ return text
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/&/g, "&")
+ .replace(/"/g, '"')
+ .replace(/'/g, "'");
+ }
+
+ // Storage for math expressions extracted before markdown processing
+ const mathExpressions: Map<
+ string,
+ { content: string; displayMode: boolean }
+ > = new Map();
+ let mathCounter = 0;
+
+ // Storage for HTML snippets that need protection from markdown
+ const htmlSnippets: Map<string, string> = new Map();
+ let htmlCounter = 0;
+
+ // Use alphanumeric placeholders that won't be interpreted as HTML tags
+ const MATH_PLACEHOLDER_PREFIX = "MATHPLACEHOLDER";
+ const CODE_PLACEHOLDER_PREFIX = "CODEPLACEHOLDER";
+ const HTML_PLACEHOLDER_PREFIX = "HTMLPLACEHOLDER";
+
+ /**
+ * Preprocess LaTeX: extract math, handle LaTeX document commands, and protect content
+ */
+ function preprocessLaTeX(text: string): string {
+ // Reset storage
+ mathExpressions.clear();
+ mathCounter = 0;
+ htmlSnippets.clear();
+ htmlCounter = 0;
+
+ // Protect code blocks first
+ const codeBlocks: string[] = [];
+ let processed = text.replace(/```[\s\S]*?```|`[^`]+`/g, (match) => {
+ codeBlocks.push(match);
+ return `${CODE_PLACEHOLDER_PREFIX}${codeBlocks.length - 1}END`;
+ });
+
+ // Remove LaTeX document commands
+ processed = processed.replace(/\\documentclass(\[[^\]]*\])?\{[^}]*\}/g, "");
+ processed = processed.replace(/\\usepackage(\[[^\]]*\])?\{[^}]*\}/g, "");
+ processed = processed.replace(/\\begin\{document\}/g, "");
+ processed = processed.replace(/\\end\{document\}/g, "");
+ processed = processed.replace(/\\maketitle/g, "");
+ processed = processed.replace(/\\title\{[^}]*\}/g, "");
+ processed = processed.replace(/\\author\{[^}]*\}/g, "");
+ processed = processed.replace(/\\date\{[^}]*\}/g, "");
+
+ // Remove \require{...} commands (MathJax-specific, not supported by KaTeX)
+ processed = processed.replace(/\$\\require\{[^}]*\}\$/g, "");
+ processed = processed.replace(/\\require\{[^}]*\}/g, "");
+
+ // Remove unsupported LaTeX commands/environments (tikzpicture, figure, center, etc.)
+ processed = processed.replace(
+ /\\begin\{tikzpicture\}[\s\S]*?\\end\{tikzpicture\}/g,
+ () => {
+ const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
+ htmlSnippets.set(
+ placeholder,
+ '<div class="latex-diagram-placeholder"><span class="latex-diagram-icon">📐</span><span class="latex-diagram-text">Diagram</span></div>',
+ );
+ htmlCounter++;
+ return placeholder;
+ },
+ );
+ processed = processed.replace(
+ /\\begin\{figure\}[\s\S]*?\\end\{figure\}/g,
+ () => {
+ const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
+ htmlSnippets.set(
+ placeholder,
+ '<div class="latex-diagram-placeholder"><span class="latex-diagram-icon">🖼️</span><span class="latex-diagram-text">Figure</span></div>',
+ );
+ htmlCounter++;
+ return placeholder;
+ },
+ );
+ // Strip center environment (layout only, no content change)
+ processed = processed.replace(/\\begin\{center\}/g, "");
+ processed = processed.replace(/\\end\{center\}/g, "");
+ // Strip other layout environments
+ processed = processed.replace(/\\begin\{flushleft\}/g, "");
+ processed = processed.replace(/\\end\{flushleft\}/g, "");
+ processed = processed.replace(/\\begin\{flushright\}/g, "");
+ processed = processed.replace(/\\end\{flushright\}/g, "");
+ processed = processed.replace(/\\label\{[^}]*\}/g, "");
+ processed = processed.replace(/\\caption\{[^}]*\}/g, "");
+
+ // Protect escaped dollar signs (e.g., \$50 should become $50, not LaTeX)
+ processed = processed.replace(/\\\$/g, "ESCAPEDDOLLARPLACEHOLDER");
+
+ // Convert LaTeX math environments to display math (both bare and wrapped in $...$)
+ const mathEnvs = [
+ "align",
+ "align\\*",
+ "equation",
+ "equation\\*",
+ "gather",
+ "gather\\*",
+ "multline",
+ "multline\\*",
+ "eqnarray",
+ "eqnarray\\*",
+ "array",
+ "matrix",
+ "pmatrix",
+ "bmatrix",
+ "vmatrix",
+ "cases",
+ ];
+ for (const env of mathEnvs) {
+ // Handle $\begin{env}...\end{env}$ (with dollar signs, possibly multiline)
+ const wrappedRegex = new RegExp(
+ `\\$\\\\begin\\{${env}\\}(\\{[^}]*\\})?([\\s\\S]*?)\\\\end\\{${env}\\}\\$`,
+ "g",
+ );
+ processed = processed.replace(wrappedRegex, (_, args, content) => {
+ const cleanEnv = env.replace("\\*", "*");
+ const mathContent = `\\begin{${cleanEnv}}${args || ""}${content}\\end{${cleanEnv}}`;
+ const placeholder = `${MATH_PLACEHOLDER_PREFIX}DISPLAY${mathCounter}END`;
+ mathExpressions.set(placeholder, {
+ content: mathContent,
+ displayMode: true,
+ });
+ mathCounter++;
+ return placeholder;
+ });
+
+ // Handle bare \begin{env}...\end{env} (without dollar signs)
+ const bareRegex = new RegExp(
+ `\\\\begin\\{${env}\\}(\\{[^}]*\\})?([\\s\\S]*?)\\\\end\\{${env}\\}`,
+ "g",
+ );
+ processed = processed.replace(bareRegex, (_, args, content) => {
+ const cleanEnv = env.replace("\\*", "*");
+ const mathContent = `\\begin{${cleanEnv}}${args || ""}${content}\\end{${cleanEnv}}`;
+ const placeholder = `${MATH_PLACEHOLDER_PREFIX}DISPLAY${mathCounter}END`;
+ mathExpressions.set(placeholder, {
+ content: mathContent,
+ displayMode: true,
+ });
+ mathCounter++;
+ return placeholder;
+ });
+ }
+
+ // Convert LaTeX proof environments to styled blocks (use placeholders for HTML)
+ processed = processed.replace(
+ /\\begin\{proof\}([\s\S]*?)\\end\{proof\}/g,
+ (_, content) => {
+ const html = `<div class="latex-proof"><div class="latex-proof-header">Proof</div><div class="latex-proof-content">${content}</div></div>`;
+ const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
+ htmlSnippets.set(placeholder, html);
+ htmlCounter++;
+ return placeholder;
+ },
+ );
+
+ // Convert LaTeX theorem-like environments
+ const theoremEnvs = [
+ "theorem",
+ "lemma",
+ "corollary",
+ "proposition",
+ "definition",
+ "remark",
+ "example",
+ ];
+ for (const env of theoremEnvs) {
+ const envRegex = new RegExp(
+ `\\\\begin\\{${env}\\}([\\s\\S]*?)\\\\end\\{${env}\\}`,
+ "gi",
+ );
+ const envName = env.charAt(0).toUpperCase() + env.slice(1);
+ processed = processed.replace(envRegex, (_, content) => {
+ const html = `<div class="latex-theorem"><div class="latex-theorem-header">${envName}</div><div class="latex-theorem-content">${content}</div></div>`;
+ const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
+ htmlSnippets.set(placeholder, html);
+ htmlCounter++;
+ return placeholder;
+ });
+ }
+
+ // Convert LaTeX text formatting commands (use placeholders to protect from markdown)
+ processed = processed.replace(/\\emph\{([^}]*)\}/g, (_, content) => {
+ const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
+ htmlSnippets.set(placeholder, `<em>${content}</em>`);
+ htmlCounter++;
+ return placeholder;
+ });
+ processed = processed.replace(/\\textit\{([^}]*)\}/g, (_, content) => {
+ const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
+ htmlSnippets.set(placeholder, `<em>${content}</em>`);
+ htmlCounter++;
+ return placeholder;
+ });
+ processed = processed.replace(/\\textbf\{([^}]*)\}/g, (_, content) => {
+ const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
+ htmlSnippets.set(placeholder, `<strong>${content}</strong>`);
+ htmlCounter++;
+ return placeholder;
+ });
+ processed = processed.replace(/\\texttt\{([^}]*)\}/g, (_, content) => {
+ const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
+ htmlSnippets.set(
+ placeholder,
+ `<code class="inline-code">${content}</code>`,
+ );
+ htmlCounter++;
+ return placeholder;
+ });
+ processed = processed.replace(/\\underline\{([^}]*)\}/g, (_, content) => {
+ const placeholder = `${HTML_PLACEHOLDER_PREFIX}${htmlCounter}END`;
+ htmlSnippets.set(placeholder, `<u>${content}</u>`);
+ htmlCounter++;
+ return placeholder;
+ });
+
+ // Handle LaTeX line breaks and spacing
+ processed = processed.replace(/\\\\(?:\s*\n)?/g, "\n"); // \\ -> newline
+ processed = processed.replace(/\\newline/g, "\n");
+ processed = processed.replace(/\\par\b/g, "\n\n");
+ processed = processed.replace(/\\quad/g, " ");
+ processed = processed.replace(/\\qquad/g, " ");
+ processed = processed.replace(/~~/g, " "); // non-breaking space
+
+ // Remove other common LaTeX commands that don't render
+ processed = processed.replace(/\\centering/g, "");
+ processed = processed.replace(/\\noindent/g, "");
+ processed = processed.replace(/\\hfill/g, "");
+ processed = processed.replace(/\\vspace\{[^}]*\}/g, "");
+ processed = processed.replace(/\\hspace\{[^}]*\}/g, " ");
+
+ // Convert \(...\) to placeholder (display: false)
+ processed = processed.replace(/\\\(([\s\S]+?)\\\)/g, (_, content) => {
+ const placeholder = `${MATH_PLACEHOLDER_PREFIX}INLINE${mathCounter}END`;
+ mathExpressions.set(placeholder, { content, displayMode: false });
+ mathCounter++;
+ return placeholder;
+ });
+
+ // Convert \[...\] to placeholder (display: true)
+ processed = processed.replace(/\\\[([\s\S]*?)\\\]/g, (_, content) => {
+ const placeholder = `${MATH_PLACEHOLDER_PREFIX}DISPLAY${mathCounter}END`;
+ mathExpressions.set(placeholder, { content, displayMode: true });
+ mathCounter++;
+ return placeholder;
+ });
+
+ // Extract display math ($$...$$) BEFORE markdown processing
+ processed = processed.replace(/\$\$([\s\S]*?)\$\$/g, (_, content) => {
+ const placeholder = `${MATH_PLACEHOLDER_PREFIX}DISPLAY${mathCounter}END`;
+ mathExpressions.set(placeholder, {
+ content: content.trim(),
+ displayMode: true,
+ });
+ mathCounter++;
+ return placeholder;
+ });
+
+ // Extract inline math ($...$) BEFORE markdown processing
+ // Allow single-line only, skip currency patterns like $5 or $50
+ processed = processed.replace(/\$([^\$\n]+?)\$/g, (match, content) => {
+ if (/^\d/.test(content.trim())) {
+ return match; // Keep as-is for currency
+ }
+ const placeholder = `${MATH_PLACEHOLDER_PREFIX}INLINE${mathCounter}END`;
+ mathExpressions.set(placeholder, {
+ content: content.trim(),
+ displayMode: false,
+ });
+ mathCounter++;
+ return placeholder;
+ });
+
+ // Restore escaped dollar signs
+ processed = processed.replace(/ESCAPEDDOLLARPLACEHOLDER/g, "$");
+
+ // Restore code blocks
+ processed = processed.replace(
+ new RegExp(`${CODE_PLACEHOLDER_PREFIX}(\\d+)END`, "g"),
+ (_, index) => codeBlocks[parseInt(index)],
+ );
+
+ // Clean up any remaining stray backslashes from unrecognized commands
+ processed = processed.replace(/\\(?=[a-zA-Z])/g, ""); // Remove \ before letters (unrecognized commands)
+
+ return processed;
+ }
+
+ /**
+ * Render math expressions with KaTeX and restore HTML placeholders
+ */
+ function renderMath(html: string): string {
+ // Replace all math placeholders with rendered KaTeX
+ for (const [placeholder, { content, displayMode }] of mathExpressions) {
+ const escapedPlaceholder = placeholder.replace(
+ /[.*+?^${}()|[\]\\]/g,
+ "\\$&",
+ );
+ const regex = new RegExp(escapedPlaceholder, "g");
+
+ html = html.replace(regex, () => {
+ try {
+ const rendered = katex.renderToString(content, {
+ displayMode,
+ throwOnError: false,
+ output: "html",
+ });
+
+ if (displayMode) {
+ return `
<div class="math-display-wrapper">
<div class="math-display-header">
<span class="math-label">LaTeX</span>
@@ -316,655 +386,669 @@
</div>
</div>
`;
- } else {
- return `<span class="math-inline">${rendered}</span>`;
- }
- } catch {
- const display = displayMode ? `$$${content}$$` : `$${content}$`;
- return `<span class="math-error"><span class="math-error-icon">⚠</span> ${display}</span>`;
- }
- });
- }
-
- // Restore HTML placeholders (for \textbf, \emph, etc.)
- for (const [placeholder, htmlContent] of htmlSnippets) {
- const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- const regex = new RegExp(escapedPlaceholder, 'g');
- html = html.replace(regex, htmlContent);
- }
-
- return html;
- }
-
- function processMarkdown(text: string): string {
- try {
- // Preprocess LaTeX notation
- const preprocessed = preprocessLaTeX(text);
- // Parse markdown
- let html = marked.parse(preprocessed) as string;
- // Render math expressions
- html = renderMath(html);
- return html;
- } catch (error) {
- console.error('Markdown processing error:', error);
- return text.replace(/\n/g, '<br>');
- }
- }
-
- async function handleCopyClick(event: Event) {
- const target = event.currentTarget as HTMLButtonElement;
- const encodedCode = target.getAttribute('data-code');
- if (!encodedCode) return;
-
- const code = decodeURIComponent(encodedCode);
-
- try {
- await navigator.clipboard.writeText(code);
- // Show copied feedback
- const originalHtml = target.innerHTML;
- target.innerHTML = `
+ } else {
+ return `<span class="math-inline">${rendered}</span>`;
+ }
+ } catch {
+ const display = displayMode ? `$$${content}$$` : `$${content}$`;
+ return `<span class="math-error"><span class="math-error-icon">⚠</span> ${display}</span>`;
+ }
+ });
+ }
+
+ // Restore HTML placeholders (for \textbf, \emph, etc.)
+ for (const [placeholder, htmlContent] of htmlSnippets) {
+ const escapedPlaceholder = placeholder.replace(
+ /[.*+?^${}()|[\]\\]/g,
+ "\\$&",
+ );
+ const regex = new RegExp(escapedPlaceholder, "g");
+ html = html.replace(regex, htmlContent);
+ }
+
+ return html;
+ }
+
+ function processMarkdown(text: string): string {
+ try {
+ // Preprocess LaTeX notation
+ const preprocessed = preprocessLaTeX(text);
+ // Parse markdown
+ let html = marked.parse(preprocessed) as string;
+ // Render math expressions
+ html = renderMath(html);
+ return html;
+ } catch (error) {
+ console.error("Markdown processing error:", error);
+ return text.replace(/\n/g, "<br>");
+ }
+ }
+
+ async function handleCopyClick(event: Event) {
+ const target = event.currentTarget as HTMLButtonElement;
+ const encodedCode = target.getAttribute("data-code");
+ if (!encodedCode) return;
+
+ const code = decodeURIComponent(encodedCode);
+
+ try {
+ await navigator.clipboard.writeText(code);
+ // Show copied feedback
+ const originalHtml = target.innerHTML;
+ target.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6L9 17l-5-5"/>
</svg>
`;
- target.classList.add('copied');
- setTimeout(() => {
- target.innerHTML = originalHtml;
- target.classList.remove('copied');
- }, 2000);
- } catch (error) {
- console.error('Failed to copy:', error);
- }
- }
-
- async function handleMathCopyClick(event: Event) {
- const target = event.currentTarget as HTMLButtonElement;
- const encodedSource = target.getAttribute('data-math-source');
- if (!encodedSource) return;
-
- const source = decodeURIComponent(encodedSource);
-
- try {
- await navigator.clipboard.writeText(source);
- // Show copied feedback
- const originalHtml = target.innerHTML;
- target.innerHTML = `
+ target.classList.add("copied");
+ setTimeout(() => {
+ target.innerHTML = originalHtml;
+ target.classList.remove("copied");
+ }, 2000);
+ } catch (error) {
+ console.error("Failed to copy:", error);
+ }
+ }
+
+ async function handleMathCopyClick(event: Event) {
+ const target = event.currentTarget as HTMLButtonElement;
+ const encodedSource = target.getAttribute("data-math-source");
+ if (!encodedSource) return;
+
+ const source = decodeURIComponent(encodedSource);
+
+ try {
+ await navigator.clipboard.writeText(source);
+ // Show copied feedback
+ const originalHtml = target.innerHTML;
+ target.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6L9 17l-5-5"/>
</svg>
`;
- target.classList.add('copied');
- setTimeout(() => {
- target.innerHTML = originalHtml;
- target.classList.remove('copied');
- }, 2000);
- } catch (error) {
- console.error('Failed to copy math:', error);
- }
- }
-
- function setupCopyButtons() {
- if (!containerRef || !browser) return;
-
- const codeButtons = containerRef.querySelectorAll<HTMLButtonElement>('.copy-code-btn');
- for (const button of codeButtons) {
- if (button.dataset.listenerBound !== 'true') {
- button.dataset.listenerBound = 'true';
- button.addEventListener('click', handleCopyClick);
- }
- }
-
- const mathButtons = containerRef.querySelectorAll<HTMLButtonElement>('.copy-math-btn');
- for (const button of mathButtons) {
- if (button.dataset.listenerBound !== 'true') {
- button.dataset.listenerBound = 'true';
- button.addEventListener('click', handleMathCopyClick);
- }
- }
- }
-
- $effect(() => {
- if (content) {
- processedHtml = processMarkdown(content);
- } else {
- processedHtml = '';
- }
- });
-
- $effect(() => {
- if (containerRef && processedHtml) {
- setupCopyButtons();
- }
- });
+ target.classList.add("copied");
+ setTimeout(() => {
+ target.innerHTML = originalHtml;
+ target.classList.remove("copied");
+ }, 2000);
+ } catch (error) {
+ console.error("Failed to copy math:", error);
+ }
+ }
+
+ function setupCopyButtons() {
+ if (!containerRef || !browser) return;
+
+ const codeButtons =
+ containerRef.querySelectorAll<HTMLButtonElement>(".copy-code-btn");
+ for (const button of codeButtons) {
+ if (button.dataset.listenerBound !== "true") {
+ button.dataset.listenerBound = "true";
+ button.addEventListener("click", handleCopyClick);
+ }
+ }
+
+ const mathButtons =
+ containerRef.querySelectorAll<HTMLButtonElement>(".copy-math-btn");
+ for (const button of mathButtons) {
+ if (button.dataset.listenerBound !== "true") {
+ button.dataset.listenerBound = "true";
+ button.addEventListener("click", handleMathCopyClick);
+ }
+ }
+ }
+
+ $effect(() => {
+ if (content) {
+ processedHtml = processMarkdown(content);
+ } else {
+ processedHtml = "";
+ }
+ });
+
+ $effect(() => {
+ if (containerRef && processedHtml) {
+ setupCopyButtons();
+ }
+ });
</script>
<div bind:this={containerRef} class="markdown-content {className}">
- {@html processedHtml}
+ {@html processedHtml}
</div>
<style>
- .markdown-content {
- line-height: 1.6;
- }
-
- /* Paragraphs */
- .markdown-content :global(p) {
- margin-bottom: 1rem;
- }
-
- .markdown-content :global(p:last-child) {
- margin-bottom: 0;
- }
-
- /* Headers */
- .markdown-content :global(h1) {
- font-size: 1.5rem;
- font-weight: 700;
- margin: 1.5rem 0 0.75rem 0;
- color: var(--exo-yellow, #ffd700);
- }
-
- .markdown-content :global(h2) {
- font-size: 1.25rem;
- font-weight: 600;
- margin: 1.25rem 0 0.5rem 0;
- color: var(--exo-yellow, #ffd700);
- }
-
- .markdown-content :global(h3) {
- font-size: 1.125rem;
- font-weight: 600;
- margin: 1rem 0 0.5rem 0;
- }
-
- .markdown-content :global(h4),
- .markdown-content :global(h5),
- .markdown-content :global(h6) {
- font-size: 1rem;
- font-weight: 600;
- margin: 0.75rem 0 0.25rem 0;
- }
-
- /* Bold and italic */
- .markdown-content :global(strong) {
- font-weight: 600;
- }
-
- .markdown-content :global(em) {
- font-style: italic;
- }
-
- /* Inline code */
- .markdown-content :global(.inline-code) {
- background: rgba(255, 215, 0, 0.1);
- color: var(--exo-yellow, #ffd700);
- padding: 0.125rem 0.375rem;
- border-radius: 0.25rem;
- font-family: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, monospace;
- font-size: 0.875em;
- }
-
- /* Links */
- .markdown-content :global(a) {
- color: var(--exo-yellow, #ffd700);
- text-decoration: underline;
- text-underline-offset: 2px;
- }
-
- .markdown-content :global(a:hover) {
- opacity: 0.8;
- }
-
- /* Lists */
- .markdown-content :global(ul) {
- list-style-type: disc;
- margin-left: 1.5rem;
- margin-bottom: 1rem;
- }
-
- .markdown-content :global(ol) {
- list-style-type: decimal;
- margin-left: 1.5rem;
- margin-bottom: 1rem;
- }
-
- .markdown-content :global(li) {
- margin-bottom: 0.25rem;
- }
-
- .markdown-content :global(li::marker) {
- color: var(--exo-light-gray, #9ca3af);
- }
-
- /* Blockquotes */
- .markdown-content :global(blockquote) {
- border-left: 3px solid var(--exo-yellow, #ffd700);
- padding: 0.5rem 1rem;
- margin: 1rem 0;
- background: rgba(255, 215, 0, 0.05);
- border-radius: 0 0.25rem 0.25rem 0;
- }
-
- /* Tables */
- .markdown-content :global(table) {
- width: 100%;
- margin: 1rem 0;
- border-collapse: collapse;
- font-size: 0.875rem;
- }
-
- .markdown-content :global(th) {
- background: rgba(255, 215, 0, 0.1);
- border: 1px solid rgba(255, 215, 0, 0.2);
- padding: 0.5rem;
- text-align: left;
- font-weight: 600;
- }
-
- .markdown-content :global(td) {
- border: 1px solid rgba(255, 255, 255, 0.1);
- padding: 0.5rem;
- }
-
- /* Horizontal rule */
- .markdown-content :global(hr) {
- border: none;
- border-top: 1px solid rgba(255, 255, 255, 0.1);
- margin: 1.5rem 0;
- }
-
- /* Code block wrapper */
- .markdown-content :global(.code-block-wrapper) {
- margin: 1rem 0;
- border-radius: 0.5rem;
- overflow: hidden;
- border: 1px solid rgba(255, 215, 0, 0.2);
- background: rgba(0, 0, 0, 0.4);
- }
-
- .markdown-content :global(.code-block-header) {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 0.5rem 0.75rem;
- background: rgba(255, 215, 0, 0.05);
- border-bottom: 1px solid rgba(255, 215, 0, 0.1);
- }
-
- .markdown-content :global(.code-language) {
- color: var(--exo-yellow, #ffd700);
- font-size: 0.7rem;
- font-weight: 500;
- text-transform: uppercase;
- letter-spacing: 0.1em;
- font-family: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, monospace;
- }
-
- .markdown-content :global(.copy-code-btn) {
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0.25rem;
- background: transparent;
- border: none;
- color: var(--exo-light-gray, #9ca3af);
- cursor: pointer;
- transition: color 0.2s;
- border-radius: 0.25rem;
- }
-
- .markdown-content :global(.copy-code-btn:hover) {
- color: var(--exo-yellow, #ffd700);
- }
-
- .markdown-content :global(.copy-code-btn.copied) {
- color: #22c55e;
- }
-
- .markdown-content :global(.code-block-wrapper pre) {
- margin: 0;
- padding: 1rem;
- overflow-x: auto;
- background: transparent;
- }
-
- .markdown-content :global(.code-block-wrapper code) {
- font-family: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, monospace;
- font-size: 0.8125rem;
- line-height: 1.5;
- background: transparent;
- }
-
- /* Syntax highlighting - dark theme matching EXO style */
- .markdown-content :global(.hljs) {
- color: #e5e7eb;
- }
-
- .markdown-content :global(.hljs-keyword),
- .markdown-content :global(.hljs-selector-tag),
- .markdown-content :global(.hljs-literal),
- .markdown-content :global(.hljs-section),
- .markdown-content :global(.hljs-link) {
- color: #c084fc;
- }
-
- .markdown-content :global(.hljs-string),
- .markdown-content :global(.hljs-title),
- .markdown-content :global(.hljs-name),
- .markdown-content :global(.hljs-type),
- .markdown-content :global(.hljs-attribute),
- .markdown-content :global(.hljs-symbol),
- .markdown-content :global(.hljs-bullet),
- .markdown-content :global(.hljs-addition),
- .markdown-content :global(.hljs-variable),
- .markdown-content :global(.hljs-template-tag),
- .markdown-content :global(.hljs-template-variable) {
- color: #fbbf24;
- }
-
- .markdown-content :global(.hljs-comment),
- .markdown-content :global(.hljs-quote),
- .markdown-content :global(.hljs-deletion),
- .markdown-content :global(.hljs-meta) {
- color: #6b7280;
- }
-
- .markdown-content :global(.hljs-number),
- .markdown-content :global(.hljs-regexp),
- .markdown-content :global(.hljs-literal),
- .markdown-content :global(.hljs-built_in) {
- color: #34d399;
- }
-
- .markdown-content :global(.hljs-function),
- .markdown-content :global(.hljs-class .hljs-title) {
- color: #60a5fa;
- }
-
- /* KaTeX math styling - Base */
- .markdown-content :global(.katex) {
- font-size: 1.1em;
- color: oklch(0.9 0 0);
- }
-
- /* Display math container wrapper */
- .markdown-content :global(.math-display-wrapper) {
- margin: 1rem 0;
- border-radius: 0.5rem;
- overflow: hidden;
- border: 1px solid rgba(255, 215, 0, 0.15);
- background: rgba(0, 0, 0, 0.3);
- transition: border-color 0.2s ease, box-shadow 0.2s ease;
- }
-
- .markdown-content :global(.math-display-wrapper:hover) {
- border-color: rgba(255, 215, 0, 0.25);
- box-shadow: 0 0 12px rgba(255, 215, 0, 0.08);
- }
-
- /* Display math header - hidden by default, slides in on hover */
- .markdown-content :global(.math-display-header) {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 0.375rem 0.75rem;
- background: rgba(255, 215, 0, 0.03);
- border-bottom: 1px solid rgba(255, 215, 0, 0.08);
- opacity: 0;
- max-height: 0;
- padding-top: 0;
- padding-bottom: 0;
- overflow: hidden;
- transition:
- opacity 0.2s ease,
- max-height 0.2s ease,
- padding 0.2s ease;
- }
-
- .markdown-content :global(.math-display-wrapper:hover .math-display-header) {
- opacity: 1;
- max-height: 2.5rem;
- padding: 0.375rem 0.75rem;
- }
-
- .markdown-content :global(.math-label) {
- color: rgba(255, 215, 0, 0.7);
- font-size: 0.65rem;
- font-weight: 500;
- text-transform: uppercase;
- letter-spacing: 0.1em;
- font-family: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, monospace;
- }
-
- .markdown-content :global(.copy-math-btn) {
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0.25rem;
- background: transparent;
- border: none;
- color: var(--exo-light-gray, #9ca3af);
- cursor: pointer;
- transition: color 0.2s;
- border-radius: 0.25rem;
- opacity: 0;
- transition:
- color 0.2s,
- opacity 0.15s ease;
- }
-
- .markdown-content :global(.math-display-wrapper:hover .copy-math-btn) {
- opacity: 1;
- }
-
- .markdown-content :global(.copy-math-btn:hover) {
- color: var(--exo-yellow, #ffd700);
- }
-
- .markdown-content :global(.copy-math-btn.copied) {
- color: #22c55e;
- }
-
- /* Display math content area */
- .markdown-content :global(.math-display-content) {
- padding: 1rem 1.25rem;
- overflow-x: auto;
- overflow-y: hidden;
- }
-
- /* Custom scrollbar for math overflow */
- .markdown-content :global(.math-display-content::-webkit-scrollbar) {
- height: 6px;
- }
-
- .markdown-content :global(.math-display-content::-webkit-scrollbar-track) {
- background: rgba(255, 255, 255, 0.05);
- border-radius: 3px;
- }
-
- .markdown-content :global(.math-display-content::-webkit-scrollbar-thumb) {
- background: rgba(255, 215, 0, 0.2);
- border-radius: 3px;
- }
-
- .markdown-content :global(.math-display-content::-webkit-scrollbar-thumb:hover) {
- background: rgba(255, 215, 0, 0.35);
- }
-
- .markdown-content :global(.math-display-content .katex-display) {
- margin: 0;
- padding: 0;
- }
-
- .markdown-content :global(.math-display-content .katex-display > .katex) {
- text-align: center;
- }
-
- /* Inline math wrapper */
- .markdown-content :global(.math-inline) {
- display: inline;
- padding: 0 0.125rem;
- border-radius: 0.25rem;
- transition: background-color 0.15s ease;
- }
-
- .markdown-content :global(.math-inline:hover) {
- background: rgba(255, 215, 0, 0.05);
- }
-
- /* Dark theme KaTeX overrides */
- .markdown-content :global(.katex .mord),
- .markdown-content :global(.katex .minner),
- .markdown-content :global(.katex .mop),
- .markdown-content :global(.katex .mbin),
- .markdown-content :global(.katex .mrel),
- .markdown-content :global(.katex .mpunct) {
- color: oklch(0.9 0 0);
- }
-
- /* Fraction lines and rules */
- .markdown-content :global(.katex .frac-line),
- .markdown-content :global(.katex .overline-line),
- .markdown-content :global(.katex .underline-line),
- .markdown-content :global(.katex .hline),
- .markdown-content :global(.katex .rule) {
- border-color: oklch(0.85 0 0) !important;
- background: oklch(0.85 0 0);
- }
-
- /* Square roots and SVG elements */
- .markdown-content :global(.katex .sqrt-line) {
- border-color: oklch(0.85 0 0) !important;
- }
-
- .markdown-content :global(.katex svg) {
- fill: oklch(0.85 0 0);
- stroke: oklch(0.85 0 0);
- }
-
- .markdown-content :global(.katex svg path) {
- stroke: oklch(0.85 0 0);
- }
-
- /* Delimiters (parentheses, brackets, braces) */
- .markdown-content :global(.katex .delimsizing),
- .markdown-content :global(.katex .delim-size1),
- .markdown-content :global(.katex .delim-size2),
- .markdown-content :global(.katex .delim-size3),
- .markdown-content :global(.katex .delim-size4),
- .markdown-content :global(.katex .mopen),
- .markdown-content :global(.katex .mclose) {
- color: oklch(0.75 0 0);
- }
-
- /* Math error styling */
- .markdown-content :global(.math-error) {
- display: inline-flex;
- align-items: center;
- gap: 0.375rem;
- color: #f87171;
- font-family: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, monospace;
- font-size: 0.875em;
- background: rgba(248, 113, 113, 0.1);
- padding: 0.25rem 0.5rem;
- border-radius: 0.25rem;
- border: 1px solid rgba(248, 113, 113, 0.2);
- }
-
- .markdown-content :global(.math-error-icon) {
- font-size: 0.875em;
- opacity: 0.9;
- }
-
- /* LaTeX proof environment */
- .markdown-content :global(.latex-proof) {
- margin: 1rem 0;
- padding: 1rem 1.25rem;
- background: rgba(255, 255, 255, 0.02);
- border-left: 3px solid rgba(255, 215, 0, 0.4);
- border-radius: 0 0.375rem 0.375rem 0;
- }
-
- .markdown-content :global(.latex-proof-header) {
- font-weight: 600;
- font-style: italic;
- color: oklch(0.85 0 0);
- margin-bottom: 0.5rem;
- }
-
- .markdown-content :global(.latex-proof-header::after) {
- content: '.';
- }
-
- .markdown-content :global(.latex-proof-content) {
- color: oklch(0.9 0 0);
- }
-
- .markdown-content :global(.latex-proof-content p:last-child) {
- margin-bottom: 0;
- }
-
- /* QED symbol at end of proof */
- .markdown-content :global(.latex-proof-content::after) {
- content: '∎';
- display: block;
- text-align: right;
- color: oklch(0.7 0 0);
- margin-top: 0.5rem;
- }
-
- /* LaTeX theorem-like environments */
- .markdown-content :global(.latex-theorem) {
- margin: 1rem 0;
- padding: 1rem 1.25rem;
- background: rgba(255, 215, 0, 0.03);
- border: 1px solid rgba(255, 215, 0, 0.15);
- border-radius: 0.375rem;
- }
-
- .markdown-content :global(.latex-theorem-header) {
- font-weight: 700;
- color: var(--exo-yellow, #ffd700);
- margin-bottom: 0.5rem;
- }
-
- .markdown-content :global(.latex-theorem-header::after) {
- content: '.';
- }
-
- .markdown-content :global(.latex-theorem-content) {
- color: oklch(0.9 0 0);
- font-style: italic;
- }
-
- .markdown-content :global(.latex-theorem-content p:last-child) {
- margin-bottom: 0;
- }
-
- /* LaTeX diagram/figure placeholder */
- .markdown-content :global(.latex-diagram-placeholder) {
- display: flex;
- align-items: center;
- justify-content: center;
- gap: 0.5rem;
- margin: 1rem 0;
- padding: 1.5rem 2rem;
- background: rgba(255, 255, 255, 0.02);
- border: 1px dashed rgba(255, 215, 0, 0.25);
- border-radius: 0.5rem;
- color: rgba(255, 215, 0, 0.6);
- font-size: 0.875rem;
- }
-
- .markdown-content :global(.latex-diagram-icon) {
- font-size: 1.25rem;
- opacity: 0.8;
- }
-
- .markdown-content :global(.latex-diagram-text) {
- font-family: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, monospace;
- font-size: 0.75rem;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- }
+ .markdown-content {
+ line-height: 1.6;
+ }
+
+ /* Paragraphs */
+ .markdown-content :global(p) {
+ margin-bottom: 1rem;
+ }
+
+ .markdown-content :global(p:last-child) {
+ margin-bottom: 0;
+ }
+
+ /* Headers */
+ .markdown-content :global(h1) {
+ font-size: 1.5rem;
+ font-weight: 700;
+ margin: 1.5rem 0 0.75rem 0;
+ color: var(--exo-yellow, #ffd700);
+ }
+
+ .markdown-content :global(h2) {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin: 1.25rem 0 0.5rem 0;
+ color: var(--exo-yellow, #ffd700);
+ }
+
+ .markdown-content :global(h3) {
+ font-size: 1.125rem;
+ font-weight: 600;
+ margin: 1rem 0 0.5rem 0;
+ }
+
+ .markdown-content :global(h4),
+ .markdown-content :global(h5),
+ .markdown-content :global(h6) {
+ font-size: 1rem;
+ font-weight: 600;
+ margin: 0.75rem 0 0.25rem 0;
+ }
+
+ /* Bold and italic */
+ .markdown-content :global(strong) {
+ font-weight: 600;
+ }
+
+ .markdown-content :global(em) {
+ font-style: italic;
+ }
+
+ /* Inline code */
+ .markdown-content :global(.inline-code) {
+ background: rgba(255, 215, 0, 0.1);
+ color: var(--exo-yellow, #ffd700);
+ padding: 0.125rem 0.375rem;
+ border-radius: 0.25rem;
+ font-family:
+ ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, monospace;
+ font-size: 0.875em;
+ }
+
+ /* Links */
+ .markdown-content :global(a) {
+ color: var(--exo-yellow, #ffd700);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ }
+
+ .markdown-content :global(a:hover) {
+ opacity: 0.8;
+ }
+
+ /* Lists */
+ .markdown-content :global(ul) {
+ list-style-type: disc;
+ margin-left: 1.5rem;
+ margin-bottom: 1rem;
+ }
+
+ .markdown-content :global(ol) {
+ list-style-type: decimal;
+ margin-left: 1.5rem;
+ margin-bottom: 1rem;
+ }
+
+ .markdown-content :global(li) {
+ margin-bottom: 0.25rem;
+ }
+
+ .markdown-content :global(li::marker) {
+ color: var(--exo-light-gray, #9ca3af);
+ }
+
+ /* Blockquotes */
+ .markdown-content :global(blockquote) {
+ border-left: 3px solid var(--exo-yellow, #ffd700);
+ padding: 0.5rem 1rem;
+ margin: 1rem 0;
+ background: rgba(255, 215, 0, 0.05);
+ border-radius: 0 0.25rem 0.25rem 0;
+ }
+
+ /* Tables */
+ .markdown-content :global(table) {
+ width: 100%;
+ margin: 1rem 0;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+ }
+
+ .markdown-content :global(th) {
+ background: rgba(255, 215, 0, 0.1);
+ border: 1px solid rgba(255, 215, 0, 0.2);
+ padding: 0.5rem;
+ text-align: left;
+ font-weight: 600;
+ }
+
+ .markdown-content :global(td) {
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ padding: 0.5rem;
+ }
+
+ /* Horizontal rule */
+ .markdown-content :global(hr) {
+ border: none;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ margin: 1.5rem 0;
+ }
+
+ /* Code block wrapper */
+ .markdown-content :global(.code-block-wrapper) {
+ margin: 1rem 0;
+ border-radius: 0.5rem;
+ overflow: hidden;
+ border: 1px solid rgba(255, 215, 0, 0.2);
+ background: rgba(0, 0, 0, 0.4);
+ }
+
+ .markdown-content :global(.code-block-header) {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.5rem 0.75rem;
+ background: rgba(255, 215, 0, 0.05);
+ border-bottom: 1px solid rgba(255, 215, 0, 0.1);
+ }
+
+ .markdown-content :global(.code-language) {
+ color: var(--exo-yellow, #ffd700);
+ font-size: 0.7rem;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ font-family:
+ ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, monospace;
+ }
+
+ .markdown-content :global(.copy-code-btn) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.25rem;
+ background: transparent;
+ border: none;
+ color: var(--exo-light-gray, #9ca3af);
+ cursor: pointer;
+ transition: color 0.2s;
+ border-radius: 0.25rem;
+ }
+
+ .markdown-content :global(.copy-code-btn:hover) {
+ color: var(--exo-yellow, #ffd700);
+ }
+
+ .markdown-content :global(.copy-code-btn.copied) {
+ color: #22c55e;
+ }
+
+ .markdown-content :global(.code-block-wrapper pre) {
+ margin: 0;
+ padding: 1rem;
+ overflow-x: auto;
+ background: transparent;
+ }
+
+ .markdown-content :global(.code-block-wrapper code) {
+ font-family:
+ ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, monospace;
+ font-size: 0.8125rem;
+ line-height: 1.5;
+ background: transparent;
+ }
+
+ /* Syntax highlighting - dark theme matching EXO style */
+ .markdown-content :global(.hljs) {
+ color: #e5e7eb;
+ }
+
+ .markdown-content :global(.hljs-keyword),
+ .markdown-content :global(.hljs-selector-tag),
+ .markdown-content :global(.hljs-literal),
+ .markdown-content :global(.hljs-section),
+ .markdown-content :global(.hljs-link) {
+ color: #c084fc;
+ }
+
+ .markdown-content :global(.hljs-string),
+ .markdown-content :global(.hljs-title),
+ .markdown-content :global(.hljs-name),
+ .markdown-content :global(.hljs-type),
+ .markdown-content :global(.hljs-attribute),
+ .markdown-content :global(.hljs-symbol),
+ .markdown-content :global(.hljs-bullet),
+ .markdown-content :global(.hljs-addition),
+ .markdown-content :global(.hljs-variable),
+ .markdown-content :global(.hljs-template-tag),
+ .markdown-content :global(.hljs-template-variable) {
+ color: #fbbf24;
+ }
+
+ .markdown-content :global(.hljs-comment),
+ .markdown-content :global(.hljs-quote),
+ .markdown-content :global(.hljs-deletion),
+ .markdown-content :global(.hljs-meta) {
+ color: #6b7280;
+ }
+
+ .markdown-content :global(.hljs-number),
+ .markdown-content :global(.hljs-regexp),
+ .markdown-content :global(.hljs-literal),
+ .markdown-content :global(.hljs-built_in) {
+ color: #34d399;
+ }
+
+ .markdown-content :global(.hljs-function),
+ .markdown-content :global(.hljs-class .hljs-title) {
+ color: #60a5fa;
+ }
+
+ /* KaTeX math styling - Base */
+ .markdown-content :global(.katex) {
+ font-size: 1.1em;
+ color: oklch(0.9 0 0);
+ }
+
+ /* Display math container wrapper */
+ .markdown-content :global(.math-display-wrapper) {
+ margin: 1rem 0;
+ border-radius: 0.5rem;
+ overflow: hidden;
+ border: 1px solid rgba(255, 215, 0, 0.15);
+ background: rgba(0, 0, 0, 0.3);
+ transition:
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
+ }
+
+ .markdown-content :global(.math-display-wrapper:hover) {
+ border-color: rgba(255, 215, 0, 0.25);
+ box-shadow: 0 0 12px rgba(255, 215, 0, 0.08);
+ }
+
+ /* Display math header - hidden by default, slides in on hover */
+ .markdown-content :global(.math-display-header) {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.375rem 0.75rem;
+ background: rgba(255, 215, 0, 0.03);
+ border-bottom: 1px solid rgba(255, 215, 0, 0.08);
+ opacity: 0;
+ max-height: 0;
+ padding-top: 0;
+ padding-bottom: 0;
+ overflow: hidden;
+ transition:
+ opacity 0.2s ease,
+ max-height 0.2s ease,
+ padding 0.2s ease;
+ }
+
+ .markdown-content :global(.math-display-wrapper:hover .math-display-header) {
+ opacity: 1;
+ max-height: 2.5rem;
+ padding: 0.375rem 0.75rem;
+ }
+
+ .markdown-content :global(.math-label) {
+ color: rgba(255, 215, 0, 0.7);
+ font-size: 0.65rem;
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ font-family:
+ ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, monospace;
+ }
+
+ .markdown-content :global(.copy-math-btn) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.25rem;
+ background: transparent;
+ border: none;
+ color: var(--exo-light-gray, #9ca3af);
+ cursor: pointer;
+ transition: color 0.2s;
+ border-radius: 0.25rem;
+ opacity: 0;
+ transition:
+ color 0.2s,
+ opacity 0.15s ease;
+ }
+
+ .markdown-content :global(.math-display-wrapper:hover .copy-math-btn) {
+ opacity: 1;
+ }
+
+ .markdown-content :global(.copy-math-btn:hover) {
+ color: var(--exo-yellow, #ffd700);
+ }
+
+ .markdown-content :global(.copy-math-btn.copied) {
+ color: #22c55e;
+ }
+
+ /* Display math content area */
+ .markdown-content :global(.math-display-content) {
+ padding: 1rem 1.25rem;
+ overflow-x: auto;
+ overflow-y: hidden;
+ }
+
+ /* Custom scrollbar for math overflow */
+ .markdown-content :global(.math-display-content::-webkit-scrollbar) {
+ height: 6px;
+ }
+
+ .markdown-content :global(.math-display-content::-webkit-scrollbar-track) {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 3px;
+ }
+
+ .markdown-content :global(.math-display-content::-webkit-scrollbar-thumb) {
+ background: rgba(255, 215, 0, 0.2);
+ border-radius: 3px;
+ }
+
+ .markdown-content
+ :global(.math-display-content::-webkit-scrollbar-thumb:hover) {
+ background: rgba(255, 215, 0, 0.35);
+ }
+
+ .markdown-content :global(.math-display-content .katex-display) {
+ margin: 0;
+ padding: 0;
+ }
+
+ .markdown-content :global(.math-display-content .katex-display > .katex) {
+ text-align: center;
+ }
+
+ /* Inline math wrapper */
+ .markdown-content :global(.math-inline) {
+ display: inline;
+ padding: 0 0.125rem;
+ border-radius: 0.25rem;
+ transition: background-color 0.15s ease;
+ }
+
+ .markdown-content :global(.math-inline:hover) {
+ background: rgba(255, 215, 0, 0.05);
+ }
+
+ /* Dark theme KaTeX overrides */
+ .markdown-content :global(.katex .mord),
+ .markdown-content :global(.katex .minner),
+ .markdown-content :global(.katex .mop),
+ .markdown-content :global(.katex .mbin),
+ .markdown-content :global(.katex .mrel),
+ .markdown-content :global(.katex .mpunct) {
+ color: oklch(0.9 0 0);
+ }
+
+ /* Fraction lines and rules */
+ .markdown-content :global(.katex .frac-line),
+ .markdown-content :global(.katex .overline-line),
+ .markdown-content :global(.katex .underline-line),
+ .markdown-content :global(.katex .hline),
+ .markdown-content :global(.katex .rule) {
+ border-color: oklch(0.85 0 0) !important;
+ background: oklch(0.85 0 0);
+ }
+
+ /* Square roots and SVG elements */
+ .markdown-content :global(.katex .sqrt-line) {
+ border-color: oklch(0.85 0 0) !important;
+ }
+
+ .markdown-content :global(.katex svg) {
+ fill: oklch(0.85 0 0);
+ stroke: oklch(0.85 0 0);
+ }
+
+ .markdown-content :global(.katex svg path) {
+ stroke: oklch(0.85 0 0);
+ }
+
+ /* Delimiters (parentheses, brackets, braces) */
+ .markdown-content :global(.katex .delimsizing),
+ .markdown-content :global(.katex .delim-size1),
+ .markdown-content :global(.katex .delim-size2),
+ .markdown-content :global(.katex .delim-size3),
+ .markdown-content :global(.katex .delim-size4),
+ .markdown-content :global(.katex .mopen),
+ .markdown-content :global(.katex .mclose) {
+ color: oklch(0.75 0 0);
+ }
+
+ /* Math error styling */
+ .markdown-content :global(.math-error) {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ color: #f87171;
+ font-family:
+ ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, monospace;
+ font-size: 0.875em;
+ background: rgba(248, 113, 113, 0.1);
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ border: 1px solid rgba(248, 113, 113, 0.2);
+ }
+
+ .markdown-content :global(.math-error-icon) {
+ font-size: 0.875em;
+ opacity: 0.9;
+ }
+
+ /* LaTeX proof environment */
+ .markdown-content :global(.latex-proof) {
+ margin: 1rem 0;
+ padding: 1rem 1.25rem;
+ background: rgba(255, 255, 255, 0.02);
+ border-left: 3px solid rgba(255, 215, 0, 0.4);
+ border-radius: 0 0.375rem 0.375rem 0;
+ }
+
+ .markdown-content :global(.latex-proof-header) {
+ font-weight: 600;
+ font-style: italic;
+ color: oklch(0.85 0 0);
+ margin-bottom: 0.5rem;
+ }
+
+ .markdown-content :global(.latex-proof-header::after) {
+ content: ".";
+ }
+
+ .markdown-content :global(.latex-proof-content) {
+ color: oklch(0.9 0 0);
+ }
+
+ .markdown-content :global(.latex-proof-content p:last-child) {
+ margin-bottom: 0;
+ }
+
+ /* QED symbol at end of proof */
+ .markdown-content :global(.latex-proof-content::after) {
+ content: "∎";
+ display: block;
+ text-align: right;
+ color: oklch(0.7 0 0);
+ margin-top: 0.5rem;
+ }
+
+ /* LaTeX theorem-like environments */
+ .markdown-content :global(.latex-theorem) {
+ margin: 1rem 0;
+ padding: 1rem 1.25rem;
+ background: rgba(255, 215, 0, 0.03);
+ border: 1px solid rgba(255, 215, 0, 0.15);
+ border-radius: 0.375rem;
+ }
+
+ .markdown-content :global(.latex-theorem-header) {
+ font-weight: 700;
+ color: var(--exo-yellow, #ffd700);
+ margin-bottom: 0.5rem;
+ }
+
+ .markdown-content :global(.latex-theorem-header::after) {
+ content: ".";
+ }
+
+ .markdown-content :global(.latex-theorem-content) {
+ color: oklch(0.9 0 0);
+ font-style: italic;
+ }
+
+ .markdown-content :global(.latex-theorem-content p:last-child) {
+ margin-bottom: 0;
+ }
+
+ /* LaTeX diagram/figure placeholder */
+ .markdown-content :global(.latex-diagram-placeholder) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ margin: 1rem 0;
+ padding: 1.5rem 2rem;
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px dashed rgba(255, 215, 0, 0.25);
+ border-radius: 0.5rem;
+ color: rgba(255, 215, 0, 0.6);
+ font-size: 0.875rem;
+ }
+
+ .markdown-content :global(.latex-diagram-icon) {
+ font-size: 1.25rem;
+ opacity: 0.8;
+ }
+
+ .markdown-content :global(.latex-diagram-text) {
+ font-family:
+ ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, monospace;
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ }
</style>
diff --git a/dashboard/src/lib/components/ModelCard.svelte b/dashboard/src/lib/components/ModelCard.svelte
index dca89819..6fef645f 100644
--- a/dashboard/src/lib/components/ModelCard.svelte
+++ b/dashboard/src/lib/components/ModelCard.svelte
@@ -1,719 +1,1018 @@
<script lang="ts">
- import type { DownloadProgress, NodeInfo, PlacementPreview, TopologyEdge } from '$lib/stores/app.svelte';
- import { debugMode, topologyData } from '$lib/stores/app.svelte';
-
-interface Props {
- model: { id: string; name?: string; storage_size_megabytes?: number };
- isLaunching?: boolean;
- downloadStatus?: {
- isDownloading: boolean;
- progress: DownloadProgress | null;
- perNode?: Array<{
- nodeId: string;
- nodeName: string;
- progress: DownloadProgress;
- }>;
- } | null;
- nodes?: Record<string, NodeInfo>;
- sharding?: 'Pipeline' | 'Tensor';
- runtime?: 'MlxRing' | 'MlxIbv' | 'MlxJaccl';
- onLaunch?: () => void;
- tags?: string[];
- apiPreview?: PlacementPreview | null;
- modelIdOverride?: string | null;
- }
-
- let {
- model,
- isLaunching = false,
- downloadStatus = null,
- nodes = {},
- sharding = 'Pipeline',
- runtime = 'MlxRing',
- onLaunch,
- tags = [],
- apiPreview = null,
- modelIdOverride = null
- }: Props = $props();
-
- // Estimate memory requirements from model name
- // 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
- }
-
- function formatBytes(bytes: number, decimals = 1): 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) + '/s';
- }
-
- 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`;
- }
-
- const isDownloading = $derived(downloadStatus?.isDownloading ?? false);
- const progress = $derived(downloadStatus?.progress);
- const percentage = $derived(progress?.percentage ?? 0);
-let expandedNodes = $state<Set<string>>(new Set());
-
-function toggleNodeDetails(nodeId: string): void {
- const next = new Set(expandedNodes);
- if (next.has(nodeId)) {
- next.delete(nodeId);
- } else {
- next.add(nodeId);
- }
- expandedNodes = next;
-}
-
- // Use actual storage_size_megabytes from API if available, otherwise fall back to estimate
- const estimatedMemory = $derived(
- model.storage_size_megabytes
- ? Math.round(model.storage_size_megabytes / 1024)
- : estimateMemoryGB(model.id, model.name)
- );
-
- function getDeviceType(name: string): 'macbook' | 'studio' | 'mini' | 'unknown' {
- const lower = name.toLowerCase();
- if (lower.includes('macbook')) return 'macbook';
- if (lower.includes('studio')) return 'studio';
- if (lower.includes('mini')) return 'mini';
- return 'unknown';
- }
-
- const clampPercent = (value: number): number => Math.min(100, Math.max(0, value));
- const huggingFaceModelId = $derived(modelIdOverride ?? model.id);
-
- // Get node list in the same order as the topology graph (insertion order of
- // topology nodes), while still ensuring preview nodes render even if the
- // topology payload is missing them. Topology order is preserved exactly so
- // that the mini preview matches the main TopologyGraph layout.
- const nodeList = $derived(() => {
- const nodesFromTopology = Object.keys(nodes).map((id) => {
- const info = nodes[id];
- const totalBytes = info.macmon_info?.memory?.ram_total ?? info.system_info?.memory ?? 0;
- const usedBytes = info.macmon_info?.memory?.ram_usage ?? 0;
- const availableBytes = Math.max(totalBytes - usedBytes, 0);
- const totalGB = totalBytes / (1024 * 1024 * 1024);
- const availableGB = availableBytes / (1024 * 1024 * 1024);
- const usedGB = Math.max(totalGB - availableGB, 0);
- const deviceName = info.system_info?.model_id ?? 'Unknown';
- const deviceType = getDeviceType(deviceName);
-
- return { id, totalGB, availableGB, usedGB, deviceName, deviceType, usedBytes, totalBytes };
- });
-
- const previewEntries = apiPreview?.memory_delta_by_node ?? null;
- const previewIds = previewEntries ? Object.keys(previewEntries) : [];
-
- if (previewIds.length === 0) return nodesFromTopology;
-
- // Append any preview-only nodes (not in topology) at the end
- const topologyIds = new Set(nodesFromTopology.map((n) => n.id));
- const extraPreviewNodes = previewIds
- .filter((id) => !topologyIds.has(id))
- .map((id) => {
- const deltaBytes = previewEntries?.[id] ?? 0;
- const deltaGB = deltaBytes / (1024 * 1024 * 1024);
- const totalGB = Math.max(deltaGB * 1.2, 1);
- const usedGB = Math.max(totalGB - deltaGB, 0);
-
- return {
- id,
- totalGB,
- availableGB: Math.max(totalGB - usedGB, 0),
- usedGB,
- deviceName: 'Unknown',
- deviceType: 'unknown' as const,
- usedBytes: usedGB * 1024 * 1024 * 1024,
- totalBytes: totalGB * 1024 * 1024 * 1024
- };
- });
-
- return [...nodesFromTopology, ...extraPreviewNodes];
- });
-
- // Calculate placement preview with all SVG metrics pre-computed
- // Uses API preview data when available, falls back to local estimation
- const placementPreview = $derived(() => {
- const nodeArray = nodeList();
- if (nodeArray.length === 0) return { nodes: [], canFit: false, totalAvailable: 0, topoWidth: 260, topoHeight: 90, error: null };
-
- const numNodes = nodeArray.length;
- const iconSize = numNodes === 1 ? 50 : 36;
- const topoWidth = 260;
- const topoHeight = numNodes === 1 ? 90 : numNodes === 2 ? 140 : numNodes * 50 + 20;
- const centerX = topoWidth / 2;
- const centerY = topoHeight / 2;
- const radius = numNodes === 1 ? 0 : numNodes === 2 ? 45 : Math.min(topoWidth, topoHeight) * 0.32;
-
- // Only use API preview data - no local estimation
- const hasApiPreview = apiPreview !== null && apiPreview.error === null && apiPreview.memory_delta_by_node !== null;
- const error = apiPreview?.error ?? null;
-
- let placementNodes: Array<{
- id: string;
- deviceName: string;
- deviceType: 'macbook' | 'studio' | 'mini' | 'unknown';
- totalGB: number;
- currentUsedGB: number;
- modelUsageGB: number;
- currentPercent: number;
- newPercent: number;
- isUsed: boolean;
- x: number;
- y: number;
- iconSize: number;
- screenHeight: number;
- currentFillHeight: number;
- modelFillHeight: number;
- }> = [];
-
- // Use API placement data directly
- const memoryDelta = apiPreview?.memory_delta_by_node ?? {};
- placementNodes = nodeArray.map((n, i) => {
- const deltaBytes = memoryDelta[n.id] ?? 0;
- const modelUsageGB = deltaBytes / (1024 * 1024 * 1024);
- const isUsed = deltaBytes > 0;
- const angle = numNodes === 1 ? 0 : (i / numNodes) * Math.PI * 2 - Math.PI / 2;
- const safeTotal = Math.max(n.totalGB, 0.001);
- const currentPercent = clampPercent((n.usedGB / safeTotal) * 100);
- const newPercent = clampPercent(((n.usedGB + modelUsageGB) / safeTotal) * 100);
- const screenHeight = iconSize * 0.58;
-
- return {
- id: n.id,
- deviceName: n.deviceName,
- deviceType: n.deviceType,
- totalGB: n.totalGB,
- currentUsedGB: n.usedGB,
- modelUsageGB,
- currentPercent,
- newPercent,
- isUsed,
- x: centerX + Math.cos(angle) * radius,
- y: centerY + Math.sin(angle) * radius,
- iconSize,
- screenHeight,
- currentFillHeight: screenHeight * (currentPercent / 100),
- modelFillHeight: screenHeight * ((newPercent - currentPercent) / 100)
- };
- });
-
- const totalAvailable = nodeArray.reduce((sum, n) => sum + n.availableGB, 0);
- return { nodes: placementNodes, canFit: hasApiPreview, totalAvailable, topoWidth, topoHeight, error };
- });
-
- const canFit = $derived(apiPreview ? apiPreview.error === null : placementPreview().canFit);
- const placementError = $derived(apiPreview?.error ?? null);
- const nodeCount = $derived(nodeList().length);
- const filterId = $derived(model.id.replace(/[^a-zA-Z0-9]/g, ''));
-
- // Debug mode state
- const isDebugMode = $derived(debugMode());
- const topology = $derived(topologyData());
- const isRdma = $derived(runtime === 'MlxIbv' || runtime === 'MlxJaccl');
-
- // Get interface name for an IP from node data
- function getInterfaceForIp(nodeId: string, ip?: string): string | null {
- if (!ip || !topology?.nodes) return null;
-
- // Strip port if present
- const cleanIp = ip.includes(':') && !ip.includes('[') ? ip.split(':')[0] : ip;
-
- // Check specified node first
- const node = topology.nodes[nodeId];
- if (node) {
- const match = node.network_interfaces?.find((iface) =>
- (iface.addresses || []).some((addr) => addr === cleanIp || addr === ip)
- );
- if (match?.name) return match.name;
-
- const mapped = node.ip_to_interface?.[cleanIp] || node.ip_to_interface?.[ip];
- if (mapped) return mapped;
- }
-
- // Fallback: check all nodes
- for (const [, otherNode] of Object.entries(topology.nodes)) {
- if (!otherNode) continue;
- const match = otherNode.network_interfaces?.find((iface) =>
- (iface.addresses || []).some((addr) => addr === cleanIp || addr === ip)
- );
- if (match?.name) return match.name;
-
- const mapped = otherNode.ip_to_interface?.[cleanIp] || otherNode.ip_to_interface?.[ip];
- if (mapped) return mapped;
- }
-
- return null;
- }
-
- // Get directional arrow based on node positions
- function getArrow(fromNode: { x: number; y: number }, toNode: { x: number; y: number }): string {
- const dx = toNode.x - fromNode.x;
- const dy = toNode.y - fromNode.y;
- const absX = Math.abs(dx);
- const absY = Math.abs(dy);
-
- if (absX > absY * 2) {
- return dx > 0 ? '→' : '←';
- } else if (absY > absX * 2) {
- return dy > 0 ? '↓' : '↑';
- } else {
- if (dx > 0 && dy > 0) return '↘';
- if (dx > 0 && dy < 0) return '↗';
- if (dx < 0 && dy > 0) return '↙';
- return '↖';
- }
- }
-
- // Get connection info for edges between two nodes
- // Returns exactly one connection per direction (A→B and B→A), preferring non-loopback
- function getConnectionInfo(nodeId1: string, nodeId2: string): Array<{ ip: string; iface: string | null; from: string; to: string }> {
- if (!topology?.edges) return [];
-
- // Collect candidates for each direction
- const aToBCandidates: Array<{ ip: string; iface: string | null }> = [];
- const bToACandidates: Array<{ ip: string; iface: string | null }> = [];
-
- for (const edge of topology.edges) {
- const ip = edge.sendBackIp || '?';
- const iface = edge.sendBackInterface || getInterfaceForIp(edge.source, ip);
-
- if (edge.source === nodeId1 && edge.target === nodeId2) {
- aToBCandidates.push({ ip, iface });
- } else if (edge.source === nodeId2 && edge.target === nodeId1) {
- bToACandidates.push({ ip, iface });
- }
- }
-
- // Pick best (prefer non-loopback)
- const pickBest = (candidates: Array<{ ip: string; iface: string | null }>) => {
- if (candidates.length === 0) return null;
- return candidates.find(c => !c.ip.startsWith('127.')) || candidates[0];
- };
-
- const result: Array<{ ip: string; iface: string | null; from: string; to: string }> = [];
-
- const bestAtoB = pickBest(aToBCandidates);
- if (bestAtoB) result.push({ ...bestAtoB, from: nodeId1, to: nodeId2 });
-
- const bestBtoA = pickBest(bToACandidates);
- if (bestBtoA) result.push({ ...bestBtoA, from: nodeId2, to: nodeId1 });
-
- return result;
- }
+ import type {
+ DownloadProgress,
+ NodeInfo,
+ PlacementPreview,
+ TopologyEdge,
+ } from "$lib/stores/app.svelte";
+ import { debugMode, topologyData } from "$lib/stores/app.svelte";
+
+ interface Props {
+ model: { id: string; name?: string; storage_size_megabytes?: number };
+ isLaunching?: boolean;
+ downloadStatus?: {
+ isDownloading: boolean;
+ progress: DownloadProgress | null;
+ perNode?: Array<{
+ nodeId: string;
+ nodeName: string;
+ progress: DownloadProgress;
+ }>;
+ } | null;
+ nodes?: Record<string, NodeInfo>;
+ sharding?: "Pipeline" | "Tensor";
+ runtime?: "MlxRing" | "MlxIbv" | "MlxJaccl";
+ onLaunch?: () => void;
+ tags?: string[];
+ apiPreview?: PlacementPreview | null;
+ modelIdOverride?: string | null;
+ }
+
+ let {
+ model,
+ isLaunching = false,
+ downloadStatus = null,
+ nodes = {},
+ sharding = "Pipeline",
+ runtime = "MlxRing",
+ onLaunch,
+ tags = [],
+ apiPreview = null,
+ modelIdOverride = null,
+ }: Props = $props();
+
+ // Estimate memory requirements from model name
+ // 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
+ }
+
+ function formatBytes(bytes: number, decimals = 1): 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) + "/s";
+ }
+
+ 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`;
+ }
+
+ const isDownloading = $derived(downloadStatus?.isDownloading ?? false);
+ const progress = $derived(downloadStatus?.progress);
+ const percentage = $derived(progress?.percentage ?? 0);
+ let expandedNodes = $state<Set<string>>(new Set());
+
+ function toggleNodeDetails(nodeId: string): void {
+ const next = new Set(expandedNodes);
+ if (next.has(nodeId)) {
+ next.delete(nodeId);
+ } else {
+ next.add(nodeId);
+ }
+ expandedNodes = next;
+ }
+
+ // Use actual storage_size_megabytes from API if available, otherwise fall back to estimate
+ const estimatedMemory = $derived(
+ model.storage_size_megabytes
+ ? Math.round(model.storage_size_megabytes / 1024)
+ : estimateMemoryGB(model.id, model.name),
+ );
+
+ function getDeviceType(
+ name: string,
+ ): "macbook" | "studio" | "mini" | "unknown" {
+ const lower = name.toLowerCase();
+ if (lower.includes("macbook")) return "macbook";
+ if (lower.includes("studio")) return "studio";
+ if (lower.includes("mini")) return "mini";
+ return "unknown";
+ }
+
+ const clampPercent = (value: number): number =>
+ Math.min(100, Math.max(0, value));
+ const huggingFaceModelId = $derived(modelIdOverride ?? model.id);
+
+ // Get node list in the same order as the topology graph (insertion order of
+ // topology nodes), while still ensuring preview nodes render even if the
+ // topology payload is missing them. Topology order is preserved exactly so
+ // that the mini preview matches the main TopologyGraph layout.
+ const nodeList = $derived(() => {
+ const nodesFromTopology = Object.keys(nodes).map((id) => {
+ const info = nodes[id];
+ const totalBytes =
+ info.macmon_info?.memory?.ram_total ?? info.system_info?.memory ?? 0;
+ const usedBytes = info.macmon_info?.memory?.ram_usage ?? 0;
+ const availableBytes = Math.max(totalBytes - usedBytes, 0);
+ const totalGB = totalBytes / (1024 * 1024 * 1024);
+ const availableGB = availableBytes / (1024 * 1024 * 1024);
+ const usedGB = Math.max(totalGB - availableGB, 0);
+ const deviceName = info.system_info?.model_id ?? "Unknown";
+ const deviceType = getDeviceType(deviceName);
+
+ return {
+ id,
+ totalGB,
+ availableGB,
+ usedGB,
+ deviceName,
+ deviceType,
+ usedBytes,
+ totalBytes,
+ };
+ });
+
+ const previewEntries = apiPreview?.memory_delta_by_node ?? null;
+ const previewIds = previewEntries ? Object.keys(previewEntries) : [];
+
+ if (previewIds.length === 0) return nodesFromTopology;
+
+ // Append any preview-only nodes (not in topology) at the end
+ const topologyIds = new Set(nodesFromTopology.map((n) => n.id));
+ const extraPreviewNodes = previewIds
+ .filter((id) => !topologyIds.has(id))
+ .map((id) => {
+ const deltaBytes = previewEntries?.[id] ?? 0;
+ const deltaGB = deltaBytes / (1024 * 1024 * 1024);
+ const totalGB = Math.max(deltaGB * 1.2, 1);
+ const usedGB = Math.max(totalGB - deltaGB, 0);
+
+ return {
+ id,
+ totalGB,
+ availableGB: Math.max(totalGB - usedGB, 0),
+ usedGB,
+ deviceName: "Unknown",
+ deviceType: "unknown" as const,
+ usedBytes: usedGB * 1024 * 1024 * 1024,
+ totalBytes: totalGB * 1024 * 1024 * 1024,
+ };
+ });
+
+ return [...nodesFromTopology, ...extraPreviewNodes];
+ });
+
+ // Calculate placement preview with all SVG metrics pre-computed
+ // Uses API preview data when available, falls back to local estimation
+ const placementPreview = $derived(() => {
+ const nodeArray = nodeList();
+ if (nodeArray.length === 0)
+ return {
+ nodes: [],
+ canFit: false,
+ totalAvailable: 0,
+ topoWidth: 260,
+ topoHeight: 90,
+ error: null,
+ };
+
+ const numNodes = nodeArray.length;
+ const iconSize = numNodes === 1 ? 50 : 36;
+ const topoWidth = 260;
+ const topoHeight =
+ numNodes === 1 ? 90 : numNodes === 2 ? 140 : numNodes * 50 + 20;
+ const centerX = topoWidth / 2;
+ const centerY = topoHeight / 2;
+ const radius =
+ numNodes === 1
+ ? 0
+ : numNodes === 2
+ ? 45
+ : Math.min(topoWidth, topoHeight) * 0.32;
+
+ // Only use API preview data - no local estimation
+ const hasApiPreview =
+ apiPreview !== null &&
+ apiPreview.error === null &&
+ apiPreview.memory_delta_by_node !== null;
+ const error = apiPreview?.error ?? null;
+
+ let placementNodes: Array<{
+ id: string;
+ deviceName: string;
+ deviceType: "macbook" | "studio" | "mini" | "unknown";
+ totalGB: number;
+ currentUsedGB: number;
+ modelUsageGB: number;
+ currentPercent: number;
+ newPercent: number;
+ isUsed: boolean;
+ x: number;
+ y: number;
+ iconSize: number;
+ screenHeight: number;
+ currentFillHeight: number;
+ modelFillHeight: number;
+ }> = [];
+
+ // Use API placement data directly
+ const memoryDelta = apiPreview?.memory_delta_by_node ?? {};
+ placementNodes = nodeArray.map((n, i) => {
+ const deltaBytes = memoryDelta[n.id] ?? 0;
+ const modelUsageGB = deltaBytes / (1024 * 1024 * 1024);
+ const isUsed = deltaBytes > 0;
+ const angle =
+ numNodes === 1 ? 0 : (i / numNodes) * Math.PI * 2 - Math.PI / 2;
+ const safeTotal = Math.max(n.totalGB, 0.001);
+ const currentPercent = clampPercent((n.usedGB / safeTotal) * 100);
+ const newPercent = clampPercent(
+ ((n.usedGB + modelUsageGB) / safeTotal) * 100,
+ );
+ const screenHeight = iconSize * 0.58;
+
+ return {
+ id: n.id,
+ deviceName: n.deviceName,
+ deviceType: n.deviceType,
+ totalGB: n.totalGB,
+ currentUsedGB: n.usedGB,
+ modelUsageGB,
+ currentPercent,
+ newPercent,
+ isUsed,
+ x: centerX + Math.cos(angle) * radius,
+ y: centerY + Math.sin(angle) * radius,
+ iconSize,
+ screenHeight,
+ currentFillHeight: screenHeight * (currentPercent / 100),
+ modelFillHeight: screenHeight * ((newPercent - currentPercent) / 100),
+ };
+ });
+
+ const totalAvailable = nodeArray.reduce((sum, n) => sum + n.availableGB, 0);
+ return {
+ nodes: placementNodes,
+ canFit: hasApiPreview,
+ totalAvailable,
+ topoWidth,
+ topoHeight,
+ error,
+ };
+ });
+
+ const canFit = $derived(
+ apiPreview ? apiPreview.error === null : placementPreview().canFit,
+ );
+ const placementError = $derived(apiPreview?.error ?? null);
+ const nodeCount = $derived(nodeList().length);
+ const filterId = $derived(model.id.replace(/[^a-zA-Z0-9]/g, ""));
+
+ // Debug mode state
+ const isDebugMode = $derived(debugMode());
+ const topology = $derived(topologyData());
+ const isRdma = $derived(runtime === "MlxIbv" || runtime === "MlxJaccl");
+
+ // Get interface name for an IP from node data
+ function getInterfaceForIp(nodeId: string, ip?: string): string | null {
+ if (!ip || !topology?.nodes) return null;
+
+ // Strip port if present
+ const cleanIp =
+ ip.includes(":") && !ip.includes("[") ? ip.split(":")[0] : ip;
+
+ // Check specified node first
+ const node = topology.nodes[nodeId];
+ if (node) {
+ const match = node.network_interfaces?.find((iface) =>
+ (iface.addresses || []).some((addr) => addr === cleanIp || addr === ip),
+ );
+ if (match?.name) return match.name;
+
+ const mapped =
+ node.ip_to_interface?.[cleanIp] || node.ip_to_interface?.[ip];
+ if (mapped) return mapped;
+ }
+
+ // Fallback: check all nodes
+ for (const [, otherNode] of Object.entries(topology.nodes)) {
+ if (!otherNode) continue;
+ const match = otherNode.network_interfaces?.find((iface) =>
+ (iface.addresses || []).some((addr) => addr === cleanIp || addr === ip),
+ );
+ if (match?.name) return match.name;
+
+ const mapped =
+ otherNode.ip_to_interface?.[cleanIp] || otherNode.ip_to_interface?.[ip];
+ if (mapped) return mapped;
+ }
+
+ return null;
+ }
+
+ // Get directional arrow based on node positions
+ function getArrow(
+ fromNode: { x: number; y: number },
+ toNode: { x: number; y: number },
+ ): string {
+ const dx = toNode.x - fromNode.x;
+ const dy = toNode.y - fromNode.y;
+ const absX = Math.abs(dx);
+ const absY = Math.abs(dy);
+
+ if (absX > absY * 2) {
+ return dx > 0 ? "→" : "←";
+ } else if (absY > absX * 2) {
+ return dy > 0 ? "↓" : "↑";
+ } else {
+ if (dx > 0 && dy > 0) return "↘";
+ if (dx > 0 && dy < 0) return "↗";
+ if (dx < 0 && dy > 0) return "↙";
+ return "↖";
+ }
+ }
+
+ // Get connection info for edges between two nodes
+ // Returns exactly one connection per direction (A→B and B→A), preferring non-loopback
+ function getConnectionInfo(
+ nodeId1: string,
+ nodeId2: string,
+ ): Array<{ ip: string; iface: string | null; from: string; to: string }> {
+ if (!topology?.edges) return [];
+
+ // Collect candidates for each direction
+ const aToBCandidates: Array<{ ip: string; iface: string | null }> = [];
+ const bToACandidates: Array<{ ip: string; iface: string | null }> = [];
+
+ for (const edge of topology.edges) {
+ const ip = edge.sendBackIp || "?";
+ const iface =
+ edge.sendBackInterface || getInterfaceForIp(edge.source, ip);
+
+ if (edge.source === nodeId1 && edge.target === nodeId2) {
+ aToBCandidates.push({ ip, iface });
+ } else if (edge.source === nodeId2 && edge.target === nodeId1) {
+ bToACandidates.push({ ip, iface });
+ }
+ }
+
+ // Pick best (prefer non-loopback)
+ const pickBest = (
+ candidates: Array<{ ip: string; iface: string | null }>,
+ ) => {
+ if (candidates.length === 0) return null;
+ return candidates.find((c) => !c.ip.startsWith("127.")) || candidates[0];
+ };
+
+ const result: Array<{
+ ip: string;
+ iface: string | null;
+ from: string;
+ to: string;
+ }> = [];
+
+ const bestAtoB = pickBest(aToBCandidates);
+ if (bestAtoB) result.push({ ...bestAtoB, from: nodeId1, to: nodeId2 });
+
+ const bestBtoA = pickBest(bToACandidates);
+ if (bestBtoA) result.push({ ...bestBtoA, from: nodeId2, to: nodeId1 });
+
+ return result;
+ }
</script>
<div class="relative group">
- <!-- Corner accents -->
- <div class="absolute -top-px -left-px w-2 h-2 border-l border-t {canFit ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60' : 'border-red-500/30'} transition-colors"></div>
- <div class="absolute -top-px -right-px w-2 h-2 border-r border-t {canFit ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60' : 'border-red-500/30'} transition-colors"></div>
- <div class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {canFit ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60' : 'border-red-500/30'} transition-colors"></div>
- <div class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {canFit ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60' : 'border-red-500/30'} transition-colors"></div>
-
- <div class="bg-exo-dark-gray/60 border {canFit ? 'border-exo-yellow/20 group-hover:border-exo-yellow/40' : 'border-red-500/20'} p-3 transition-all duration-200 group-hover:shadow-[0_0_15px_rgba(255,215,0,0.1)]">
- <!-- Model Name & Memory Required -->
- <div class="flex items-start justify-between gap-2 mb-2">
- <div class="flex-1 min-w-0">
- <div class="flex items-center gap-2">
- <div class="text-exo-yellow text-xs font-mono tracking-wide truncate" title={model.name || model.id}>
- {model.name || model.id}
- </div>
- {#if huggingFaceModelId}
- <a
- class="shrink-0 text-white/60 hover:text-exo-yellow transition-colors"
- href={`https://huggingface.co/${huggingFaceModelId}`}
- target="_blank"
- rel="noreferrer noopener"
- aria-label="View model on Hugging Face"
- >
- <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 tags.length > 0}
- <div class="flex gap-1 flex-shrink-0">
- {#each tags as tag}
- <span class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase rounded {tag === 'FASTEST' ? 'bg-green-500/20 text-green-400 border border-green-500/30' : 'bg-purple-500/20 text-purple-400 border border-purple-500/30'}">
- {tag}
- </span>
- {/each}
- </div>
- {/if}
- </div>
- {#if model.name && model.name !== model.id}
- <div class="text-xs text-exo-light-gray font-mono truncate mt-0.5" title={model.id}>
- {model.id}
- </div>
- {/if}
- </div>
- <div class="flex-shrink-0 text-right">
- <div class="text-xs font-mono {canFit ? 'text-exo-yellow' : 'text-red-400'}">
- {estimatedMemory}GB
- </div>
- </div>
- </div>
-
- <!-- Configuration Badge -->
- <div class="flex items-center gap-1.5 mb-2">
- <span class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/40">
- {sharding}
- </span>
- <span class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/40">
- {runtime === 'MlxRing' ? 'MLX Ring' : runtime === 'MlxIbv' || runtime === 'MlxJaccl' ? 'MLX RDMA' : runtime}
- </span>
- </div>
-
- <!-- Mini Topology Preview -->
- {#if placementPreview().nodes.length > 0}
- {@const preview = placementPreview()}
- <div class="mb-3 bg-exo-black/60 rounded border border-exo-medium-gray/20 p-2 relative overflow-hidden">
- <!-- Scanline effect -->
- <div class="absolute inset-0 bg-[repeating-linear-gradient(0deg,transparent,transparent_2px,rgba(255,215,0,0.02)_2px,rgba(255,215,0,0.02)_4px)] pointer-events-none"></div>
-
- <svg width="100%" height={preview.topoHeight} viewBox="0 0 {preview.topoWidth} {preview.topoHeight}" class="overflow-visible">
- <defs>
- <!-- Glow filter for active nodes -->
- <filter id="nodeGlow-{filterId}" x="-50%" y="-50%" width="200%" height="200%">
- <feGaussianBlur stdDeviation="2" result="blur"/>
- <feMerge>
- <feMergeNode in="blur"/>
- <feMergeNode in="SourceGraphic"/>
- </feMerge>
- </filter>
-
- <!-- Strong glow for new memory -->
- <filter id="memGlow-{filterId}" x="-100%" y="-100%" width="300%" height="300%">
- <feGaussianBlur stdDeviation="3" result="blur"/>
- <feComposite in="SourceGraphic" in2="blur" operator="over"/>
- </filter>
- </defs>
-
- <!-- Connection lines between nodes (if multiple) -->
- {#if preview.nodes.length > 1}
- {@const usedNodes = preview.nodes.filter(n => n.isUsed)}
- {@const nodePositions = Object.fromEntries(preview.nodes.map(n => [n.id, { x: n.x, y: n.y }]))}
- {@const allConnections = isDebugMode && usedNodes.length > 1 ? (() => {
- const conns: Array<{ ip: string; iface: string | null; from: string; to: string; midX: number; midY: number; arrow: string }> = [];
- for (let i = 0; i < usedNodes.length; i++) {
- for (let j = i + 1; j < usedNodes.length; j++) {
- const n1 = usedNodes[i];
- const n2 = usedNodes[j];
- const midX = (n1.x + n2.x) / 2;
- const midY = (n1.y + n2.y) / 2;
- for (const c of getConnectionInfo(n1.id, n2.id)) {
- const fromPos = nodePositions[c.from];
- const toPos = nodePositions[c.to];
- const arrow = fromPos && toPos ? getArrow(fromPos, toPos) : '→';
- conns.push({ ...c, midX, midY, arrow });
- }
- }
- }
- return conns;
- })() : []}
- {#each preview.nodes as node, i}
- {#each preview.nodes.slice(i + 1) as node2}
- <line
- x1={node.x} y1={node.y} x2={node2.x} y2={node2.y}
- stroke={node.isUsed && node2.isUsed ? '#FFD700' : '#374151'}
- stroke-width="1"
- stroke-dasharray={node.isUsed && node2.isUsed ? '4,2' : '2,4'}
- opacity={node.isUsed && node2.isUsed ? 0.4 : 0.15}
- />
- {/each}
- {/each}
- <!-- Debug: Show connection IPs/interfaces in corners -->
- {#if isDebugMode && allConnections.length > 0}
- {@const centerX = preview.topoWidth / 2}
- {@const centerY = preview.topoHeight / 2}
- {@const quadrants = {
- topLeft: allConnections.filter(c => c.midX < centerX && c.midY < centerY),
- topRight: allConnections.filter(c => c.midX >= centerX && c.midY < centerY),
- bottomLeft: allConnections.filter(c => c.midX < centerX && c.midY >= centerY),
- bottomRight: allConnections.filter(c => c.midX >= centerX && c.midY >= centerY)
- }}
- {@const padding = 4}
- {@const lineHeight = 8}
- <!-- Top Left -->
- {#each quadrants.topLeft as conn, idx}
- <text x={padding} y={padding + idx * lineHeight} text-anchor="start" dominant-baseline="hanging" font-size="6" font-family="SF Mono, Monaco, monospace" fill={conn.iface ? 'rgba(255,255,255,0.85)' : 'rgba(248,113,113,0.85)'}>
- {conn.arrow} {isRdma ? (conn.iface || '?') : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ''}`}
- </text>
- {/each}
- <!-- Top Right -->
- {#each quadrants.topRight as conn, idx}
- <text x={preview.topoWidth - padding} y={padding + idx * lineHeight} text-anchor="end" dominant-baseline="hanging" font-size="6" font-family="SF Mono, Monaco, monospace" fill={conn.iface ? 'rgba(255,255,255,0.85)' : 'rgba(248,113,113,0.85)'}>
- {conn.arrow} {isRdma ? (conn.iface || '?') : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ''}`}
- </text>
- {/each}
- <!-- Bottom Left -->
- {#each quadrants.bottomLeft as conn, idx}
- <text x={padding} y={preview.topoHeight - padding - (quadrants.bottomLeft.length - 1 - idx) * lineHeight} text-anchor="start" dominant-baseline="auto" font-size="6" font-family="SF Mono, Monaco, monospace" fill={conn.iface ? 'rgba(255,255,255,0.85)' : 'rgba(248,113,113,0.85)'}>
- {conn.arrow} {isRdma ? (conn.iface || '?') : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ''}`}
- </text>
- {/each}
- <!-- Bottom Right -->
- {#each quadrants.bottomRight as conn, idx}
- <text x={preview.topoWidth - padding} y={preview.topoHeight - padding - (quadrants.bottomRight.length - 1 - idx) * lineHeight} text-anchor="end" dominant-baseline="auto" font-size="6" font-family="SF Mono, Monaco, monospace" fill={conn.iface ? 'rgba(255,255,255,0.85)' : 'rgba(248,113,113,0.85)'}>
- {conn.arrow} {isRdma ? (conn.iface || '?') : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ''}`}
- </text>
- {/each}
- {/if}
- {/if}
-
- {#each preview.nodes as node}
- <g
- transform="translate({node.x}, {node.y})"
- opacity={node.isUsed ? 1 : 0.25}
- filter={node.isUsed ? `url(#nodeGlow-${filterId})` : 'none'}
- >
- <!-- Device icon based on type -->
- {#if node.deviceType === 'macbook'}
- <!-- MacBook Pro icon with memory fill -->
- <g transform="translate({-node.iconSize/2}, {-node.iconSize/2})">
- <!-- Screen bezel -->
- <rect
- x="2" y="0"
- width={node.iconSize - 4} height={node.iconSize * 0.65}
- rx="2"
- fill="none"
- stroke={node.isUsed ? '#FFD700' : '#4B5563'}
- stroke-width="1.5"
- />
- <!-- Screen area (memory fill container) -->
- <rect
- x="4" y="2"
- width={node.iconSize - 8} height={node.screenHeight}
- fill="#0a0a0a"
- />
- <!-- Current memory fill (gray) -->
- <rect
- x="4"
- y={2 + node.screenHeight - node.currentFillHeight}
- width={node.iconSize - 8}
- height={node.currentFillHeight}
- fill="#374151"
- />
- <!-- New model memory fill (glowing yellow) -->
- {#if node.modelUsageGB > 0 && node.isUsed}
- <rect
- x="4"
- y={2 + node.screenHeight - node.currentFillHeight - node.modelFillHeight}
- width={node.iconSize - 8}
- height={node.modelFillHeight}
- fill="#FFD700"
- filter="url(#memGlow-{filterId})"
- class="animate-pulse-slow"
- />
- {/if}
- <!-- Base/keyboard -->
- <path
- d="M 0 {node.iconSize * 0.68} L {node.iconSize} {node.iconSize * 0.68} L {node.iconSize - 2} {node.iconSize * 0.78} L 2 {node.iconSize * 0.78} Z"
- fill="none"
- stroke={node.isUsed ? '#FFD700' : '#4B5563'}
- stroke-width="1.5"
- />
- </g>
- {:else if node.deviceType === 'studio'}
- <!-- Mac Studio icon -->
- <g transform="translate({-node.iconSize/2}, {-node.iconSize/2})">
- <rect
- x="2" y="2"
- width={node.iconSize - 4} height={node.iconSize - 4}
- rx="4"
- fill="none"
- stroke={node.isUsed ? '#FFD700' : '#4B5563'}
- stroke-width="1.5"
- />
- <!-- Memory fill background -->
- <rect
- x="4" y="4"
- width={node.iconSize - 8} height={node.iconSize - 8}
- fill="#0a0a0a"
- />
- <!-- Current memory fill -->
- <rect
- x="4"
- y={4 + (node.iconSize - 8) * (1 - node.currentPercent / 100)}
- width={node.iconSize - 8}
- height={(node.iconSize - 8) * (node.currentPercent / 100)}
- fill="#374151"
- />
- <!-- New model memory fill -->
- {#if node.modelUsageGB > 0 && node.isUsed}
- <rect
- x="4"
- y={4 + (node.iconSize - 8) * (1 - node.newPercent / 100)}
- width={node.iconSize - 8}
- height={(node.iconSize - 8) * ((node.newPercent - node.currentPercent) / 100)}
- fill="#FFD700"
- filter="url(#memGlow-{filterId})"
- class="animate-pulse-slow"
- />
- {/if}
- </g>
- {:else if node.deviceType === 'mini'}
- <!-- Mac Mini icon -->
- <g transform="translate({-node.iconSize/2}, {-node.iconSize/2})">
- <rect
- x="2" y={node.iconSize * 0.3}
- width={node.iconSize - 4} height={node.iconSize * 0.4}
- rx="3"
- fill="none"
- stroke={node.isUsed ? '#FFD700' : '#4B5563'}
- stroke-width="1.5"
- />
- <!-- Memory fill background -->
- <rect
- x="4" y={node.iconSize * 0.32}
- width={node.iconSize - 8} height={node.iconSize * 0.36}
- fill="#0a0a0a"
- />
- <!-- Current memory fill -->
- <rect
- x="4"
- y={node.iconSize * 0.32 + (node.iconSize * 0.36) * (1 - node.currentPercent / 100)}
- width={node.iconSize - 8}
- height={(node.iconSize * 0.36) * (node.currentPercent / 100)}
- fill="#374151"
- />
- <!-- New model memory fill -->
- {#if node.modelUsageGB > 0 && node.isUsed}
- <rect
- x="4"
- y={node.iconSize * 0.32 + (node.iconSize * 0.36) * (1 - node.newPercent / 100)}
- width={node.iconSize - 8}
- height={(node.iconSize * 0.36) * ((node.newPercent - node.currentPercent) / 100)}
- fill="#FFD700"
- filter="url(#memGlow-{filterId})"
- class="animate-pulse-slow"
- />
- {/if}
- </g>
- {:else}
- <!-- Unknown device - hexagon -->
- <g transform="translate({-node.iconSize/2}, {-node.iconSize/2})">
- <polygon
- points="{node.iconSize/2},0 {node.iconSize},{node.iconSize*0.25} {node.iconSize},{node.iconSize*0.75} {node.iconSize/2},{node.iconSize} 0,{node.iconSize*0.75} 0,{node.iconSize*0.25}"
- fill={node.isUsed ? 'rgba(255,215,0,0.1)' : '#0a0a0a'}
- stroke={node.isUsed ? '#FFD700' : '#4B5563'}
- stroke-width="1.5"
- />
- </g>
- {/if}
-
- <!-- Percentage label -->
- <text
- y={node.iconSize/2 + 12}
- text-anchor="middle"
- font-size="8"
- font-family="SF Mono, Monaco, monospace"
- fill={node.isUsed ? (node.newPercent > 90 ? '#f87171' : '#FFD700') : '#4B5563'}
- >
- {node.newPercent.toFixed(0)}%
- </text>
- </g>
- {/each}
- </svg>
- </div>
- {/if}
-
- <!-- Launch Button -->
- <button
- onclick={onLaunch}
- disabled={isLaunching || !canFit}
- class="w-full py-2 text-sm font-mono tracking-wider uppercase border transition-all duration-200
- {isLaunching
- ? 'bg-transparent text-exo-yellow border-exo-yellow/50 cursor-wait'
- : !canFit
- ? 'bg-red-500/10 text-red-400/70 border-red-500/30 cursor-not-allowed'
- : 'bg-transparent text-exo-light-gray border-exo-light-gray/40 hover:text-exo-yellow hover:border-exo-yellow/50 cursor-pointer'
- }"
- >
- {#if isLaunching}
- <span class="flex items-center justify-center gap-1.5">
- <span class="w-2 h-2 border border-exo-yellow border-t-transparent rounded-full animate-spin"></span>
- LAUNCHING...
- </span>
- {:else if !canFit}
- INSUFFICIENT MEMORY
- {:else}
- ▸ LAUNCH
- {/if}
- </button>
- </div>
+ <!-- Corner accents -->
+ <div
+ class="absolute -top-px -left-px w-2 h-2 border-l border-t {canFit
+ ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60'
+ : 'border-red-500/30'} transition-colors"
+ ></div>
+ <div
+ class="absolute -top-px -right-px w-2 h-2 border-r border-t {canFit
+ ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60'
+ : 'border-red-500/30'} transition-colors"
+ ></div>
+ <div
+ class="absolute -bottom-px -left-px w-2 h-2 border-l border-b {canFit
+ ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60'
+ : 'border-red-500/30'} transition-colors"
+ ></div>
+ <div
+ class="absolute -bottom-px -right-px w-2 h-2 border-r border-b {canFit
+ ? 'border-exo-yellow/30 group-hover:border-exo-yellow/60'
+ : 'border-red-500/30'} transition-colors"
+ ></div>
+
+ <div
+ class="bg-exo-dark-gray/60 border {canFit
+ ? 'border-exo-yellow/20 group-hover:border-exo-yellow/40'
+ : 'border-red-500/20'} p-3 transition-all duration-200 group-hover:shadow-[0_0_15px_rgba(255,215,0,0.1)]"
+ >
+ <!-- Model Name & Memory Required -->
+ <div class="flex items-start justify-between gap-2 mb-2">
+ <div class="flex-1 min-w-0">
+ <div class="flex items-center gap-2">
+ <div
+ class="text-exo-yellow text-xs font-mono tracking-wide truncate"
+ title={model.name || model.id}
+ >
+ {model.name || model.id}
+ </div>
+ {#if huggingFaceModelId}
+ <a
+ class="shrink-0 text-white/60 hover:text-exo-yellow transition-colors"
+ href={`https://huggingface.co/${huggingFaceModelId}`}
+ target="_blank"
+ rel="noreferrer noopener"
+ aria-label="View model on Hugging Face"
+ >
+ <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 tags.length > 0}
+ <div class="flex gap-1 flex-shrink-0">
+ {#each tags as tag}
+ <span
+ class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase rounded {tag ===
+ 'FASTEST'
+ ? 'bg-green-500/20 text-green-400 border border-green-500/30'
+ : 'bg-purple-500/20 text-purple-400 border border-purple-500/30'}"
+ >
+ {tag}
+ </span>
+ {/each}
+ </div>
+ {/if}
+ </div>
+ {#if model.name && model.name !== model.id}
+ <div
+ class="text-xs text-exo-light-gray font-mono truncate mt-0.5"
+ title={model.id}
+ >
+ {model.id}
+ </div>
+ {/if}
+ </div>
+ <div class="flex-shrink-0 text-right">
+ <div
+ class="text-xs font-mono {canFit
+ ? 'text-exo-yellow'
+ : 'text-red-400'}"
+ >
+ {estimatedMemory}GB
+ </div>
+ </div>
+ </div>
+
+ <!-- Configuration Badge -->
+ <div class="flex items-center gap-1.5 mb-2">
+ <span
+ class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/40"
+ >
+ {sharding}
+ </span>
+ <span
+ class="px-1.5 py-0.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/40"
+ >
+ {runtime === "MlxRing"
+ ? "MLX Ring"
+ : runtime === "MlxIbv" || runtime === "MlxJaccl"
+ ? "MLX RDMA"
+ : runtime}
+ </span>
+ </div>
+
+ <!-- Mini Topology Preview -->
+ {#if placementPreview().nodes.length > 0}
+ {@const preview = placementPreview()}
+ <div
+ class="mb-3 bg-exo-black/60 rounded border border-exo-medium-gray/20 p-2 relative overflow-hidden"
+ >
+ <!-- Scanline effect -->
+ <div
+ class="absolute inset-0 bg-[repeating-linear-gradient(0deg,transparent,transparent_2px,rgba(255,215,0,0.02)_2px,rgba(255,215,0,0.02)_4px)] pointer-events-none"
+ ></div>
+
+ <svg
+ width="100%"
+ height={preview.topoHeight}
+ viewBox="0 0 {preview.topoWidth} {preview.topoHeight}"
+ class="overflow-visible"
+ >
+ <defs>
+ <!-- Glow filter for active nodes -->
+ <filter
+ id="nodeGlow-{filterId}"
+ x="-50%"
+ y="-50%"
+ width="200%"
+ height="200%"
+ >
+ <feGaussianBlur stdDeviation="2" result="blur" />
+ <feMerge>
+ <feMergeNode in="blur" />
+ <feMergeNode in="SourceGraphic" />
+ </feMerge>
+ </filter>
+
+ <!-- Strong glow for new memory -->
+ <filter
+ id="memGlow-{filterId}"
+ x="-100%"
+ y="-100%"
+ width="300%"
+ height="300%"
+ >
+ <feGaussianBlur stdDeviation="3" result="blur" />
+ <feComposite in="SourceGraphic" in2="blur" operator="over" />
+ </filter>
+ </defs>
+
+ <!-- Connection lines between nodes (if multiple) -->
+ {#if preview.nodes.length > 1}
+ {@const usedNodes = preview.nodes.filter((n) => n.isUsed)}
+ {@const nodePositions = Object.fromEntries(
+ preview.nodes.map((n) => [n.id, { x: n.x, y: n.y }]),
+ )}
+ {@const allConnections =
+ isDebugMode && usedNodes.length > 1
+ ? (() => {
+ const conns: Array<{
+ ip: string;
+ iface: string | null;
+ from: string;
+ to: string;
+ midX: number;
+ midY: number;
+ arrow: string;
+ }> = [];
+ for (let i = 0; i < usedNodes.length; i++) {
+ for (let j = i + 1; j < usedNodes.length; j++) {
+ const n1 = usedNodes[i];
+ const n2 = usedNodes[j];
+ const midX = (n1.x + n2.x) / 2;
+ const midY = (n1.y + n2.y) / 2;
+ for (const c of getConnectionInfo(n1.id, n2.id)) {
+ const fromPos = nodePositions[c.from];
+ const toPos = nodePositions[c.to];
+ const arrow =
+ fromPos && toPos ? getArrow(fromPos, toPos) : "→";
+ conns.push({ ...c, midX, midY, arrow });
+ }
+ }
+ }
+ return conns;
+ })()
+ : []}
+ {#each preview.nodes as node, i}
+ {#each preview.nodes.slice(i + 1) as node2}
+ <line
+ x1={node.x}
+ y1={node.y}
+ x2={node2.x}
+ y2={node2.y}
+ stroke={node.isUsed && node2.isUsed ? "#FFD700" : "#374151"}
+ stroke-width="1"
+ stroke-dasharray={node.isUsed && node2.isUsed ? "4,2" : "2,4"}
+ opacity={node.isUsed && node2.isUsed ? 0.4 : 0.15}
+ />
+ {/each}
+ {/each}
+ <!-- Debug: Show connection IPs/interfaces in corners -->
+ {#if isDebugMode && allConnections.length > 0}
+ {@const centerX = preview.topoWidth / 2}
+ {@const centerY = preview.topoHeight / 2}
+ {@const quadrants = {
+ topLeft: allConnections.filter(
+ (c) => c.midX < centerX && c.midY < centerY,
+ ),
+ topRight: allConnections.filter(
+ (c) => c.midX >= centerX && c.midY < centerY,
+ ),
+ bottomLeft: allConnections.filter(
+ (c) => c.midX < centerX && c.midY >= centerY,
+ ),
+ bottomRight: allConnections.filter(
+ (c) => c.midX >= centerX && c.midY >= centerY,
+ ),
+ }}
+ {@const padding = 4}
+ {@const lineHeight = 8}
+ <!-- Top Left -->
+ {#each quadrants.topLeft as conn, idx}
+ <text
+ x={padding}
+ y={padding + idx * lineHeight}
+ text-anchor="start"
+ dominant-baseline="hanging"
+ font-size="6"
+ font-family="SF Mono, Monaco, monospace"
+ fill={conn.iface
+ ? "rgba(255,255,255,0.85)"
+ : "rgba(248,113,113,0.85)"}
+ >
+ {conn.arrow}
+ {isRdma
+ ? conn.iface || "?"
+ : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ""}`}
+ </text>
+ {/each}
+ <!-- Top Right -->
+ {#each quadrants.topRight as conn, idx}
+ <text
+ x={preview.topoWidth - padding}
+ y={padding + idx * lineHeight}
+ text-anchor="end"
+ dominant-baseline="hanging"
+ font-size="6"
+ font-family="SF Mono, Monaco, monospace"
+ fill={conn.iface
+ ? "rgba(255,255,255,0.85)"
+ : "rgba(248,113,113,0.85)"}
+ >
+ {conn.arrow}
+ {isRdma
+ ? conn.iface || "?"
+ : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ""}`}
+ </text>
+ {/each}
+ <!-- Bottom Left -->
+ {#each quadrants.bottomLeft as conn, idx}
+ <text
+ x={padding}
+ y={preview.topoHeight -
+ padding -
+ (quadrants.bottomLeft.length - 1 - idx) * lineHeight}
+ text-anchor="start"
+ dominant-baseline="auto"
+ font-size="6"
+ font-family="SF Mono, Monaco, monospace"
+ fill={conn.iface
+ ? "rgba(255,255,255,0.85)"
+ : "rgba(248,113,113,0.85)"}
+ >
+ {conn.arrow}
+ {isRdma
+ ? conn.iface || "?"
+ : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ""}`}
+ </text>
+ {/each}
+ <!-- Bottom Right -->
+ {#each quadrants.bottomRight as conn, idx}
+ <text
+ x={preview.topoWidth - padding}
+ y={preview.topoHeight -
+ padding -
+ (quadrants.bottomRight.length - 1 - idx) * lineHeight}
+ text-anchor="end"
+ dominant-baseline="auto"
+ font-size="6"
+ font-family="SF Mono, Monaco, monospace"
+ fill={conn.iface
+ ? "rgba(255,255,255,0.85)"
+ : "rgba(248,113,113,0.85)"}
+ >
+ {conn.arrow}
+ {isRdma
+ ? conn.iface || "?"
+ : `${conn.ip}${conn.iface ? ` (${conn.iface})` : ""}`}
+ </text>
+ {/each}
+ {/if}
+ {/if}
+
+ {#each preview.nodes as node}
+ <g
+ transform="translate({node.x}, {node.y})"
+ opacity={node.isUsed ? 1 : 0.25}
+ filter={node.isUsed ? `url(#nodeGlow-${filterId})` : "none"}
+ >
+ <!-- Device icon based on type -->
+ {#if node.deviceType === "macbook"}
+ <!-- MacBook Pro icon with memory fill -->
+ <g
+ transform="translate({-node.iconSize / 2}, {-node.iconSize /
+ 2})"
+ >
+ <!-- Screen bezel -->
+ <rect
+ x="2"
+ y="0"
+ width={node.iconSize - 4}
+ height={node.iconSize * 0.65}
+ rx="2"
+ fill="none"
+ stroke={node.isUsed ? "#FFD700" : "#4B5563"}
+ stroke-width="1.5"
+ />
+ <!-- Screen area (memory fill container) -->
+ <rect
+ x="4"
+ y="2"
+ width={node.iconSize - 8}
+ height={node.screenHeight}
+ fill="#0a0a0a"
+ />
+ <!-- Current memory fill (gray) -->
+ <rect
+ x="4"
+ y={2 + node.screenHeight - node.currentFillHeight}
+ width={node.iconSize - 8}
+ height={node.currentFillHeight}
+ fill="#374151"
+ />
+ <!-- New model memory fill (glowing yellow) -->
+ {#if node.modelUsageGB > 0 && node.isUsed}
+ <rect
+ x="4"
+ y={2 +
+ node.screenHeight -
+ node.currentFillHeight -
+ node.modelFillHeight}
+ width={node.iconSize - 8}
+ height={node.modelFillHeight}
+ fill="#FFD700"
+ filter="url(#memGlow-{filterId})"
+ class="animate-pulse-slow"
+ />
+ {/if}
+ <!-- Base/keyboard -->
+ <path
+ d="M 0 {node.iconSize *
+ 0.68} L {node.iconSize} {node.iconSize *
+ 0.68} L {node.iconSize - 2} {node.iconSize *
+ 0.78} L 2 {node.iconSize * 0.78} Z"
+ fill="none"
+ stroke={node.isUsed ? "#FFD700" : "#4B5563"}
+ stroke-width="1.5"
+ />
+ </g>
+ {:else if node.deviceType === "studio"}
+ <!-- Mac Studio icon -->
+ <g
+ transform="translate({-node.iconSize / 2}, {-node.iconSize /
+ 2})"
+ >
+ <rect
+ x="2"
+ y="2"
+ width={node.iconSize - 4}
+ height={node.iconSize - 4}
+ rx="4"
+ fill="none"
+ stroke={node.isUsed ? "#FFD700" : "#4B5563"}
+ stroke-width="1.5"
+ />
+ <!-- Memory fill background -->
+ <rect
+ x="4"
+ y="4"
+ width={node.iconSize - 8}
+ height={node.iconSize - 8}
+ fill="#0a0a0a"
+ />
+ <!-- Current memory fill -->
+ <rect
+ x="4"
+ y={4 +
+ (node.iconSize - 8) * (1 - node.currentPercent / 100)}
+ width={node.iconSize - 8}
+ height={(node.iconSize - 8) * (node.currentPercent / 100)}
+ fill="#374151"
+ />
+ <!-- New model memory fill -->
+ {#if node.modelUsageGB > 0 && node.isUsed}
+ <rect
+ x="4"
+ y={4 + (node.iconSize - 8) * (1 - node.newPercent / 100)}
+ width={node.iconSize - 8}
+ height={(node.iconSize - 8) *
+ ((node.newPercent - node.currentPercent) / 100)}
+ fill="#FFD700"
+ filter="url(#memGlow-{filterId})"
+ class="animate-pulse-slow"
+ />
+ {/if}
+ </g>
+ {:else if node.deviceType === "mini"}
+ <!-- Mac Mini icon -->
+ <g
+ transform="translate({-node.iconSize / 2}, {-node.iconSize /
+ 2})"
+ >
+ <rect
+ x="2"
+ y={node.iconSize * 0.3}
+ width={node.iconSize - 4}
+ height={node.iconSize * 0.4}
+ rx="3"
+ fill="none"
+ stroke={node.isUsed ? "#FFD700" : "#4B5563"}
+ stroke-width="1.5"
+ />
+ <!-- Memory fill background -->
+ <rect
+ x="4"
+ y={node.iconSize * 0.32}
+ width={node.iconSize - 8}
+ height={node.iconSize * 0.36}
+ fill="#0a0a0a"
+ />
+ <!-- Current memory fill -->
+ <rect
+ x="4"
+ y={node.iconSize * 0.32 +
+ node.iconSize * 0.36 * (1 - node.currentPercent / 100)}
+ width={node.iconSize - 8}
+ height={node.iconSize * 0.36 * (node.currentPercent / 100)}
+ fill="#374151"
+ />
+ <!-- New model memory fill -->
+ {#if node.modelUsageGB > 0 && node.isUsed}
+ <rect
+ x="4"
+ y={node.iconSize * 0.32 +
+ node.iconSize * 0.36 * (1 - node.newPercent / 100)}
+ width={node.iconSize - 8}
+ height={node.iconSize *
+ 0.36 *
+ ((node.newPercent - node.currentPercent) / 100)}
+ fill="#FFD700"
+ filter="url(#memGlow-{filterId})"
+ class="animate-pulse-slow"
+ />
+ {/if}
+ </g>
+ {:else}
+ <!-- Unknown device - hexagon -->
+ <g
+ transform="translate({-node.iconSize / 2}, {-node.iconSize /
+ 2})"
+ >
+ <polygon
+ points="{node.iconSize /
+ 2},0 {node.iconSize},{node.iconSize *
+ 0.25} {node.iconSize},{node.iconSize *
+ 0.75} {node.iconSize /
+ 2},{node.iconSize} 0,{node.iconSize *
+ 0.75} 0,{node.iconSize * 0.25}"
+ fill={node.isUsed ? "rgba(255,215,0,0.1)" : "#0a0a0a"}
+ stroke={node.isUsed ? "#FFD700" : "#4B5563"}
+ stroke-width="1.5"
+ />
+ </g>
+ {/if}
+
+ <!-- Percentage label -->
+ <text
+ y={node.iconSize / 2 + 12}
+ text-anchor="middle"
+ font-size="8"
+ font-family="SF Mono, Monaco, monospace"
+ fill={node.isUsed
+ ? node.newPercent > 90
+ ? "#f87171"
+ : "#FFD700"
+ : "#4B5563"}
+ >
+ {node.newPercent.toFixed(0)}%
+ </text>
+ </g>
+ {/each}
+ </svg>
+ </div>
+ {/if}
+
+ <!-- Launch Button -->
+ <button
+ onclick={onLaunch}
+ disabled={isLaunching || !canFit}
+ class="w-full py-2 text-sm font-mono tracking-wider uppercase border transition-all duration-200
+ {isLaunching
+ ? 'bg-transparent text-exo-yellow border-exo-yellow/50 cursor-wait'
+ : !canFit
+ ? 'bg-red-500/10 text-red-400/70 border-red-500/30 cursor-not-allowed'
+ : 'bg-transparent text-exo-light-gray border-exo-light-gray/40 hover:text-exo-yellow hover:border-exo-yellow/50 cursor-pointer'}"
+ >
+ {#if isLaunching}
+ <span class="flex items-center justify-center gap-1.5">
+ <span
+ class="w-2 h-2 border border-exo-yellow border-t-transparent rounded-full animate-spin"
+ ></span>
+ LAUNCHING...
+ </span>
+ {:else if !canFit}
+ INSUFFICIENT MEMORY
+ {:else}
+ ▸ LAUNCH
+ {/if}
+ </button>
+ </div>
</div>
<style>
- @keyframes pulse-slow {
- 0%, 100% { opacity: 0.8; }
- 50% { opacity: 1; }
- }
- .animate-pulse-slow {
- animation: pulse-slow 1.5s ease-in-out infinite;
- }
+ @keyframes pulse-slow {
+ 0%,
+ 100% {
+ opacity: 0.8;
+ }
+ 50% {
+ opacity: 1;
+ }
+ }
+ .animate-pulse-slow {
+ animation: pulse-slow 1.5s ease-in-out infinite;
+ }
</style>
diff --git a/dashboard/src/lib/components/TopologyGraph.svelte b/dashboard/src/lib/components/TopologyGraph.svelte
index c4d9b041..41748977 100644
--- a/dashboard/src/lib/components/TopologyGraph.svelte
+++ b/dashboard/src/lib/components/TopologyGraph.svelte
@@ -1,983 +1,1116 @@
<script lang="ts">
- import { onMount, onDestroy } from 'svelte';
- import * as d3 from 'd3';
-import { topologyData, isTopologyMinimized, debugMode, type NodeInfo } from '$lib/stores/app.svelte';
-
- interface Props {
- class?: string;
- highlightedNodes?: Set<string>;
- }
-
- let { class: className = '', highlightedNodes = new Set() }: Props = $props();
-
- let svgContainer: SVGSVGElement | undefined = $state();
- let resizeObserver: ResizeObserver | undefined;
-
-const isMinimized = $derived(isTopologyMinimized());
-const data = $derived(topologyData());
-const debugEnabled = $derived(debugMode());
-
-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 };
-
- // Strip port if present (e.g., "192.168.1.1:8080" -> "192.168.1.1")
- const cleanIp = ip.includes(':') && !ip.includes('[') ? ip.split(':')[0] : ip;
-
- // Helper to check a node's interfaces
- function checkNode(node: NodeInfo | undefined): string | null {
- if (!node) return null;
-
- const matchFromInterfaces = node.network_interfaces?.find((iface) =>
- (iface.addresses || []).some((addr) => addr === cleanIp || addr === ip)
- );
- if (matchFromInterfaces?.name) {
- return matchFromInterfaces.name;
- }
-
- if (node.ip_to_interface) {
- const mapped = node.ip_to_interface[cleanIp] || (ip ? node.ip_to_interface[ip] : undefined);
- if (mapped && mapped.trim().length > 0) {
- return mapped;
- }
- }
- return null;
- }
-
- // Try specified node first
- const result = checkNode(data?.nodes?.[nodeId]);
- if (result) return { label: result, missing: false };
-
- // Fallback: search all nodes for this IP
- for (const [, otherNode] of Object.entries(data?.nodes || {})) {
- const otherResult = checkNode(otherNode);
- if (otherResult) return { label: otherResult, missing: false };
- }
-
- return { label: '?', missing: true };
-}
-
-function wrapLine(text: string, maxLen: number): string[] {
- if (text.length <= maxLen) return [text];
- const words = text.split(' ');
- const lines: string[] = [];
- let current = '';
- for (const word of words) {
- if (word.length > maxLen) {
- if (current) {
- lines.push(current);
- current = '';
- }
- for (let i = 0; i < word.length; i += maxLen) {
- lines.push(word.slice(i, i + maxLen));
- }
- } else if ((current + ' ' + word).trim().length > maxLen) {
- lines.push(current);
- current = word;
- } else {
- current = current ? `${current} ${word}` : word;
- }
- }
- if (current) lines.push(current);
- return lines;
-}
-
-
- // Apple logo path for MacBook Pro screen
- const APPLE_LOGO_PATH = "M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z";
- const LOGO_NATIVE_WIDTH = 814;
- const LOGO_NATIVE_HEIGHT = 1000;
-
- function formatBytes(bytes: number, decimals = 1): string {
- if (!bytes || bytes === 0) return '0B';
- 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 getTemperatureColor(temp: number): string {
- // Default for N/A temp - light gray
- if (isNaN(temp) || temp === null) return 'rgba(179, 179, 179, 0.8)';
-
- const coolTemp = 45; // Temp for pure blue
- const midTemp = 57.5; // Temp for pure yellow
- const hotTemp = 75; // Temp for pure red
-
- const coolColor = { r: 93, g: 173, b: 226 }; // #5DADE2 (Blue)
- const midColor = { r: 255, g: 215, b: 0 }; // #FFD700 (Yellow)
- const hotColor = { r: 244, g: 67, b: 54 }; // #F44336 (Red)
-
- let r: number, g: number, b: number;
-
- if (temp <= coolTemp) {
- ({ r, g, b } = coolColor);
- } else if (temp <= midTemp) {
- const ratio = (temp - coolTemp) / (midTemp - coolTemp);
- r = Math.round(coolColor.r * (1 - ratio) + midColor.r * ratio);
- g = Math.round(coolColor.g * (1 - ratio) + midColor.g * ratio);
- b = Math.round(coolColor.b * (1 - ratio) + midColor.b * ratio);
- } else if (temp < hotTemp) {
- const ratio = (temp - midTemp) / (hotTemp - midTemp);
- r = Math.round(midColor.r * (1 - ratio) + hotColor.r * ratio);
- g = Math.round(midColor.g * (1 - ratio) + hotColor.g * ratio);
- b = Math.round(midColor.b * (1 - ratio) + hotColor.b * ratio);
- } else {
- ({ r, g, b } = hotColor);
- }
-
- return `rgb(${r}, ${g}, ${b})`;
- }
-
- function renderGraph() {
- if (!svgContainer || !data) return;
-
- d3.select(svgContainer).selectAll('*').remove();
-
- const nodes = data.nodes || {};
- const edges = data.edges || [];
- const nodeIds = Object.keys(nodes);
-
- const rect = svgContainer.getBoundingClientRect();
- const width = rect.width;
- const height = rect.height;
- const centerX = width / 2;
- const centerY = height / 2;
-
- const svg = d3.select(svgContainer);
-
- // Add defs for clip paths and filters
- const defs = svg.append('defs');
-
- // Glow filter
- const glowFilter = defs.append('filter')
- .attr('id', 'glow')
- .attr('x', '-50%')
- .attr('y', '-50%')
- .attr('width', '200%')
- .attr('height', '200%');
- glowFilter.append('feGaussianBlur')
- .attr('stdDeviation', '2')
- .attr('result', 'coloredBlur');
- const glowMerge = glowFilter.append('feMerge');
- glowMerge.append('feMergeNode').attr('in', 'coloredBlur');
- glowMerge.append('feMergeNode').attr('in', 'SourceGraphic');
-
- // Arrowhead marker for directional edges
- const marker = defs.append('marker')
- .attr('id', 'arrowhead')
- .attr('viewBox', '0 0 10 10')
- .attr('refX', '10')
- .attr('refY', '5')
- .attr('markerWidth', '11')
- .attr('markerHeight', '11')
- .attr('orient', 'auto-start-reverse');
- marker.append('path')
- .attr('d', 'M 0 0 L 10 5 L 0 10')
- .attr('fill', 'none')
- .attr('stroke', 'var(--exo-light-gray, #B3B3B3)')
- .attr('stroke-width', '1.6')
- .attr('stroke-linecap', 'round')
- .attr('stroke-linejoin', 'round')
- .style('animation', 'none');
-
- if (nodeIds.length === 0) {
- svg.append('text')
- .attr('x', centerX)
- .attr('y', centerY)
- .attr('text-anchor', 'middle')
- .attr('dominant-baseline', 'middle')
- .attr('fill', 'rgba(255,215,0,0.4)')
- .attr('font-size', isMinimized ? 10 : 12)
- .attr('font-family', 'SF Mono, monospace')
- .attr('letter-spacing', '0.1em')
- .text('AWAITING NODES');
- return;
- }
-
- const numNodes = nodeIds.length;
- const minDimension = Math.min(width, height);
-
- // Dynamic scaling - larger nodes for big displays
- const sizeScale = numNodes === 1 ? 1 : Math.max(0.6, 1 - (numNodes - 1) * 0.10);
- const baseNodeRadius = isMinimized
- ? Math.max(36, Math.min(60, minDimension * 0.22))
- : Math.min(120, minDimension * 0.20);
- const nodeRadius = baseNodeRadius * sizeScale;
-
- // Orbit radius - balanced spacing for nodes
- const circumference = numNodes * nodeRadius * 4;
- const radiusFromCircumference = circumference / (2 * Math.PI);
- const minOrbitRadius = Math.max(radiusFromCircumference, minDimension * 0.18);
- const maxOrbitRadius = minDimension * 0.30;
- const orbitRadius = isMinimized
- ? Math.min(maxOrbitRadius, Math.max(minOrbitRadius, minDimension * 0.26))
- : Math.min(maxOrbitRadius, Math.max(minOrbitRadius, minDimension * (0.22 + numNodes * 0.02)));
-
- // Determine display mode based on space and node count
- const showFullLabels = !isMinimized && numNodes <= 4;
- const showCompactLabels = !isMinimized && numNodes > 4;
-
- // Add padding for labels (top/bottom)
- const topPadding = 70; // Space for "NETWORK TOPOLOGY" label and node names
- const bottomPadding = 70; // Space for stats and bottom label
- const safeCenterY = topPadding + (height - topPadding - bottomPadding) / 2;
-
- // Calculate node positions
- const nodesWithPositions = nodeIds.map((id, index) => {
- if (numNodes === 1) {
- // Single node: center it
- return {
- id,
- data: nodes[id],
- x: centerX,
- y: safeCenterY
- };
- }
- // Distribute nodes around the orbit
- // Start from top (-90 degrees) and go clockwise
- const angle = (index / numNodes) * 2 * Math.PI - (Math.PI / 2);
- return {
- id,
- data: nodes[id],
- x: centerX + orbitRadius * Math.cos(angle),
- y: safeCenterY + orbitRadius * Math.sin(angle)
- };
- });
-
- const positionById: Record<string, { x: number; y: number }> = {};
- nodesWithPositions.forEach(n => { positionById[n.id] = { x: n.x, y: n.y }; });
-
- // Draw edges
- const linksGroup = svg.append('g').attr('class', 'links-group');
- const arrowsGroup = svg.append('g').attr('class', 'arrows-group');
- const debugLabelsGroup = svg.append('g').attr('class', 'debug-edge-labels');
-
- type ConnectionInfo = { from: string; to: string; ip: string; ifaceLabel: string; missingIface: boolean };
- type PairEntry = { a: string; b: string; aToB: boolean; bToA: boolean; connections: ConnectionInfo[] };
- type DebugEdgeLabelEntry = { connections: ConnectionInfo[]; isLeft: boolean; isTop: boolean; mx: number; my: number };
- const pairMap = new Map<string, PairEntry>();
- const debugEdgeLabels: DebugEdgeLabelEntry[] = [];
- edges.forEach(edge => {
- if (!edge.source || !edge.target || edge.source === edge.target) return;
- if (!positionById[edge.source] || !positionById[edge.target]) return;
-
- const a = edge.source < edge.target ? edge.source : edge.target;
- const b = edge.source < edge.target ? edge.target : edge.source;
- const key = `${a}|${b}`;
- const entry = pairMap.get(key) || { a, b, aToB: false, bToA: false, connections: [] };
-
- if (edge.source === a) entry.aToB = true;
- else entry.bToA = true;
-
- const ip = edge.sendBackIp || '?';
- const ifaceInfo = getInterfaceLabel(edge.source, ip);
- entry.connections.push({
- from: edge.source,
- to: edge.target,
- ip,
- ifaceLabel: ifaceInfo.label,
- missingIface: ifaceInfo.missing
- });
- pairMap.set(key, entry);
- });
-
- pairMap.forEach(entry => {
- const posA = positionById[entry.a];
- const posB = positionById[entry.b];
- if (!posA || !posB) return;
-
- // Base dashed line
- linksGroup.append('line')
- .attr('x1', posA.x)
- .attr('y1', posA.y)
- .attr('x2', posB.x)
- .attr('y2', posB.y)
- .attr('class', 'graph-link');
-
- // Calculate midpoint and direction for arrows
- const dx = posB.x - posA.x;
- const dy = posB.y - posA.y;
- const len = Math.hypot(dx, dy) || 1;
- const ux = dx / len;
- const uy = dy / len;
- const mx = (posA.x + posB.x) / 2;
- const my = (posA.y + posB.y) / 2;
- const tipOffset = 16; // Distance from center for arrow tips
- const carrier = 2; // Short segment length for arrow orientation
-
- // Arrow A -> B (if connection exists in that direction)
- if (entry.aToB) {
- const tipX = mx - ux * tipOffset;
- const tipY = my - uy * tipOffset;
- arrowsGroup.append('line')
- .attr('x1', tipX - ux * carrier)
- .attr('y1', tipY - uy * carrier)
- .attr('x2', tipX)
- .attr('y2', tipY)
- .attr('stroke', 'none')
- .attr('fill', 'none')
- .attr('marker-end', 'url(#arrowhead)');
- }
-
- // Arrow B -> A (if connection exists in that direction)
- if (entry.bToA) {
- const tipX = mx + ux * tipOffset;
- const tipY = my + uy * tipOffset;
- arrowsGroup.append('line')
- .attr('x1', tipX + ux * carrier)
- .attr('y1', tipY + uy * carrier)
- .attr('x2', tipX)
- .attr('y2', tipY)
- .attr('stroke', 'none')
- .attr('fill', 'none')
- .attr('marker-end', 'url(#arrowhead)');
- }
-
- // Collect debug labels for later positioning at edges
- if (debugEnabled && entry.connections.length > 0) {
- // Determine which side of viewport based on edge midpoint
- const isLeft = mx < centerX;
- const isTop = my < safeCenterY;
-
- // Store for batch rendering after all edges processed
- debugEdgeLabels.push({
- connections: entry.connections,
- isLeft,
- isTop,
- mx,
- my
- });
- }
- });
-
- // Render debug labels at viewport edges/corners
- if (debugEdgeLabels && debugEdgeLabels.length > 0) {
- const fontSize = isMinimized ? 10 : 12;
- const lineHeight = fontSize + 4;
- const padding = 10;
-
- // Helper to get arrow based on direction vector
- function getArrow(fromId: string, toId: string): string {
- const fromPos = positionById[fromId];
- const toPos = positionById[toId];
- if (!fromPos || !toPos) return '→';
-
- const dirX = toPos.x - fromPos.x;
- const dirY = toPos.y - fromPos.y;
- const absX = Math.abs(dirX);
- const absY = Math.abs(dirY);
-
- if (absX > absY * 2) {
- return dirX > 0 ? '→' : '←';
- } else if (absY > absX * 2) {
- return dirY > 0 ? '↓' : '↑';
- } else {
- if (dirX > 0 && dirY > 0) return '↘';
- if (dirX > 0 && dirY < 0) return '↗';
- if (dirX < 0 && dirY > 0) return '↙';
- return '↖';
- }
- }
-
- // Group by quadrant: topLeft, topRight, bottomLeft, bottomRight
- const quadrants: Record<string, DebugEdgeLabelEntry[]> = {
- topLeft: [],
- topRight: [],
- bottomLeft: [],
- bottomRight: []
- };
-
- debugEdgeLabels.forEach(edge => {
- const key = (edge.isTop ? 'top' : 'bottom') + (edge.isLeft ? 'Left' : 'Right');
- quadrants[key].push(edge);
- });
-
- // Render each quadrant
- Object.entries(quadrants).forEach(([quadrant, quadrantEdges]) => {
- if (quadrantEdges.length === 0) return;
-
- const isLeft = quadrant.includes('Left');
- const isTop = quadrant.includes('top');
-
- let baseX = isLeft ? padding : width - padding;
- let baseY = isTop ? padding : height - padding;
- const textAnchor = isLeft ? 'start' : 'end';
-
- let currentY = baseY;
-
- quadrantEdges.forEach(edge => {
- edge.connections.forEach(conn => {
- const arrow = getArrow(conn.from, conn.to);
- const label = `${arrow} ${conn.ip} ${conn.ifaceLabel}`;
- debugLabelsGroup.append('text')
- .attr('x', baseX)
- .attr('y', currentY)
- .attr('text-anchor', textAnchor)
- .attr('dominant-baseline', isTop ? 'hanging' : 'auto')
- .attr('font-size', fontSize)
- .attr('font-family', 'SF Mono, monospace')
- .attr('fill', conn.missingIface ? 'rgba(248,113,113,0.9)' : 'rgba(255,255,255,0.85)')
- .text(label);
- currentY += isTop ? lineHeight : -lineHeight;
- });
- });
- });
- }
-
- // Draw nodes
- const nodesGroup = svg.append('g').attr('class', 'nodes-group');
-
- nodesWithPositions.forEach(nodeInfo => {
- const node = nodeInfo.data;
- const macmon = node.macmon_info;
- const modelId = node.system_info?.model_id || 'Unknown';
- const friendlyName = node.friendly_name || modelId;
-
- let ramUsagePercent = 0;
- let gpuTemp = NaN;
- let ramTotal = 0;
- let ramUsed = 0;
- let gpuUsagePercent = 0;
- let sysPower: number | null = null;
-
- if (macmon) {
- if (macmon.memory && macmon.memory.ram_total > 0) {
- ramUsagePercent = (macmon.memory.ram_usage / macmon.memory.ram_total) * 100;
- ramTotal = macmon.memory.ram_total;
- ramUsed = macmon.memory.ram_usage;
- }
- if (macmon.temp && typeof macmon.temp.gpu_temp_avg === 'number') {
- gpuTemp = Math.max(30, macmon.temp.gpu_temp_avg);
- }
- if (macmon.gpu_usage) {
- gpuUsagePercent = macmon.gpu_usage[1] * 100;
- }
- if (macmon.sys_power) {
- sysPower = macmon.sys_power;
- }
- }
-
- const nodeG = nodesGroup.append('g')
- .attr('class', 'graph-node')
- .style('cursor', 'pointer');
-
- // Add tooltip
- nodeG.append('title')
- .text(`${friendlyName}\nID: ${nodeInfo.id.slice(-8)}\nMemory: ${formatBytes(ramUsed)}/${formatBytes(ramTotal)}`);
-
- let iconBaseWidth = nodeRadius * 1.2;
- let iconBaseHeight = nodeRadius * 1.0;
- const clipPathId = `clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, '-')}`;
-
- const modelLower = modelId.toLowerCase();
-
- // Check if this node should be highlighted (from hovered instance)
- const isHighlighted = highlightedNodes.has(nodeInfo.id);
-
- // Holographic wireframe colors - yellow border when highlighted
- const wireColor = isHighlighted ? 'rgba(255,215,0,0.9)' : 'rgba(179,179,179,0.8)';
- const wireColorBright = 'rgba(255,255,255,0.9)';
- const fillColor = isHighlighted ? 'rgba(255,215,0,0.15)' : 'rgba(255,215,0,0.08)';
- const strokeWidth = isHighlighted ? 2.5 : 1.5;
- const screenFill = 'rgba(0,20,40,0.9)';
- const glowColor = 'rgba(255,215,0,0.3)';
-
- if (modelLower === 'mac studio') {
- // Mac Studio - classic cube with memory fill
- iconBaseWidth = nodeRadius * 1.25;
- iconBaseHeight = nodeRadius * 0.85;
- const x = nodeInfo.x - iconBaseWidth / 2;
- const y = nodeInfo.y - iconBaseHeight / 2;
- const cornerRadius = 4;
- const topSurfaceHeight = iconBaseHeight * 0.15;
-
- // Create clip path for memory fill area (front body)
- const studioClipId = `studio-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, '-')}`;
- defs.append('clipPath')
- .attr('id', studioClipId)
- .append('rect')
- .attr('x', x)
- .attr('y', y + topSurfaceHeight)
- .attr('width', iconBaseWidth)
- .attr('height', iconBaseHeight - topSurfaceHeight)
- .attr('rx', cornerRadius - 1);
-
- // Main body (uniform color)
- nodeG.append('rect')
- .attr('x', x)
- .attr('y', y)
- .attr('width', iconBaseWidth)
- .attr('height', iconBaseHeight)
- .attr('rx', cornerRadius)
- .attr('fill', '#1a1a1a')
- .attr('stroke', wireColor)
- .attr('stroke-width', strokeWidth);
-
- // Memory fill (fills from bottom up)
- if (ramUsagePercent > 0) {
- const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
- const memFillActualHeight = (ramUsagePercent / 100) * memFillTotalHeight;
- nodeG.append('rect')
- .attr('x', x)
- .attr('y', y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight))
- .attr('width', iconBaseWidth)
- .attr('height', memFillActualHeight)
- .attr('fill', 'rgba(255,215,0,0.75)')
- .attr('clip-path', `url(#${studioClipId})`);
- }
-
- // Front panel details - vertical slots
- const detailColor = 'rgba(0,0,0,0.35)';
- const slotHeight = iconBaseHeight * 0.14;
- const vSlotWidth = iconBaseWidth * 0.05;
- const vSlotY = y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.6;
- const vSlot1X = x + iconBaseWidth * 0.18;
- const vSlot2X = x + iconBaseWidth * 0.28;
-
- [vSlot1X, vSlot2X].forEach(vx => {
- nodeG.append('rect')
- .attr('x', vx - vSlotWidth / 2)
- .attr('y', vSlotY)
- .attr('width', vSlotWidth)
- .attr('height', slotHeight)
- .attr('fill', detailColor)
- .attr('rx', 1.5);
- });
-
- // Horizontal slot (SD card)
- const hSlotWidth = iconBaseWidth * 0.2;
- const hSlotX = x + iconBaseWidth * 0.5 - hSlotWidth / 2;
- nodeG.append('rect')
- .attr('x', hSlotX)
- .attr('y', vSlotY)
- .attr('width', hSlotWidth)
- .attr('height', slotHeight * 0.6)
- .attr('fill', detailColor)
- .attr('rx', 1);
-
- } else if (modelLower === 'mac mini') {
- // Mac Mini - classic flat box with memory fill
- iconBaseWidth = nodeRadius * 1.3;
- iconBaseHeight = nodeRadius * 0.7;
- const x = nodeInfo.x - iconBaseWidth / 2;
- const y = nodeInfo.y - iconBaseHeight / 2;
- const cornerRadius = 3;
- const topSurfaceHeight = iconBaseHeight * 0.20;
-
- // Create clip path for memory fill area
- const miniClipId = `mini-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, '-')}`;
- defs.append('clipPath')
- .attr('id', miniClipId)
- .append('rect')
- .attr('x', x)
- .attr('y', y + topSurfaceHeight)
- .attr('width', iconBaseWidth)
- .attr('height', iconBaseHeight - topSurfaceHeight)
- .attr('rx', cornerRadius - 1);
-
- // Main body (uniform color)
- nodeG.append('rect')
- .attr('x', x)
- .attr('y', y)
- .attr('width', iconBaseWidth)
- .attr('height', iconBaseHeight)
- .attr('rx', cornerRadius)
- .attr('fill', '#1a1a1a')
- .attr('stroke', wireColor)
- .attr('stroke-width', strokeWidth);
-
- // Memory fill (fills from bottom up)
- if (ramUsagePercent > 0) {
- const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
- const memFillActualHeight = (ramUsagePercent / 100) * memFillTotalHeight;
- nodeG.append('rect')
- .attr('x', x)
- .attr('y', y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight))
- .attr('width', iconBaseWidth)
- .attr('height', memFillActualHeight)
- .attr('fill', 'rgba(255,215,0,0.75)')
- .attr('clip-path', `url(#${miniClipId})`);
- }
-
- // Front panel details - vertical slots (no horizontal slot for Mini)
- const detailColor = 'rgba(0,0,0,0.35)';
- const slotHeight = iconBaseHeight * 0.20;
- const vSlotWidth = iconBaseWidth * 0.045;
- const vSlotY = y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.45;
- const vSlot1X = x + iconBaseWidth * 0.20;
- const vSlot2X = x + iconBaseWidth * 0.30;
-
- [vSlot1X, vSlot2X].forEach(vx => {
- nodeG.append('rect')
- .attr('x', vx - vSlotWidth / 2)
- .attr('y', vSlotY)
- .attr('width', vSlotWidth)
- .attr('height', slotHeight)
- .attr('fill', detailColor)
- .attr('rx', 1.2);
- });
-
- } else if (modelLower === 'macbook pro' || modelLower.includes('macbook')) {
- // MacBook Pro - classic style with memory fill on screen
- iconBaseWidth = nodeRadius * 1.6;
- iconBaseHeight = nodeRadius * 1.15;
- const x = nodeInfo.x - iconBaseWidth / 2;
- const y = nodeInfo.y - iconBaseHeight / 2;
-
- const screenHeight = iconBaseHeight * 0.70;
- const baseHeight = iconBaseHeight * 0.30;
- const screenWidth = iconBaseWidth * 0.85;
- const screenX = nodeInfo.x - screenWidth / 2;
- const screenBezel = 3;
-
- // Create clip path for screen content
- const screenClipId = `screen-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, '-')}`;
- defs.append('clipPath')
- .attr('id', screenClipId)
- .append('rect')
- .attr('x', screenX + screenBezel)
- .attr('y', y + screenBezel)
- .attr('width', screenWidth - screenBezel * 2)
- .attr('height', screenHeight - screenBezel * 2)
- .attr('rx', 2);
-
- // Screen outer frame
- nodeG.append('rect')
- .attr('x', screenX)
- .attr('y', y)
- .attr('width', screenWidth)
- .attr('height', screenHeight)
- .attr('rx', 3)
- .attr('fill', '#1a1a1a')
- .attr('stroke', wireColor)
- .attr('stroke-width', strokeWidth);
-
- // Screen inner (dark background)
- nodeG.append('rect')
- .attr('x', screenX + screenBezel)
- .attr('y', y + screenBezel)
- .attr('width', screenWidth - screenBezel * 2)
- .attr('height', screenHeight - screenBezel * 2)
- .attr('rx', 2)
- .attr('fill', '#0a0a12');
-
- // Memory fill on screen (fills from bottom up - classic style)
- if (ramUsagePercent > 0) {
- const memFillTotalHeight = screenHeight - screenBezel * 2;
- const memFillActualHeight = (ramUsagePercent / 100) * memFillTotalHeight;
- nodeG.append('rect')
- .attr('x', screenX + screenBezel)
- .attr('y', y + screenBezel + (memFillTotalHeight - memFillActualHeight))
- .attr('width', screenWidth - screenBezel * 2)
- .attr('height', memFillActualHeight)
- .attr('fill', 'rgba(255,215,0,0.85)')
- .attr('clip-path', `url(#${screenClipId})`);
- }
-
- // Apple logo on screen (centered, on top of memory fill)
- const targetLogoHeight = screenHeight * 0.22;
- const logoScale = targetLogoHeight / LOGO_NATIVE_HEIGHT;
- const logoX = nodeInfo.x - (LOGO_NATIVE_WIDTH * logoScale / 2);
- const logoY = y + screenHeight / 2 - (LOGO_NATIVE_HEIGHT * logoScale / 2);
- nodeG.append('path')
- .attr('d', APPLE_LOGO_PATH)
- .attr('transform', `translate(${logoX}, ${logoY}) scale(${logoScale})`)
- .attr('fill', '#FFFFFF')
- .attr('opacity', 0.9);
-
- // Base (keyboard) - trapezoidal
- const baseY = y + screenHeight;
- const baseTopWidth = screenWidth;
- const baseBottomWidth = iconBaseWidth;
- const baseTopX = nodeInfo.x - baseTopWidth / 2;
- const baseBottomX = nodeInfo.x - baseBottomWidth / 2;
-
- nodeG.append('path')
- .attr('d', `M ${baseTopX} ${baseY} L ${baseTopX + baseTopWidth} ${baseY} L ${baseBottomX + baseBottomWidth} ${baseY + baseHeight} L ${baseBottomX} ${baseY + baseHeight} Z`)
- .attr('fill', '#2c2c2c')
- .attr('stroke', wireColor)
- .attr('stroke-width', 1);
-
- // Keyboard area
- const keyboardX = baseTopX + 6;
- const keyboardY = baseY + 3;
- const keyboardWidth = baseTopWidth - 12;
- const keyboardHeight = baseHeight * 0.55;
- nodeG.append('rect')
- .attr('x', keyboardX)
- .attr('y', keyboardY)
- .attr('width', keyboardWidth)
- .attr('height', keyboardHeight)
- .attr('fill', 'rgba(0,0,0,0.2)')
- .attr('rx', 2);
-
- // Trackpad
- const trackpadWidth = baseTopWidth * 0.4;
- const trackpadX = nodeInfo.x - trackpadWidth / 2;
- const trackpadY = baseY + keyboardHeight + 5;
- const trackpadHeight = baseHeight * 0.30;
- nodeG.append('rect')
- .attr('x', trackpadX)
- .attr('y', trackpadY)
- .attr('width', trackpadWidth)
- .attr('height', trackpadHeight)
- .attr('fill', 'rgba(255,255,255,0.08)')
- .attr('rx', 2);
-
- } else {
- // Default/Unknown - holographic hexagon
- const hexRadius = nodeRadius * 0.6;
- const hexPoints = Array.from({ length: 6 }, (_, i) => {
- const angle = (i * 60 - 30) * Math.PI / 180;
- return `${nodeInfo.x + hexRadius * Math.cos(angle)},${nodeInfo.y + hexRadius * Math.sin(angle)}`;
- }).join(' ');
-
- // Main shape
- nodeG.append('polygon')
- .attr('points', hexPoints)
- .attr('fill', fillColor)
- .attr('stroke', wireColor)
- .attr('stroke-width', strokeWidth);
- }
-
- // --- Vertical GPU Bar (right side of icon) ---
- // Show in both full mode and minimized mode (scaled appropriately)
- if (showFullLabels || isMinimized) {
- const gpuBarWidth = isMinimized ? Math.max(16, nodeRadius * 0.32) : Math.max(28, nodeRadius * 0.30);
- const gpuBarHeight = iconBaseHeight * 0.95;
- const barXOffset = iconBaseWidth / 2 + (isMinimized ? 5 : 10);
- const gpuBarX = nodeInfo.x + barXOffset;
- const gpuBarY = nodeInfo.y - gpuBarHeight / 2;
-
- // GPU Bar Background (grey, no border)
- nodeG.append('rect')
- .attr('x', gpuBarX)
- .attr('y', gpuBarY)
- .attr('width', gpuBarWidth)
- .attr('height', gpuBarHeight)
- .attr('fill', 'rgba(80, 80, 90, 0.7)')
- .attr('rx', 2);
-
- // GPU Bar Fill (from bottom up, colored by temperature)
- if (gpuUsagePercent > 0) {
- const fillHeight = (gpuUsagePercent / 100) * gpuBarHeight;
- const gpuFillColor = getTemperatureColor(gpuTemp);
- nodeG.append('rect')
- .attr('x', gpuBarX)
- .attr('y', gpuBarY + (gpuBarHeight - fillHeight))
- .attr('width', gpuBarWidth)
- .attr('height', fillHeight)
- .attr('fill', gpuFillColor)
- .attr('opacity', 0.9)
- .attr('rx', 2);
- }
-
- // GPU Stats Text (centered on bar, multiline, bigger and bold)
- const gpuTextX = gpuBarX + gpuBarWidth / 2;
- const gpuTextY = gpuBarY + gpuBarHeight / 2;
- const gpuTextFontSize = isMinimized ? Math.max(10, gpuBarWidth * 0.6) : Math.min(16, Math.max(12, gpuBarWidth * 0.55));
- const lineSpacing = gpuTextFontSize * 1.25;
-
- const gpuUsageText = `${gpuUsagePercent.toFixed(0)}%`;
- const tempText = !isNaN(gpuTemp) ? `${gpuTemp.toFixed(0)}°C` : '-';
- const powerText = sysPower !== null ? `${sysPower.toFixed(0)}W` : '-';
-
- // GPU Usage %
- nodeG.append('text')
- .attr('x', gpuTextX)
- .attr('y', gpuTextY - lineSpacing)
- .attr('text-anchor', 'middle')
- .attr('dominant-baseline', 'middle')
- .attr('fill', '#FFFFFF')
- .attr('font-size', gpuTextFontSize)
- .attr('font-weight', '700')
- .attr('font-family', 'SF Mono, Monaco, monospace')
- .text(gpuUsageText);
-
- // Temperature
- nodeG.append('text')
- .attr('x', gpuTextX)
- .attr('y', gpuTextY)
- .attr('text-anchor', 'middle')
- .attr('dominant-baseline', 'middle')
- .attr('fill', '#FFFFFF')
- .attr('font-size', gpuTextFontSize)
- .attr('font-weight', '700')
- .attr('font-family', 'SF Mono, Monaco, monospace')
- .text(tempText);
-
- // Power (Watts)
- nodeG.append('text')
- .attr('x', gpuTextX)
- .attr('y', gpuTextY + lineSpacing)
- .attr('text-anchor', 'middle')
- .attr('dominant-baseline', 'middle')
- .attr('fill', '#FFFFFF')
- .attr('font-size', gpuTextFontSize)
- .attr('font-weight', '700')
- .attr('font-family', 'SF Mono, Monaco, monospace')
- .text(powerText);
- }
-
- // Labels - adapt based on mode
- if (showFullLabels) {
- // FULL MODE: Name above, memory info below (1-4 nodes)
- const nameY = nodeInfo.y - iconBaseHeight / 2 - 15;
- const fontSize = Math.max(10, nodeRadius * 0.16);
-
- // Truncate name based on node count
- const maxNameLen = numNodes === 1 ? 22 : (numNodes === 2 ? 18 : numNodes === 3 ? 16 : 14);
- const displayName = friendlyName.length > maxNameLen
- ? friendlyName.slice(0, maxNameLen - 2) + '..'
- : friendlyName;
-
- // Name label above
- nodeG.append('text')
- .attr('x', nodeInfo.x)
- .attr('y', nameY)
- .attr('text-anchor', 'middle')
- .attr('dominant-baseline', 'middle')
- .attr('fill', '#FFD700')
- .attr('font-size', fontSize)
- .attr('font-weight', 500)
- .attr('font-family', 'SF Mono, Monaco, monospace')
- .text(displayName);
-
- // Memory info below - used in grey, total in yellow
- const infoY = nodeInfo.y + iconBaseHeight / 2 + 16;
- const memText = nodeG.append('text')
- .attr('x', nodeInfo.x)
- .attr('y', infoY)
- .attr('text-anchor', 'middle')
- .attr('font-size', fontSize * 0.85)
- .attr('font-family', 'SF Mono, Monaco, monospace');
- memText.append('tspan')
- .attr('fill', 'rgba(255,215,0,0.9)')
- .text(`${formatBytes(ramUsed)}`);
- memText.append('tspan')
- .attr('fill', 'rgba(179,179,179,0.9)')
- .text(`/${formatBytes(ramTotal)}`);
- memText.append('tspan')
- .attr('fill', 'rgba(179,179,179,0.7)')
- .text(` (${ramUsagePercent.toFixed(0)}%)`);
-
- } else if (showCompactLabels) {
- // COMPACT MODE: Just name and basic info (4+ nodes)
- const fontSize = Math.max(7, nodeRadius * 0.11);
-
- // Very compact name below icon
- const nameY = nodeInfo.y + iconBaseHeight / 2 + 9;
- const shortName = friendlyName.length > 10
- ? friendlyName.slice(0, 8) + '..'
- : friendlyName;
- nodeG.append('text')
- .attr('x', nodeInfo.x)
- .attr('y', nameY)
- .attr('text-anchor', 'middle')
- .attr('fill', '#FFD700')
- .attr('font-size', fontSize)
- .attr('font-family', 'SF Mono, Monaco, monospace')
- .text(shortName);
-
- // Single line of key stats
- const statsY = nameY + 9;
- nodeG.append('text')
- .attr('x', nodeInfo.x)
- .attr('y', statsY)
- .attr('text-anchor', 'middle')
- .attr('fill', 'rgba(255,215,0,0.7)')
- .attr('font-size', fontSize * 0.85)
- .attr('font-family', 'SF Mono, Monaco, monospace')
- .text(`${ramUsagePercent.toFixed(0)}%${!isNaN(gpuTemp) ? ' ' + gpuTemp.toFixed(0) + '°C' : ''}`);
-
- } else {
- // MINIMIZED MODE: Show name above and memory info below (like main topology)
- const fontSize = 8;
-
- // Friendly name (shortened) above icon
- const nameY = nodeInfo.y - iconBaseHeight / 2 - 8;
- const shortName = friendlyName.length > 12
- ? friendlyName.slice(0, 10) + '..'
- : friendlyName;
- nodeG.append('text')
- .attr('x', nodeInfo.x)
- .attr('y', nameY)
- .attr('text-anchor', 'middle')
- .attr('fill', '#FFD700')
- .attr('font-size', fontSize)
- .attr('font-weight', '500')
- .attr('font-family', 'SF Mono, Monaco, monospace')
- .text(shortName);
-
- // Memory info below icon - used in grey, total in yellow (same as main topology)
- const infoY = nodeInfo.y + iconBaseHeight / 2 + 10;
- const memTextMini = nodeG.append('text')
- .attr('x', nodeInfo.x)
- .attr('y', infoY)
- .attr('text-anchor', 'middle')
- .attr('font-size', fontSize * 0.85)
- .attr('font-family', 'SF Mono, Monaco, monospace');
- memTextMini.append('tspan')
- .attr('fill', 'rgba(255,215,0,0.9)')
- .text(`${formatBytes(ramUsed)}`);
- memTextMini.append('tspan')
- .attr('fill', 'rgba(179,179,179,0.9)')
- .text(`/${formatBytes(ramTotal)}`);
- memTextMini.append('tspan')
- .attr('fill', 'rgba(179,179,179,0.7)')
- .text(` (${ramUsagePercent.toFixed(0)}%)`);
- }
- });
-
- }
-
- $effect(() => {
- if (data) {
- renderGraph();
- }
- });
-
- onMount(() => {
- if (svgContainer) {
- resizeObserver = new ResizeObserver(() => {
- renderGraph();
- });
- resizeObserver.observe(svgContainer);
- }
- });
-
- onDestroy(() => {
- resizeObserver?.disconnect();
- });
+ import { onMount, onDestroy } from "svelte";
+ import * as d3 from "d3";
+ import {
+ topologyData,
+ isTopologyMinimized,
+ debugMode,
+ type NodeInfo,
+ } from "$lib/stores/app.svelte";
+
+ interface Props {
+ class?: string;
+ highlightedNodes?: Set<string>;
+ }
+
+ let { class: className = "", highlightedNodes = new Set() }: Props = $props();
+
+ let svgContainer: SVGSVGElement | undefined = $state();
+ let resizeObserver: ResizeObserver | undefined;
+
+ const isMinimized = $derived(isTopologyMinimized());
+ const data = $derived(topologyData());
+ const debugEnabled = $derived(debugMode());
+
+ 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 };
+
+ // Strip port if present (e.g., "192.168.1.1:8080" -> "192.168.1.1")
+ const cleanIp =
+ ip.includes(":") && !ip.includes("[") ? ip.split(":")[0] : ip;
+
+ // Helper to check a node's interfaces
+ function checkNode(node: NodeInfo | undefined): string | null {
+ if (!node) return null;
+
+ const matchFromInterfaces = node.network_interfaces?.find((iface) =>
+ (iface.addresses || []).some((addr) => addr === cleanIp || addr === ip),
+ );
+ if (matchFromInterfaces?.name) {
+ return matchFromInterfaces.name;
+ }
+
+ if (node.ip_to_interface) {
+ const mapped =
+ node.ip_to_interface[cleanIp] ||
+ (ip ? node.ip_to_interface[ip] : undefined);
+ if (mapped && mapped.trim().length > 0) {
+ return mapped;
+ }
+ }
+ return null;
+ }
+
+ // Try specified node first
+ const result = checkNode(data?.nodes?.[nodeId]);
+ if (result) return { label: result, missing: false };
+
+ // Fallback: search all nodes for this IP
+ for (const [, otherNode] of Object.entries(data?.nodes || {})) {
+ const otherResult = checkNode(otherNode);
+ if (otherResult) return { label: otherResult, missing: false };
+ }
+
+ return { label: "?", missing: true };
+ }
+
+ function wrapLine(text: string, maxLen: number): string[] {
+ if (text.length <= maxLen) return [text];
+ const words = text.split(" ");
+ const lines: string[] = [];
+ let current = "";
+ for (const word of words) {
+ if (word.length > maxLen) {
+ if (current) {
+ lines.push(current);
+ current = "";
+ }
+ for (let i = 0; i < word.length; i += maxLen) {
+ lines.push(word.slice(i, i + maxLen));
+ }
+ } else if ((current + " " + word).trim().length > maxLen) {
+ lines.push(current);
+ current = word;
+ } else {
+ current = current ? `${current} ${word}` : word;
+ }
+ }
+ if (current) lines.push(current);
+ return lines;
+ }
+
+ // Apple logo path for MacBook Pro screen
+ const APPLE_LOGO_PATH =
+ "M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z";
+ const LOGO_NATIVE_WIDTH = 814;
+ const LOGO_NATIVE_HEIGHT = 1000;
+
+ function formatBytes(bytes: number, decimals = 1): string {
+ if (!bytes || bytes === 0) return "0B";
+ 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 getTemperatureColor(temp: number): string {
+ // Default for N/A temp - light gray
+ if (isNaN(temp) || temp === null) return "rgba(179, 179, 179, 0.8)";
+
+ const coolTemp = 45; // Temp for pure blue
+ const midTemp = 57.5; // Temp for pure yellow
+ const hotTemp = 75; // Temp for pure red
+
+ const coolColor = { r: 93, g: 173, b: 226 }; // #5DADE2 (Blue)
+ const midColor = { r: 255, g: 215, b: 0 }; // #FFD700 (Yellow)
+ const hotColor = { r: 244, g: 67, b: 54 }; // #F44336 (Red)
+
+ let r: number, g: number, b: number;
+
+ if (temp <= coolTemp) {
+ ({ r, g, b } = coolColor);
+ } else if (temp <= midTemp) {
+ const ratio = (temp - coolTemp) / (midTemp - coolTemp);
+ r = Math.round(coolColor.r * (1 - ratio) + midColor.r * ratio);
+ g = Math.round(coolColor.g * (1 - ratio) + midColor.g * ratio);
+ b = Math.round(coolColor.b * (1 - ratio) + midColor.b * ratio);
+ } else if (temp < hotTemp) {
+ const ratio = (temp - midTemp) / (hotTemp - midTemp);
+ r = Math.round(midColor.r * (1 - ratio) + hotColor.r * ratio);
+ g = Math.round(midColor.g * (1 - ratio) + hotColor.g * ratio);
+ b = Math.round(midColor.b * (1 - ratio) + hotColor.b * ratio);
+ } else {
+ ({ r, g, b } = hotColor);
+ }
+
+ return `rgb(${r}, ${g}, ${b})`;
+ }
+
+ function renderGraph() {
+ if (!svgContainer || !data) return;
+
+ d3.select(svgContainer).selectAll("*").remove();
+
+ const nodes = data.nodes || {};
+ const edges = data.edges || [];
+ const nodeIds = Object.keys(nodes);
+
+ const rect = svgContainer.getBoundingClientRect();
+ const width = rect.width;
+ const height = rect.height;
+ const centerX = width / 2;
+ const centerY = height / 2;
+
+ const svg = d3.select(svgContainer);
+
+ // Add defs for clip paths and filters
+ const defs = svg.append("defs");
+
+ // Glow filter
+ const glowFilter = defs
+ .append("filter")
+ .attr("id", "glow")
+ .attr("x", "-50%")
+ .attr("y", "-50%")
+ .attr("width", "200%")
+ .attr("height", "200%");
+ glowFilter
+ .append("feGaussianBlur")
+ .attr("stdDeviation", "2")
+ .attr("result", "coloredBlur");
+ const glowMerge = glowFilter.append("feMerge");
+ glowMerge.append("feMergeNode").attr("in", "coloredBlur");
+ glowMerge.append("feMergeNode").attr("in", "SourceGraphic");
+
+ // Arrowhead marker for directional edges
+ const marker = defs
+ .append("marker")
+ .attr("id", "arrowhead")
+ .attr("viewBox", "0 0 10 10")
+ .attr("refX", "10")
+ .attr("refY", "5")
+ .attr("markerWidth", "11")
+ .attr("markerHeight", "11")
+ .attr("orient", "auto-start-reverse");
+ marker
+ .append("path")
+ .attr("d", "M 0 0 L 10 5 L 0 10")
+ .attr("fill", "none")
+ .attr("stroke", "var(--exo-light-gray, #B3B3B3)")
+ .attr("stroke-width", "1.6")
+ .attr("stroke-linecap", "round")
+ .attr("stroke-linejoin", "round")
+ .style("animation", "none");
+
+ if (nodeIds.length === 0) {
+ svg
+ .append("text")
+ .attr("x", centerX)
+ .attr("y", centerY)
+ .attr("text-anchor", "middle")
+ .attr("dominant-baseline", "middle")
+ .attr("fill", "rgba(255,215,0,0.4)")
+ .attr("font-size", isMinimized ? 10 : 12)
+ .attr("font-family", "SF Mono, monospace")
+ .attr("letter-spacing", "0.1em")
+ .text("AWAITING NODES");
+ return;
+ }
+
+ const numNodes = nodeIds.length;
+ const minDimension = Math.min(width, height);
+
+ // Dynamic scaling - larger nodes for big displays
+ const sizeScale =
+ numNodes === 1 ? 1 : Math.max(0.6, 1 - (numNodes - 1) * 0.1);
+ const baseNodeRadius = isMinimized
+ ? Math.max(36, Math.min(60, minDimension * 0.22))
+ : Math.min(120, minDimension * 0.2);
+ const nodeRadius = baseNodeRadius * sizeScale;
+
+ // Orbit radius - balanced spacing for nodes
+ const circumference = numNodes * nodeRadius * 4;
+ const radiusFromCircumference = circumference / (2 * Math.PI);
+ const minOrbitRadius = Math.max(
+ radiusFromCircumference,
+ minDimension * 0.18,
+ );
+ const maxOrbitRadius = minDimension * 0.3;
+ const orbitRadius = isMinimized
+ ? Math.min(maxOrbitRadius, Math.max(minOrbitRadius, minDimension * 0.26))
+ : Math.min(
+ maxOrbitRadius,
+ Math.max(minOrbitRadius, minDimension * (0.22 + numNodes * 0.02)),
+ );
+
+ // Determine display mode based on space and node count
+ const showFullLabels = !isMinimized && numNodes <= 4;
+ const showCompactLabels = !isMinimized && numNodes > 4;
+
+ // Add padding for labels (top/bottom)
+ const topPadding = 70; // Space for "NETWORK TOPOLOGY" label and node names
+ const bottomPadding = 70; // Space for stats and bottom label
+ const safeCenterY = topPadding + (height - topPadding - bottomPadding) / 2;
+
+ // Calculate node positions
+ const nodesWithPositions = nodeIds.map((id, index) => {
+ if (numNodes === 1) {
+ // Single node: center it
+ return {
+ id,
+ data: nodes[id],
+ x: centerX,
+ y: safeCenterY,
+ };
+ }
+ // Distribute nodes around the orbit
+ // Start from top (-90 degrees) and go clockwise
+ const angle = (index / numNodes) * 2 * Math.PI - Math.PI / 2;
+ return {
+ id,
+ data: nodes[id],
+ x: centerX + orbitRadius * Math.cos(angle),
+ y: safeCenterY + orbitRadius * Math.sin(angle),
+ };
+ });
+
+ const positionById: Record<string, { x: number; y: number }> = {};
+ nodesWithPositions.forEach((n) => {
+ positionById[n.id] = { x: n.x, y: n.y };
+ });
+
+ // Draw edges
+ const linksGroup = svg.append("g").attr("class", "links-group");
+ const arrowsGroup = svg.append("g").attr("class", "arrows-group");
+ const debugLabelsGroup = svg.append("g").attr("class", "debug-edge-labels");
+
+ type ConnectionInfo = {
+ from: string;
+ to: string;
+ ip: string;
+ ifaceLabel: string;
+ missingIface: boolean;
+ };
+ type PairEntry = {
+ a: string;
+ b: string;
+ aToB: boolean;
+ bToA: boolean;
+ connections: ConnectionInfo[];
+ };
+ type DebugEdgeLabelEntry = {
+ connections: ConnectionInfo[];
+ isLeft: boolean;
+ isTop: boolean;
+ mx: number;
+ my: number;
+ };
+ const pairMap = new Map<string, PairEntry>();
+ const debugEdgeLabels: DebugEdgeLabelEntry[] = [];
+ edges.forEach((edge) => {
+ if (!edge.source || !edge.target || edge.source === edge.target) return;
+ if (!positionById[edge.source] || !positionById[edge.target]) return;
+
+ const a = edge.source < edge.target ? edge.source : edge.target;
+ const b = edge.source < edge.target ? edge.target : edge.source;
+ const key = `${a}|${b}`;
+ const entry = pairMap.get(key) || {
+ a,
+ b,
+ aToB: false,
+ bToA: false,
+ connections: [],
+ };
+
+ if (edge.source === a) entry.aToB = true;
+ else entry.bToA = true;
+
+ const ip = edge.sendBackIp || "?";
+ const ifaceInfo = getInterfaceLabel(edge.source, ip);
+ entry.connections.push({
+ from: edge.source,
+ to: edge.target,
+ ip,
+ ifaceLabel: ifaceInfo.label,
+ missingIface: ifaceInfo.missing,
+ });
+ pairMap.set(key, entry);
+ });
+
+ pairMap.forEach((entry) => {
+ const posA = positionById[entry.a];
+ const posB = positionById[entry.b];
+ if (!posA || !posB) return;
+
+ // Base dashed line
+ linksGroup
+ .append("line")
+ .attr("x1", posA.x)
+ .attr("y1", posA.y)
+ .attr("x2", posB.x)
+ .attr("y2", posB.y)
+ .attr("class", "graph-link");
+
+ // Calculate midpoint and direction for arrows
+ const dx = posB.x - posA.x;
+ const dy = posB.y - posA.y;
+ const len = Math.hypot(dx, dy) || 1;
+ const ux = dx / len;
+ const uy = dy / len;
+ const mx = (posA.x + posB.x) / 2;
+ const my = (posA.y + posB.y) / 2;
+ const tipOffset = 16; // Distance from center for arrow tips
+ const carrier = 2; // Short segment length for arrow orientation
+
+ // Arrow A -> B (if connection exists in that direction)
+ if (entry.aToB) {
+ const tipX = mx - ux * tipOffset;
+ const tipY = my - uy * tipOffset;
+ arrowsGroup
+ .append("line")
+ .attr("x1", tipX - ux * carrier)
+ .attr("y1", tipY - uy * carrier)
+ .attr("x2", tipX)
+ .attr("y2", tipY)
+ .attr("stroke", "none")
+ .attr("fill", "none")
+ .attr("marker-end", "url(#arrowhead)");
+ }
+
+ // Arrow B -> A (if connection exists in that direction)
+ if (entry.bToA) {
+ const tipX = mx + ux * tipOffset;
+ const tipY = my + uy * tipOffset;
+ arrowsGroup
+ .append("line")
+ .attr("x1", tipX + ux * carrier)
+ .attr("y1", tipY + uy * carrier)
+ .attr("x2", tipX)
+ .attr("y2", tipY)
+ .attr("stroke", "none")
+ .attr("fill", "none")
+ .attr("marker-end", "url(#arrowhead)");
+ }
+
+ // Collect debug labels for later positioning at edges
+ if (debugEnabled && entry.connections.length > 0) {
+ // Determine which side of viewport based on edge midpoint
+ const isLeft = mx < centerX;
+ const isTop = my < safeCenterY;
+
+ // Store for batch rendering after all edges processed
+ debugEdgeLabels.push({
+ connections: entry.connections,
+ isLeft,
+ isTop,
+ mx,
+ my,
+ });
+ }
+ });
+
+ // Render debug labels at viewport edges/corners
+ if (debugEdgeLabels && debugEdgeLabels.length > 0) {
+ const fontSize = isMinimized ? 10 : 12;
+ const lineHeight = fontSize + 4;
+ const padding = 10;
+
+ // Helper to get arrow based on direction vector
+ function getArrow(fromId: string, toId: string): string {
+ const fromPos = positionById[fromId];
+ const toPos = positionById[toId];
+ if (!fromPos || !toPos) return "→";
+
+ const dirX = toPos.x - fromPos.x;
+ const dirY = toPos.y - fromPos.y;
+ const absX = Math.abs(dirX);
+ const absY = Math.abs(dirY);
+
+ if (absX > absY * 2) {
+ return dirX > 0 ? "→" : "←";
+ } else if (absY > absX * 2) {
+ return dirY > 0 ? "↓" : "↑";
+ } else {
+ if (dirX > 0 && dirY > 0) return "↘";
+ if (dirX > 0 && dirY < 0) return "↗";
+ if (dirX < 0 && dirY > 0) return "↙";
+ return "↖";
+ }
+ }
+
+ // Group by quadrant: topLeft, topRight, bottomLeft, bottomRight
+ const quadrants: Record<string, DebugEdgeLabelEntry[]> = {
+ topLeft: [],
+ topRight: [],
+ bottomLeft: [],
+ bottomRight: [],
+ };
+
+ debugEdgeLabels.forEach((edge) => {
+ const key =
+ (edge.isTop ? "top" : "bottom") + (edge.isLeft ? "Left" : "Right");
+ quadrants[key].push(edge);
+ });
+
+ // Render each quadrant
+ Object.entries(quadrants).forEach(([quadrant, quadrantEdges]) => {
+ if (quadrantEdges.length === 0) return;
+
+ const isLeft = quadrant.includes("Left");
+ const isTop = quadrant.includes("top");
+
+ let baseX = isLeft ? padding : width - padding;
+ let baseY = isTop ? padding : height - padding;
+ const textAnchor = isLeft ? "start" : "end";
+
+ let currentY = baseY;
+
+ quadrantEdges.forEach((edge) => {
+ edge.connections.forEach((conn) => {
+ const arrow = getArrow(conn.from, conn.to);
+ const label = `${arrow} ${conn.ip} ${conn.ifaceLabel}`;
+ debugLabelsGroup
+ .append("text")
+ .attr("x", baseX)
+ .attr("y", currentY)
+ .attr("text-anchor", textAnchor)
+ .attr("dominant-baseline", isTop ? "hanging" : "auto")
+ .attr("font-size", fontSize)
+ .attr("font-family", "SF Mono, monospace")
+ .attr(
+ "fill",
+ conn.missingIface
+ ? "rgba(248,113,113,0.9)"
+ : "rgba(255,255,255,0.85)",
+ )
+ .text(label);
+ currentY += isTop ? lineHeight : -lineHeight;
+ });
+ });
+ });
+ }
+
+ // Draw nodes
+ const nodesGroup = svg.append("g").attr("class", "nodes-group");
+
+ nodesWithPositions.forEach((nodeInfo) => {
+ const node = nodeInfo.data;
+ const macmon = node.macmon_info;
+ const modelId = node.system_info?.model_id || "Unknown";
+ const friendlyName = node.friendly_name || modelId;
+
+ let ramUsagePercent = 0;
+ let gpuTemp = NaN;
+ let ramTotal = 0;
+ let ramUsed = 0;
+ let gpuUsagePercent = 0;
+ let sysPower: number | null = null;
+
+ if (macmon) {
+ if (macmon.memory && macmon.memory.ram_total > 0) {
+ ramUsagePercent =
+ (macmon.memory.ram_usage / macmon.memory.ram_total) * 100;
+ ramTotal = macmon.memory.ram_total;
+ ramUsed = macmon.memory.ram_usage;
+ }
+ if (macmon.temp && typeof macmon.temp.gpu_temp_avg === "number") {
+ gpuTemp = Math.max(30, macmon.temp.gpu_temp_avg);
+ }
+ if (macmon.gpu_usage) {
+ gpuUsagePercent = macmon.gpu_usage[1] * 100;
+ }
+ if (macmon.sys_power) {
+ sysPower = macmon.sys_power;
+ }
+ }
+
+ const nodeG = nodesGroup
+ .append("g")
+ .attr("class", "graph-node")
+ .style("cursor", "pointer");
+
+ // Add tooltip
+ nodeG
+ .append("title")
+ .text(
+ `${friendlyName}\nID: ${nodeInfo.id.slice(-8)}\nMemory: ${formatBytes(ramUsed)}/${formatBytes(ramTotal)}`,
+ );
+
+ let iconBaseWidth = nodeRadius * 1.2;
+ let iconBaseHeight = nodeRadius * 1.0;
+ const clipPathId = `clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
+
+ const modelLower = modelId.toLowerCase();
+
+ // Check if this node should be highlighted (from hovered instance)
+ const isHighlighted = highlightedNodes.has(nodeInfo.id);
+
+ // Holographic wireframe colors - yellow border when highlighted
+ const wireColor = isHighlighted
+ ? "rgba(255,215,0,0.9)"
+ : "rgba(179,179,179,0.8)";
+ const wireColorBright = "rgba(255,255,255,0.9)";
+ const fillColor = isHighlighted
+ ? "rgba(255,215,0,0.15)"
+ : "rgba(255,215,0,0.08)";
+ const strokeWidth = isHighlighted ? 2.5 : 1.5;
+ const screenFill = "rgba(0,20,40,0.9)";
+ const glowColor = "rgba(255,215,0,0.3)";
+
+ if (modelLower === "mac studio") {
+ // Mac Studio - classic cube with memory fill
+ iconBaseWidth = nodeRadius * 1.25;
+ iconBaseHeight = nodeRadius * 0.85;
+ const x = nodeInfo.x - iconBaseWidth / 2;
+ const y = nodeInfo.y - iconBaseHeight / 2;
+ const cornerRadius = 4;
+ const topSurfaceHeight = iconBaseHeight * 0.15;
+
+ // Create clip path for memory fill area (front body)
+ const studioClipId = `studio-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
+ defs
+ .append("clipPath")
+ .attr("id", studioClipId)
+ .append("rect")
+ .attr("x", x)
+ .attr("y", y + topSurfaceHeight)
+ .attr("width", iconBaseWidth)
+ .attr("height", iconBaseHeight - topSurfaceHeight)
+ .attr("rx", cornerRadius - 1);
+
+ // Main body (uniform color)
+ nodeG
+ .append("rect")
+ .attr("x", x)
+ .attr("y", y)
+ .attr("width", iconBaseWidth)
+ .attr("height", iconBaseHeight)
+ .attr("rx", cornerRadius)
+ .attr("fill", "#1a1a1a")
+ .attr("stroke", wireColor)
+ .attr("stroke-width", strokeWidth);
+
+ // Memory fill (fills from bottom up)
+ if (ramUsagePercent > 0) {
+ const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
+ const memFillActualHeight =
+ (ramUsagePercent / 100) * memFillTotalHeight;
+ nodeG
+ .append("rect")
+ .attr("x", x)
+ .attr(
+ "y",
+ y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight),
+ )
+ .attr("width", iconBaseWidth)
+ .attr("height", memFillActualHeight)
+ .attr("fill", "rgba(255,215,0,0.75)")
+ .attr("clip-path", `url(#${studioClipId})`);
+ }
+
+ // Front panel details - vertical slots
+ const detailColor = "rgba(0,0,0,0.35)";
+ const slotHeight = iconBaseHeight * 0.14;
+ const vSlotWidth = iconBaseWidth * 0.05;
+ const vSlotY =
+ y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.6;
+ const vSlot1X = x + iconBaseWidth * 0.18;
+ const vSlot2X = x + iconBaseWidth * 0.28;
+
+ [vSlot1X, vSlot2X].forEach((vx) => {
+ nodeG
+ .append("rect")
+ .attr("x", vx - vSlotWidth / 2)
+ .attr("y", vSlotY)
+ .attr("width", vSlotWidth)
+ .attr("height", slotHeight)
+ .attr("fill", detailColor)
+ .attr("rx", 1.5);
+ });
+
+ // Horizontal slot (SD card)
+ const hSlotWidth = iconBaseWidth * 0.2;
+ const hSlotX = x + iconBaseWidth * 0.5 - hSlotWidth / 2;
+ nodeG
+ .append("rect")
+ .attr("x", hSlotX)
+ .attr("y", vSlotY)
+ .attr("width", hSlotWidth)
+ .attr("height", slotHeight * 0.6)
+ .attr("fill", detailColor)
+ .attr("rx", 1);
+ } else if (modelLower === "mac mini") {
+ // Mac Mini - classic flat box with memory fill
+ iconBaseWidth = nodeRadius * 1.3;
+ iconBaseHeight = nodeRadius * 0.7;
+ const x = nodeInfo.x - iconBaseWidth / 2;
+ const y = nodeInfo.y - iconBaseHeight / 2;
+ const cornerRadius = 3;
+ const topSurfaceHeight = iconBaseHeight * 0.2;
+
+ // Create clip path for memory fill area
+ const miniClipId = `mini-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
+ defs
+ .append("clipPath")
+ .attr("id", miniClipId)
+ .append("rect")
+ .attr("x", x)
+ .attr("y", y + topSurfaceHeight)
+ .attr("width", iconBaseWidth)
+ .attr("height", iconBaseHeight - topSurfaceHeight)
+ .attr("rx", cornerRadius - 1);
+
+ // Main body (uniform color)
+ nodeG
+ .append("rect")
+ .attr("x", x)
+ .attr("y", y)
+ .attr("width", iconBaseWidth)
+ .attr("height", iconBaseHeight)
+ .attr("rx", cornerRadius)
+ .attr("fill", "#1a1a1a")
+ .attr("stroke", wireColor)
+ .attr("stroke-width", strokeWidth);
+
+ // Memory fill (fills from bottom up)
+ if (ramUsagePercent > 0) {
+ const memFillTotalHeight = iconBaseHeight - topSurfaceHeight;
+ const memFillActualHeight =
+ (ramUsagePercent / 100) * memFillTotalHeight;
+ nodeG
+ .append("rect")
+ .attr("x", x)
+ .attr(
+ "y",
+ y + topSurfaceHeight + (memFillTotalHeight - memFillActualHeight),
+ )
+ .attr("width", iconBaseWidth)
+ .attr("height", memFillActualHeight)
+ .attr("fill", "rgba(255,215,0,0.75)")
+ .attr("clip-path", `url(#${miniClipId})`);
+ }
+
+ // Front panel details - vertical slots (no horizontal slot for Mini)
+ const detailColor = "rgba(0,0,0,0.35)";
+ const slotHeight = iconBaseHeight * 0.2;
+ const vSlotWidth = iconBaseWidth * 0.045;
+ const vSlotY =
+ y + topSurfaceHeight + (iconBaseHeight - topSurfaceHeight) * 0.45;
+ const vSlot1X = x + iconBaseWidth * 0.2;
+ const vSlot2X = x + iconBaseWidth * 0.3;
+
+ [vSlot1X, vSlot2X].forEach((vx) => {
+ nodeG
+ .append("rect")
+ .attr("x", vx - vSlotWidth / 2)
+ .attr("y", vSlotY)
+ .attr("width", vSlotWidth)
+ .attr("height", slotHeight)
+ .attr("fill", detailColor)
+ .attr("rx", 1.2);
+ });
+ } else if (
+ modelLower === "macbook pro" ||
+ modelLower.includes("macbook")
+ ) {
+ // MacBook Pro - classic style with memory fill on screen
+ iconBaseWidth = nodeRadius * 1.6;
+ iconBaseHeight = nodeRadius * 1.15;
+ const x = nodeInfo.x - iconBaseWidth / 2;
+ const y = nodeInfo.y - iconBaseHeight / 2;
+
+ const screenHeight = iconBaseHeight * 0.7;
+ const baseHeight = iconBaseHeight * 0.3;
+ const screenWidth = iconBaseWidth * 0.85;
+ const screenX = nodeInfo.x - screenWidth / 2;
+ const screenBezel = 3;
+
+ // Create clip path for screen content
+ const screenClipId = `screen-clip-${nodeInfo.id.replace(/[^a-zA-Z0-9]/g, "-")}`;
+ defs
+ .append("clipPath")
+ .attr("id", screenClipId)
+ .append("rect")
+ .attr("x", screenX + screenBezel)
+ .attr("y", y + screenBezel)
+ .attr("width", screenWidth - screenBezel * 2)
+ .attr("height", screenHeight - screenBezel * 2)
+ .attr("rx", 2);
+
+ // Screen outer frame
+ nodeG
+ .append("rect")
+ .attr("x", screenX)
+ .attr("y", y)
+ .attr("width", screenWidth)
+ .attr("height", screenHeight)
+ .attr("rx", 3)
+ .attr("fill", "#1a1a1a")
+ .attr("stroke", wireColor)
+ .attr("stroke-width", strokeWidth);
+
+ // Screen inner (dark background)
+ nodeG
+ .append("rect")
+ .attr("x", screenX + screenBezel)
+ .attr("y", y + screenBezel)
+ .attr("width", screenWidth - screenBezel * 2)
+ .attr("height", screenHeight - screenBezel * 2)
+ .attr("rx", 2)
+ .attr("fill", "#0a0a12");
+
+ // Memory fill on screen (fills from bottom up - classic style)
+ if (ramUsagePercent > 0) {
+ const memFillTotalHeight = screenHeight - screenBezel * 2;
+ const memFillActualHeight =
+ (ramUsagePercent / 100) * memFillTotalHeight;
+ nodeG
+ .append("rect")
+ .attr("x", screenX + screenBezel)
+ .attr(
+ "y",
+ y + screenBezel + (memFillTotalHeight - memFillActualHeight),
+ )
+ .attr("width", screenWidth - screenBezel * 2)
+ .attr("height", memFillActualHeight)
+ .attr("fill", "rgba(255,215,0,0.85)")
+ .attr("clip-path", `url(#${screenClipId})`);
+ }
+
+ // Apple logo on screen (centered, on top of memory fill)
+ const targetLogoHeight = screenHeight * 0.22;
+ const logoScale = targetLogoHeight / LOGO_NATIVE_HEIGHT;
+ const logoX = nodeInfo.x - (LOGO_NATIVE_WIDTH * logoScale) / 2;
+ const logoY =
+ y + screenHeight / 2 - (LOGO_NATIVE_HEIGHT * logoScale) / 2;
+ nodeG
+ .append("path")
+ .attr("d", APPLE_LOGO_PATH)
+ .attr(
+ "transform",
+ `translate(${logoX}, ${logoY}) scale(${logoScale})`,
+ )
+ .attr("fill", "#FFFFFF")
+ .attr("opacity", 0.9);
+
+ // Base (keyboard) - trapezoidal
+ const baseY = y + screenHeight;
+ const baseTopWidth = screenWidth;
+ const baseBottomWidth = iconBaseWidth;
+ const baseTopX = nodeInfo.x - baseTopWidth / 2;
+ const baseBottomX = nodeInfo.x - baseBottomWidth / 2;
+
+ nodeG
+ .append("path")
+ .attr(
+ "d",
+ `M ${baseTopX} ${baseY} L ${baseTopX + baseTopWidth} ${baseY} L ${baseBottomX + baseBottomWidth} ${baseY + baseHeight} L ${baseBottomX} ${baseY + baseHeight} Z`,
+ )
+ .attr("fill", "#2c2c2c")
+ .attr("stroke", wireColor)
+ .attr("stroke-width", 1);
+
+ // Keyboard area
+ const keyboardX = baseTopX + 6;
+ const keyboardY = baseY + 3;
+ const keyboardWidth = baseTopWidth - 12;
+ const keyboardHeight = baseHeight * 0.55;
+ nodeG
+ .append("rect")
+ .attr("x", keyboardX)
+ .attr("y", keyboardY)
+ .attr("width", keyboardWidth)
+ .attr("height", keyboardHeight)
+ .attr("fill", "rgba(0,0,0,0.2)")
+ .attr("rx", 2);
+
+ // Trackpad
+ const trackpadWidth = baseTopWidth * 0.4;
+ const trackpadX = nodeInfo.x - trackpadWidth / 2;
+ const trackpadY = baseY + keyboardHeight + 5;
+ const trackpadHeight = baseHeight * 0.3;
+ nodeG
+ .append("rect")
+ .attr("x", trackpadX)
+ .attr("y", trackpadY)
+ .attr("width", trackpadWidth)
+ .attr("height", trackpadHeight)
+ .attr("fill", "rgba(255,255,255,0.08)")
+ .attr("rx", 2);
+ } else {
+ // Default/Unknown - holographic hexagon
+ const hexRadius = nodeRadius * 0.6;
+ const hexPoints = Array.from({ length: 6 }, (_, i) => {
+ const angle = ((i * 60 - 30) * Math.PI) / 180;
+ return `${nodeInfo.x + hexRadius * Math.cos(angle)},${nodeInfo.y + hexRadius * Math.sin(angle)}`;
+ }).join(" ");
+
+ // Main shape
+ nodeG
+ .append("polygon")
+ .attr("points", hexPoints)
+ .attr("fill", fillColor)
+ .attr("stroke", wireColor)
+ .attr("stroke-width", strokeWidth);
+ }
+
+ // --- Vertical GPU Bar (right side of icon) ---
+ // Show in both full mode and minimized mode (scaled appropriately)
+ if (showFullLabels || isMinimized) {
+ const gpuBarWidth = isMinimized
+ ? Math.max(16, nodeRadius * 0.32)
+ : Math.max(28, nodeRadius * 0.3);
+ const gpuBarHeight = iconBaseHeight * 0.95;
+ const barXOffset = iconBaseWidth / 2 + (isMinimized ? 5 : 10);
+ const gpuBarX = nodeInfo.x + barXOffset;
+ const gpuBarY = nodeInfo.y - gpuBarHeight / 2;
+
+ // GPU Bar Background (grey, no border)
+ nodeG
+ .append("rect")
+ .attr("x", gpuBarX)
+ .attr("y", gpuBarY)
+ .attr("width", gpuBarWidth)
+ .attr("height", gpuBarHeight)
+ .attr("fill", "rgba(80, 80, 90, 0.7)")
+ .attr("rx", 2);
+
+ // GPU Bar Fill (from bottom up, colored by temperature)
+ if (gpuUsagePercent > 0) {
+ const fillHeight = (gpuUsagePercent / 100) * gpuBarHeight;
+ const gpuFillColor = getTemperatureColor(gpuTemp);
+ nodeG
+ .append("rect")
+ .attr("x", gpuBarX)
+ .attr("y", gpuBarY + (gpuBarHeight - fillHeight))
+ .attr("width", gpuBarWidth)
+ .attr("height", fillHeight)
+ .attr("fill", gpuFillColor)
+ .attr("opacity", 0.9)
+ .attr("rx", 2);
+ }
+
+ // GPU Stats Text (centered on bar, multiline, bigger and bold)
+ const gpuTextX = gpuBarX + gpuBarWidth / 2;
+ const gpuTextY = gpuBarY + gpuBarHeight / 2;
+ const gpuTextFontSize = isMinimized
+ ? Math.max(10, gpuBarWidth * 0.6)
+ : Math.min(16, Math.max(12, gpuBarWidth * 0.55));
+ const lineSpacing = gpuTextFontSize * 1.25;
+
+ const gpuUsageText = `${gpuUsagePercent.toFixed(0)}%`;
+ const tempText = !isNaN(gpuTemp) ? `${gpuTemp.toFixed(0)}°C` : "-";
+ const powerText = sysPower !== null ? `${sysPower.toFixed(0)}W` : "-";
+
+ // GPU Usage %
+ nodeG
+ .append("text")
+ .attr("x", gpuTextX)
+ .attr("y", gpuTextY - lineSpacing)
+ .attr("text-anchor", "middle")
+ .attr("dominant-baseline", "middle")
+ .attr("fill", "#FFFFFF")
+ .attr("font-size", gpuTextFontSize)
+ .attr("font-weight", "700")
+ .attr("font-family", "SF Mono, Monaco, monospace")
+ .text(gpuUsageText);
+
+ // Temperature
+ nodeG
+ .append("text")
+ .attr("x", gpuTextX)
+ .attr("y", gpuTextY)
+ .attr("text-anchor", "middle")
+ .attr("dominant-baseline", "middle")
+ .attr("fill", "#FFFFFF")
+ .attr("font-size", gpuTextFontSize)
+ .attr("font-weight", "700")
+ .attr("font-family", "SF Mono, Monaco, monospace")
+ .text(tempText);
+
+ // Power (Watts)
+ nodeG
+ .append("text")
+ .attr("x", gpuTextX)
+ .attr("y", gpuTextY + lineSpacing)
+ .attr("text-anchor", "middle")
+ .attr("dominant-baseline", "middle")
+ .attr("fill", "#FFFFFF")
+ .attr("font-size", gpuTextFontSize)
+ .attr("font-weight", "700")
+ .attr("font-family", "SF Mono, Monaco, monospace")
+ .text(powerText);
+ }
+
+ // Labels - adapt based on mode
+ if (showFullLabels) {
+ // FULL MODE: Name above, memory info below (1-4 nodes)
+ const nameY = nodeInfo.y - iconBaseHeight / 2 - 15;
+ const fontSize = Math.max(10, nodeRadius * 0.16);
+
+ // Truncate name based on node count
+ const maxNameLen =
+ numNodes === 1 ? 22 : numNodes === 2 ? 18 : numNodes === 3 ? 16 : 14;
+ const displayName =
+ friendlyName.length > maxNameLen
+ ? friendlyName.slice(0, maxNameLen - 2) + ".."
+ : friendlyName;
+
+ // Name label above
+ nodeG
+ .append("text")
+ .attr("x", nodeInfo.x)
+ .attr("y", nameY)
+ .attr("text-anchor", "middle")
+ .attr("dominant-baseline", "middle")
+ .attr("fill", "#FFD700")
+ .attr("font-size", fontSize)
+ .attr("font-weight", 500)
+ .attr("font-family", "SF Mono, Monaco, monospace")
+ .text(displayName);
+
+ // Memory info below - used in grey, total in yellow
+ const infoY = nodeInfo.y + iconBaseHeight / 2 + 16;
+ const memText = nodeG
+ .append("text")
+ .attr("x", nodeInfo.x)
+ .attr("y", infoY)
+ .attr("text-anchor", "middle")
+ .attr("font-size", fontSize * 0.85)
+ .attr("font-family", "SF Mono, Monaco, monospace");
+ memText
+ .append("tspan")
+ .attr("fill", "rgba(255,215,0,0.9)")
+ .text(`${formatBytes(ramUsed)}`);
+ memText
+ .append("tspan")
+ .attr("fill", "rgba(179,179,179,0.9)")
+ .text(`/${formatBytes(ramTotal)}`);
+ memText
+ .append("tspan")
+ .attr("fill", "rgba(179,179,179,0.7)")
+ .text(` (${ramUsagePercent.toFixed(0)}%)`);
+ } else if (showCompactLabels) {
+ // COMPACT MODE: Just name and basic info (4+ nodes)
+ const fontSize = Math.max(7, nodeRadius * 0.11);
+
+ // Very compact name below icon
+ const nameY = nodeInfo.y + iconBaseHeight / 2 + 9;
+ const shortName =
+ friendlyName.length > 10
+ ? friendlyName.slice(0, 8) + ".."
+ : friendlyName;
+ nodeG
+ .append("text")
+ .attr("x", nodeInfo.x)
+ .attr("y", nameY)
+ .attr("text-anchor", "middle")
+ .attr("fill", "#FFD700")
+ .attr("font-size", fontSize)
+ .attr("font-family", "SF Mono, Monaco, monospace")
+ .text(shortName);
+
+ // Single line of key stats
+ const statsY = nameY + 9;
+ nodeG
+ .append("text")
+ .attr("x", nodeInfo.x)
+ .attr("y", statsY)
+ .attr("text-anchor", "middle")
+ .attr("fill", "rgba(255,215,0,0.7)")
+ .attr("font-size", fontSize * 0.85)
+ .attr("font-family", "SF Mono, Monaco, monospace")
+ .text(
+ `${ramUsagePercent.toFixed(0)}%${!isNaN(gpuTemp) ? " " + gpuTemp.toFixed(0) + "°C" : ""}`,
+ );
+ } else {
+ // MINIMIZED MODE: Show name above and memory info below (like main topology)
+ const fontSize = 8;
+
+ // Friendly name (shortened) above icon
+ const nameY = nodeInfo.y - iconBaseHeight / 2 - 8;
+ const shortName =
+ friendlyName.length > 12
+ ? friendlyName.slice(0, 10) + ".."
+ : friendlyName;
+ nodeG
+ .append("text")
+ .attr("x", nodeInfo.x)
+ .attr("y", nameY)
+ .attr("text-anchor", "middle")
+ .attr("fill", "#FFD700")
+ .attr("font-size", fontSize)
+ .attr("font-weight", "500")
+ .attr("font-family", "SF Mono, Monaco, monospace")
+ .text(shortName);
+
+ // Memory info below icon - used in grey, total in yellow (same as main topology)
+ const infoY = nodeInfo.y + iconBaseHeight / 2 + 10;
+ const memTextMini = nodeG
+ .append("text")
+ .attr("x", nodeInfo.x)
+ .attr("y", infoY)
+ .attr("text-anchor", "middle")
+ .attr("font-size", fontSize * 0.85)
+ .attr("font-family", "SF Mono, Monaco, monospace");
+ memTextMini
+ .append("tspan")
+ .attr("fill", "rgba(255,215,0,0.9)")
+ .text(`${formatBytes(ramUsed)}`);
+ memTextMini
+ .append("tspan")
+ .attr("fill", "rgba(179,179,179,0.9)")
+ .text(`/${formatBytes(ramTotal)}`);
+ memTextMini
+ .append("tspan")
+ .attr("fill", "rgba(179,179,179,0.7)")
+ .text(` (${ramUsagePercent.toFixed(0)}%)`);
+ }
+ });
+ }
+
+ $effect(() => {
+ if (data) {
+ renderGraph();
+ }
+ });
+
+ onMount(() => {
+ if (svgContainer) {
+ resizeObserver = new ResizeObserver(() => {
+ renderGraph();
+ });
+ resizeObserver.observe(svgContainer);
+ }
+ });
+
+ onDestroy(() => {
+ resizeObserver?.disconnect();
+ });
</script>
-<svg
- bind:this={svgContainer}
- class="w-full h-full {className}"
-></svg>
+<svg bind:this={svgContainer} class="w-full h-full {className}"></svg>
<style>
- :global(.graph-node) {
- transition: transform 0.2s ease, opacity 0.2s ease;
- }
- :global(.graph-node:hover) {
- filter: brightness(1.1);
- }
- :global(.graph-link) {
- stroke: var(--exo-light-gray, #B3B3B3);
- stroke-width: 1px;
- stroke-dasharray: 4, 4;
- opacity: 0.8;
- animation: flowAnimation 0.75s linear infinite;
- }
- @keyframes flowAnimation {
- from { stroke-dashoffset: 0; }
- to { stroke-dashoffset: -10; }
- }
-
+ :global(.graph-node) {
+ transition:
+ transform 0.2s ease,
+ opacity 0.2s ease;
+ }
+ :global(.graph-node:hover) {
+ filter: brightness(1.1);
+ }
+ :global(.graph-link) {
+ stroke: var(--exo-light-gray, #b3b3b3);
+ stroke-width: 1px;
+ stroke-dasharray: 4, 4;
+ opacity: 0.8;
+ animation: flowAnimation 0.75s linear infinite;
+ }
+ @keyframes flowAnimation {
+ from {
+ stroke-dashoffset: 0;
+ }
+ to {
+ stroke-dashoffset: -10;
+ }
+ }
</style>
diff --git a/dashboard/src/lib/stores/app.svelte.ts b/dashboard/src/lib/stores/app.svelte.ts
index b67c103e..0d5d9b28 100644
--- a/dashboard/src/lib/stores/app.svelte.ts
+++ b/dashboard/src/lib/stores/app.svelte.ts
@@ -11,114 +11,114 @@ import { browser } from "$app/environment";
// UUID generation fallback for browsers without crypto.randomUUID
function generateUUID(): string {
- if (
- typeof crypto !== "undefined" &&
- typeof crypto.randomUUID === "function"
- ) {
- return crypto.randomUUID();
- }
- // Fallback implementation
- return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
- const r = (Math.random() * 16) | 0;
- const v = c === "x" ? r : (r & 0x3) | 0x8;
- return v.toString(16);
- });
+ if (
+ typeof crypto !== "undefined" &&
+ typeof crypto.randomUUID === "function"
+ ) {
+ return crypto.randomUUID();
+ }
+ // Fallback implementation
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
+ const r = (Math.random() * 16) | 0;
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
+ return v.toString(16);
+ });
}
export interface NodeInfo {
- system_info?: {
- model_id?: string;
- chip?: string;
- memory?: number;
- };
- network_interfaces?: Array<{
- name?: string;
- addresses?: string[];
- }>;
- ip_to_interface?: Record<string, string>;
- macmon_info?: {
- memory?: {
- ram_usage: number;
- ram_total: number;
- };
- temp?: {
- gpu_temp_avg: number;
- };
- gpu_usage?: [number, number];
- sys_power?: number;
- };
- last_macmon_update: number;
- friendly_name?: string;
+ system_info?: {
+ model_id?: string;
+ chip?: string;
+ memory?: number;
+ };
+ network_interfaces?: Array<{
+ name?: string;
+ addresses?: string[];
+ }>;
+ ip_to_interface?: Record<string, string>;
+ macmon_info?: {
+ memory?: {
+ ram_usage: number;
+ ram_total: number;
+ };
+ temp?: {
+ gpu_temp_avg: number;
+ };
+ gpu_usage?: [number, number];
+ sys_power?: number;
+ };
+ last_macmon_update: number;
+ friendly_name?: string;
}
export interface TopologyEdge {
- source: string;
- target: string;
- sendBackIp?: string;
- sendBackInterface?: string;
+ source: string;
+ target: string;
+ sendBackIp?: string;
+ sendBackInterface?: string;
}
export interface TopologyData {
- nodes: Record<string, NodeInfo>;
- edges: TopologyEdge[];
+ nodes: Record<string, NodeInfo>;
+ edges: TopologyEdge[];
}
export interface Instance {
- shardAssignments?: {
- modelId?: string;
- runnerToShard?: Record<string, unknown>;
- nodeToRunner?: Record<string, string>;
- };
+ shardAssignments?: {
+ modelId?: string;
+ runnerToShard?: Record<string, unknown>;
+ nodeToRunner?: Record<string, string>;
+ };
}
// Granular node state types from the new state structure
interface RawNodeIdentity {
- modelId?: string;
- chipId?: string;
- friendlyName?: string;
+ modelId?: string;
+ chipId?: string;
+ friendlyName?: string;
}
interface RawMemoryUsage {
- ramTotal?: { inBytes: number };
- ramAvailable?: { inBytes: number };
- swapTotal?: { inBytes: number };
- swapAvailable?: { inBytes: number };
+ ramTotal?: { inBytes: number };
+ ramAvailable?: { inBytes: number };
+ swapTotal?: { inBytes: number };
+ swapAvailable?: { inBytes: number };
}
interface RawSystemPerformanceProfile {
- gpuUsage?: number;
- temp?: number;
- sysPower?: number;
- pcpuUsage?: number;
- ecpuUsage?: number;
+ gpuUsage?: number;
+ temp?: number;
+ sysPower?: number;
+ pcpuUsage?: number;
+ ecpuUsage?: number;
}
interface RawNetworkInterfaceInfo {
- name?: string;
- ipAddress?: string;
- addresses?: Array<{ address?: string } | string>;
- ipv4?: string;
- ipv6?: string;
- ipAddresses?: string[];
- ips?: string[];
+ name?: string;
+ ipAddress?: string;
+ addresses?: Array<{ address?: string } | string>;
+ ipv4?: string;
+ ipv6?: string;
+ ipAddresses?: string[];
+ ips?: string[];
}
interface RawNodeNetworkInfo {
- interfaces?: RawNetworkInterfaceInfo[];
+ interfaces?: RawNetworkInterfaceInfo[];
}
interface RawSocketConnection {
- sinkMultiaddr?: {
- address?: string;
- ip_address?: string;
- address_type?: string;
- port?: number;
- };
+ sinkMultiaddr?: {
+ address?: string;
+ ip_address?: string;
+ address_type?: string;
+ port?: number;
+ };
}
interface RawRDMAConnection {
- sourceRdmaIface?: string;
- sinkRdmaIface?: string;
+ sourceRdmaIface?: string;
+ sinkRdmaIface?: string;
}
type RawConnectionEdge = RawSocketConnection | RawRDMAConnection;
@@ -127,99 +127,99 @@ type RawConnectionEdge = RawSocketConnection | RawRDMAConnection;
type RawConnectionsMap = Record<string, Record<string, RawConnectionEdge[]>>;
interface RawTopology {
- nodes: string[];
- connections?: RawConnectionsMap;
+ nodes: string[];
+ connections?: RawConnectionsMap;
}
export interface DownloadProgress {
- totalBytes: number;
- downloadedBytes: number;
- speed: number;
- etaMs: number;
- percentage: number;
- completedFiles: number;
- totalFiles: number;
- files: Array<{
- name: string;
- totalBytes: number;
- downloadedBytes: number;
- speed: number;
- etaMs: number;
- percentage: number;
- }>;
+ totalBytes: number;
+ downloadedBytes: number;
+ speed: number;
+ etaMs: number;
+ percentage: number;
+ completedFiles: number;
+ totalFiles: number;
+ files: Array<{
+ name: string;
+ totalBytes: number;
+ downloadedBytes: number;
+ speed: number;
+ etaMs: number;
+ percentage: number;
+ }>;
}
export interface ModelDownloadStatus {
- isDownloading: boolean;
- progress: DownloadProgress | null;
- nodeDetails: Array<{
- nodeId: string;
- nodeName: string;
- progress: DownloadProgress;
- }>;
+ isDownloading: boolean;
+ progress: DownloadProgress | null;
+ nodeDetails: Array<{
+ nodeId: string;
+ nodeName: string;
+ progress: DownloadProgress;
+ }>;
}
// Placement preview from the API
export interface PlacementPreview {
- model_id: string;
- sharding: "Pipeline" | "Tensor";
- instance_meta: "MlxRing" | "MlxIbv" | "MlxJaccl";
- instance: unknown | null;
- memory_delta_by_node: Record<string, number> | null;
- error: string | null;
+ model_id: string;
+ sharding: "Pipeline" | "Tensor";
+ instance_meta: "MlxRing" | "MlxIbv" | "MlxJaccl";
+ instance: unknown | null;
+ memory_delta_by_node: Record<string, number> | null;
+ error: string | null;
}
export interface PlacementPreviewResponse {
- previews: PlacementPreview[];
+ previews: PlacementPreview[];
}
interface RawStateResponse {
- topology?: RawTopology;
- instances?: Record<
- string,
- {
- MlxRingInstance?: Instance;
- MlxIbvInstance?: Instance;
- MlxJacclInstance?: Instance;
- }
- >;
- runners?: Record<string, unknown>;
- downloads?: Record<string, unknown[]>;
- // New granular node state fields
- nodeIdentities?: Record<string, RawNodeIdentity>;
- nodeMemory?: Record<string, RawMemoryUsage>;
- nodeSystem?: Record<string, RawSystemPerformanceProfile>;
- nodeNetwork?: Record<string, RawNodeNetworkInfo>;
+ topology?: RawTopology;
+ instances?: Record<
+ string,
+ {
+ MlxRingInstance?: Instance;
+ MlxIbvInstance?: Instance;
+ MlxJacclInstance?: Instance;
+ }
+ >;
+ runners?: Record<string, unknown>;
+ downloads?: Record<string, unknown[]>;
+ // New granular node state fields
+ nodeIdentities?: Record<string, RawNodeIdentity>;
+ nodeMemory?: Record<string, RawMemoryUsage>;
+ nodeSystem?: Record<string, RawSystemPerformanceProfile>;
+ nodeNetwork?: Record<string, RawNodeNetworkInfo>;
}
export interface MessageAttachment {
- type: "image" | "text" | "file" | "generated-image";
- name: string;
- content?: string;
- preview?: string;
- mimeType?: string;
+ type: "image" | "text" | "file" | "generated-image";
+ name: string;
+ content?: string;
+ preview?: string;
+ mimeType?: string;
}
export interface Message {
- id: string;
- role: "user" | "assistant" | "system";
- content: string;
- timestamp: number;
- thinking?: string;
- attachments?: MessageAttachment[];
- ttftMs?: number; // Time to first token in ms (for assistant messages)
- tps?: number; // Tokens per second (for assistant messages)
+ id: string;
+ role: "user" | "assistant" | "system";
+ content: string;
+ timestamp: number;
+ thinking?: string;
+ attachments?: MessageAttachment[];
+ ttftMs?: number; // Time to first token in ms (for assistant messages)
+ tps?: number; // Tokens per second (for assistant messages)
}
export interface Conversation {
- id: string;
- name: string;
- messages: Message[];
- createdAt: number;
- updatedAt: number;
- modelId: string | null;
- sharding: string | null;
- instanceType: string | null;
+ id: string;
+ name: string;
+ messages: Message[];
+ createdAt: number;
+ updatedAt: number;
+ modelId: string | null;
+ sharding: string | null;
+ instanceType: string | null;
}
const STORAGE_KEY = "exo-conversations";
@@ -227,1823 +227,1823 @@ const IMAGE_PARAMS_STORAGE_KEY = "exo-image-generation-params";
// Image generation params interface matching backend API
export interface ImageGenerationParams {
- // Basic params
- size: "512x512" | "768x768" | "1024x1024" | "1024x768" | "768x1024";
- quality: "low" | "medium" | "high";
- outputFormat: "png" | "jpeg";
- // Advanced params
- seed: number | null;
- numInferenceSteps: number | null;
- guidance: number | null;
- negativePrompt: string | null;
- // Edit mode params
- inputFidelity: "low" | "high";
+ // Basic params
+ size: "512x512" | "768x768" | "1024x1024" | "1024x768" | "768x1024";
+ quality: "low" | "medium" | "high";
+ outputFormat: "png" | "jpeg";
+ // Advanced params
+ seed: number | null;
+ numInferenceSteps: number | null;
+ guidance: number | null;
+ negativePrompt: string | null;
+ // Edit mode params
+ inputFidelity: "low" | "high";
}
// Image being edited
export interface EditingImage {
- imageDataUrl: string;
- sourceMessage: Message;
+ imageDataUrl: string;
+ sourceMessage: Message;
}
const DEFAULT_IMAGE_PARAMS: ImageGenerationParams = {
- size: "1024x1024",
- quality: "medium",
- outputFormat: "png",
- seed: null,
- numInferenceSteps: null,
- guidance: null,
- negativePrompt: null,
- inputFidelity: "low",
+ size: "1024x1024",
+ quality: "medium",
+ outputFormat: "png",
+ seed: null,
+ numInferenceSteps: null,
+ guidance: null,
+ negativePrompt: null,
+ inputFidelity: "low",
};
interface GranularNodeState {
- nodeIdentities?: Record<string, RawNodeIdentity>;
- nodeMemory?: Record<string, RawMemoryUsage>;
- nodeSystem?: Record<string, RawSystemPerformanceProfile>;
- nodeNetwork?: Record<string, RawNodeNetworkInfo>;
+ nodeIdentities?: Record<string, RawNodeIdentity>;
+ nodeMemory?: Record<string, RawMemoryUsage>;
+ nodeSystem?: Record<string, RawSystemPerformanceProfile>;
+ nodeNetwork?: Record<string, RawNodeNetworkInfo>;
}
function transformNetworkInterface(iface: RawNetworkInterfaceInfo): {
- name?: string;
- addresses: string[];
+ name?: string;
+ addresses: string[];
} {
- const addresses: string[] = [];
- if (iface.ipAddress && typeof iface.ipAddress === "string") {
- addresses.push(iface.ipAddress);
- }
- if (Array.isArray(iface.addresses)) {
- for (const addr of iface.addresses) {
- if (typeof addr === "string") addresses.push(addr);
- else if (addr && typeof addr === "object" && addr.address)
- addresses.push(addr.address);
- }
- }
- if (Array.isArray(iface.ipAddresses)) {
- addresses.push(
- ...iface.ipAddresses.filter((a): a is string => typeof a === "string"),
- );
- }
- if (Array.isArray(iface.ips)) {
- addresses.push(
- ...iface.ips.filter((a): a is string => typeof a === "string"),
- );
- }
- if (iface.ipv4 && typeof iface.ipv4 === "string") addresses.push(iface.ipv4);
- if (iface.ipv6 && typeof iface.ipv6 === "string") addresses.push(iface.ipv6);
-
- return {
- name: iface.name,
- addresses: Array.from(new Set(addresses)),
- };
+ const addresses: string[] = [];
+ if (iface.ipAddress && typeof iface.ipAddress === "string") {
+ addresses.push(iface.ipAddress);
+ }
+ if (Array.isArray(iface.addresses)) {
+ for (const addr of iface.addresses) {
+ if (typeof addr === "string") addresses.push(addr);
+ else if (addr && typeof addr === "object" && addr.address)
+ addresses.push(addr.address);
+ }
+ }
+ if (Array.isArray(iface.ipAddresses)) {
+ addresses.push(
+ ...iface.ipAddresses.filter((a): a is string => typeof a === "string"),
+ );
+ }
+ if (Array.isArray(iface.ips)) {
+ addresses.push(
+ ...iface.ips.filter((a): a is string => typeof a === "string"),
+ );
+ }
+ if (iface.ipv4 && typeof iface.ipv4 === "string") addresses.push(iface.ipv4);
+ if (iface.ipv6 && typeof iface.ipv6 === "string") addresses.push(iface.ipv6);
+
+ return {
+ name: iface.name,
+ addresses: Array.from(new Set(addresses)),
+ };
}
function transformTopology(
- raw: RawTopology,
- granularState: GranularNodeState,
+ raw: RawTopology,
+ granularState: GranularNodeState,
): TopologyData {
- const nodes: Record<string, NodeInfo> = {};
- const edges: TopologyEdge[] = [];
-
- for (const nodeId of raw.nodes || []) {
- if (!nodeId) continue;
-
- // Get data from granular state mappings
- const identity = granularState.nodeIdentities?.[nodeId];
- const memory = granularState.nodeMemory?.[nodeId];
- const system = granularState.nodeSystem?.[nodeId];
- const network = granularState.nodeNetwork?.[nodeId];
-
- const ramTotal = memory?.ramTotal?.inBytes ?? 0;
- const ramAvailable = memory?.ramAvailable?.inBytes ?? 0;
- const ramUsage = Math.max(ramTotal - ramAvailable, 0);
-
- const rawInterfaces = network?.interfaces || [];
- const networkInterfaces = rawInterfaces.map(transformNetworkInterface);
-
- const ipToInterface: Record<string, string> = {};
- for (const iface of networkInterfaces) {
- for (const addr of iface.addresses || []) {
- ipToInterface[addr] = iface.name ?? "";
- }
- }
-
- nodes[nodeId] = {
- system_info: {
- model_id: identity?.modelId ?? "Unknown",
- chip: identity?.chipId,
- memory: ramTotal,
- },
- network_interfaces: networkInterfaces,
- ip_to_interface: ipToInterface,
- macmon_info: {
- memory: {
- ram_usage: ramUsage,
- ram_total: ramTotal,
- },
- temp:
- system?.temp !== undefined
- ? { gpu_temp_avg: system.temp }
- : undefined,
- gpu_usage:
- system?.gpuUsage !== undefined ? [0, system.gpuUsage] : undefined,
- sys_power: system?.sysPower,
- },
- last_macmon_update: Date.now() / 1000,
- friendly_name: identity?.friendlyName,
- };
- }
-
- // Handle connections - nested mapping format { source: { sink: [edges] } }
- const connections = raw.connections;
- if (connections && typeof connections === "object") {
- for (const [source, sinks] of Object.entries(connections)) {
- if (!sinks || typeof sinks !== "object") continue;
- for (const [sink, edgeList] of Object.entries(sinks)) {
- if (!Array.isArray(edgeList)) continue;
- for (const edge of edgeList) {
- let sendBackIp: string | undefined;
- if (edge && typeof edge === "object" && "sinkMultiaddr" in edge) {
- const multiaddr = edge.sinkMultiaddr;
- if (multiaddr) {
- sendBackIp =
- multiaddr.ip_address ||
- extractIpFromMultiaddr(multiaddr.address);
- }
- }
-
- if (nodes[source] && nodes[sink] && source !== sink) {
- edges.push({ source, target: sink, sendBackIp });
- }
- }
- }
- }
- }
-
- return { nodes, edges };
+ const nodes: Record<string, NodeInfo> = {};
+ const edges: TopologyEdge[] = [];
+
+ for (const nodeId of raw.nodes || []) {
+ if (!nodeId) continue;
+
+ // Get data from granular state mappings
+ const identity = granularState.nodeIdentities?.[nodeId];
+ const memory = granularState.nodeMemory?.[nodeId];
+ const system = granularState.nodeSystem?.[nodeId];
+ const network = granularState.nodeNetwork?.[nodeId];
+
+ const ramTotal = memory?.ramTotal?.inBytes ?? 0;
+ const ramAvailable = memory?.ramAvailable?.inBytes ?? 0;
+ const ramUsage = Math.max(ramTotal - ramAvailable, 0);
+
+ const rawInterfaces = network?.interfaces || [];
+ const networkInterfaces = rawInterfaces.map(transformNetworkInterface);
+
+ const ipToInterface: Record<string, string> = {};
+ for (const iface of networkInterfaces) {
+ for (const addr of iface.addresses || []) {
+ ipToInterface[addr] = iface.name ?? "";
+ }
+ }
+
+ nodes[nodeId] = {
+ system_info: {
+ model_id: identity?.modelId ?? "Unknown",
+ chip: identity?.chipId,
+ memory: ramTotal,
+ },
+ network_interfaces: networkInterfaces,
+ ip_to_interface: ipToInterface,
+ macmon_info: {
+ memory: {
+ ram_usage: ramUsage,
+ ram_total: ramTotal,
+ },
+ temp:
+ system?.temp !== undefined
+ ? { gpu_temp_avg: system.temp }
+ : undefined,
+ gpu_usage:
+ system?.gpuUsage !== undefined ? [0, system.gpuUsage] : undefined,
+ sys_power: system?.sysPower,
+ },
+ last_macmon_update: Date.now() / 1000,
+ friendly_name: identity?.friendlyName,
+ };
+ }
+
+ // Handle connections - nested mapping format { source: { sink: [edges] } }
+ const connections = raw.connections;
+ if (connections && typeof connections === "object") {
+ for (const [source, sinks] of Object.entries(connections)) {
+ if (!sinks || typeof sinks !== "object") continue;
+ for (const [sink, edgeList] of Object.entries(sinks)) {
+ if (!Array.isArray(edgeList)) continue;
+ for (const edge of edgeList) {
+ let sendBackIp: string | undefined;
+ if (edge && typeof edge === "object" && "sinkMultiaddr" in edge) {
+ const multiaddr = edge.sinkMultiaddr;
+ if (multiaddr) {
+ sendBackIp =
+ multiaddr.ip_address ||
+ extractIpFromMultiaddr(multiaddr.address);
+ }
+ }
+
+ if (nodes[source] && nodes[sink] && source !== sink) {
+ edges.push({ source, target: sink, sendBackIp });
+ }
+ }
+ }
+ }
+ }
+
+ return { nodes, edges };
}
function extractIpFromMultiaddr(ma?: string): string | undefined {
- if (!ma) return undefined;
- const parts = ma.split("/");
- const ip4Idx = parts.indexOf("ip4");
- const ip6Idx = parts.indexOf("ip6");
- const idx = ip4Idx >= 0 ? ip4Idx : ip6Idx;
- if (idx >= 0 && parts.length > idx + 1) {
- return parts[idx + 1];
- }
- return undefined;
+ if (!ma) return undefined;
+ const parts = ma.split("/");
+ const ip4Idx = parts.indexOf("ip4");
+ const ip6Idx = parts.indexOf("ip6");
+ const idx = ip4Idx >= 0 ? ip4Idx : ip6Idx;
+ if (idx >= 0 && parts.length > idx + 1) {
+ return parts[idx + 1];
+ }
+ return undefined;
}
class AppStore {
- // Conversation state
- conversations = $state<Conversation[]>([]);
- activeConversationId = $state<string | null>(null);
-
- // Chat state
- hasStartedChat = $state(false);
- messages = $state<Message[]>([]);
- currentResponse = $state("");
- isLoading = $state(false);
-
- // Performance metrics
- ttftMs = $state<number | null>(null); // Time to first token in ms
- tps = $state<number | null>(null); // Tokens per second
- totalTokens = $state<number>(0); // Total tokens in current response
-
- // Topology state
- topologyData = $state<TopologyData | null>(null);
- instances = $state<Record<string, unknown>>({});
- runners = $state<Record<string, unknown>>({});
- downloads = $state<Record<string, unknown[]>>({});
- placementPreviews = $state<PlacementPreview[]>([]);
- selectedPreviewModelId = $state<string | null>(null);
- isLoadingPreviews = $state(false);
- lastUpdate = $state<number | null>(null);
-
- // UI state
- isTopologyMinimized = $state(false);
- isSidebarOpen = $state(false); // Hidden by default, shown when in chat mode
- debugMode = $state(false);
- topologyOnlyMode = $state(false);
- chatSidebarVisible = $state(true); // Shown by default
-
- // Image generation params
- imageGenerationParams = $state<ImageGenerationParams>({
- ...DEFAULT_IMAGE_PARAMS,
- });
-
- // Image editing state
- editingImage = $state<EditingImage | null>(null);
-
- private fetchInterval: ReturnType<typeof setInterval> | null = null;
- private previewsInterval: ReturnType<typeof setInterval> | null = null;
- private lastConversationPersistTs = 0;
-
- constructor() {
- if (browser) {
- this.startPolling();
- this.loadConversationsFromStorage();
- this.loadDebugModeFromStorage();
- this.loadTopologyOnlyModeFromStorage();
- this.loadChatSidebarVisibleFromStorage();
- this.loadImageGenerationParamsFromStorage();
- }
- }
-
- /**
- * Load conversations from localStorage
- */
- private loadConversationsFromStorage() {
- try {
- const stored = localStorage.getItem(STORAGE_KEY);
- if (stored) {
- const parsed = JSON.parse(stored) as Array<Partial<Conversation>>;
- this.conversations = parsed.map((conversation) => ({
- id: conversation.id ?? generateUUID(),
- name: conversation.name ?? "Chat",
- messages: conversation.messages ?? [],
- createdAt: conversation.createdAt ?? Date.now(),
- updatedAt: conversation.updatedAt ?? Date.now(),
- modelId: conversation.modelId ?? null,
- sharding: conversation.sharding ?? null,
- instanceType: conversation.instanceType ?? null,
- }));
- }
- } catch (error) {
- console.error("Failed to load conversations:", error);
- }
- }
-
- /**
- * Save conversations to localStorage
- */
- private saveConversationsToStorage() {
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify(this.conversations));
- } catch (error) {
- console.error("Failed to save conversations:", error);
- }
- }
-
- private loadDebugModeFromStorage() {
- try {
- const stored = localStorage.getItem("exo-debug-mode");
- if (stored !== null) {
- this.debugMode = stored === "true";
- }
- } catch (error) {
- console.error("Failed to load debug mode:", error);
- }
- }
-
- private saveDebugModeToStorage() {
- try {
- localStorage.setItem("exo-debug-mode", this.debugMode ? "true" : "false");
- } catch (error) {
- console.error("Failed to save debug mode:", error);
- }
- }
-
- private loadTopologyOnlyModeFromStorage() {
- try {
- const stored = localStorage.getItem("exo-topology-only-mode");
- if (stored !== null) {
- this.topologyOnlyMode = stored === "true";
- }
- } catch (error) {
- console.error("Failed to load topology only mode:", error);
- }
- }
-
- private saveTopologyOnlyModeToStorage() {
- try {
- localStorage.setItem(
- "exo-topology-only-mode",
- this.topologyOnlyMode ? "true" : "false",
- );
- } catch (error) {
- console.error("Failed to save topology only mode:", error);
- }
- }
-
- private loadChatSidebarVisibleFromStorage() {
- try {
- const stored = localStorage.getItem("exo-chat-sidebar-visible");
- if (stored !== null) {
- this.chatSidebarVisible = stored === "true";
- }
- } catch (error) {
- console.error("Failed to load chat sidebar visibility:", error);
- }
- }
-
- private saveChatSidebarVisibleToStorage() {
- try {
- localStorage.setItem(
- "exo-chat-sidebar-visible",
- this.chatSidebarVisible ? "true" : "false",
- );
- } catch (error) {
- console.error("Failed to save chat sidebar visibility:", error);
- }
- }
-
- private loadImageGenerationParamsFromStorage() {
- try {
- const stored = localStorage.getItem(IMAGE_PARAMS_STORAGE_KEY);
- if (stored) {
- const parsed = JSON.parse(stored) as Partial<ImageGenerationParams>;
- this.imageGenerationParams = {
- ...DEFAULT_IMAGE_PARAMS,
- ...parsed,
- };
- }
- } catch (error) {
- console.error("Failed to load image generation params:", error);
- }
- }
-
- private saveImageGenerationParamsToStorage() {
- try {
- localStorage.setItem(
- IMAGE_PARAMS_STORAGE_KEY,
- JSON.stringify(this.imageGenerationParams),
- );
- } catch (error) {
- console.error("Failed to save image generation params:", error);
- }
- }
-
- getImageGenerationParams(): ImageGenerationParams {
- return this.imageGenerationParams;
- }
-
- setImageGenerationParams(params: Partial<ImageGenerationParams>) {
- this.imageGenerationParams = {
- ...this.imageGenerationParams,
- ...params,
- };
- this.saveImageGenerationParamsToStorage();
- }
-
- resetImageGenerationParams() {
- this.imageGenerationParams = { ...DEFAULT_IMAGE_PARAMS };
- this.saveImageGenerationParamsToStorage();
- }
-
- setEditingImage(imageDataUrl: string, sourceMessage: Message) {
- this.editingImage = { imageDataUrl, sourceMessage };
- }
-
- clearEditingImage() {
- this.editingImage = null;
- }
-
- /**
- * Create a new conversation
- */
- createConversation(name?: string): string {
- const id = generateUUID();
- const now = Date.now();
-
- // Try to derive model and strategy immediately from selected model or running instances
- let derivedModelId = this.selectedChatModel || null;
- let derivedInstanceType: string | null = null;
- let derivedSharding: string | null = null;
-
- // If no selected model, fall back to the first running instance
- if (!derivedModelId) {
- const firstInstance = Object.values(this.instances)[0];
- if (firstInstance) {
- const candidateModel = this.extractInstanceModelId(firstInstance);
- derivedModelId = candidateModel ?? null;
- const details = this.describeInstance(firstInstance);
- derivedInstanceType = details.instanceType;
- derivedSharding = details.sharding;
- }
- } else {
- // If selected model is set, attempt to get its details from instances
- for (const [, instanceWrapper] of Object.entries(this.instances)) {
- const candidateModelId = this.extractInstanceModelId(instanceWrapper);
- if (candidateModelId === derivedModelId) {
- const details = this.describeInstance(instanceWrapper);
- derivedInstanceType = details.instanceType;
- derivedSharding = details.sharding;
- break;
- }
- }
- }
-
- const conversation: Conversation = {
- id,
- name:
- name ||
- `Chat ${new Date(now).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}`,
- messages: [],
- createdAt: now,
- updatedAt: now,
- modelId: derivedModelId,
- sharding: derivedSharding,
- instanceType: derivedInstanceType,
- };
-
- this.conversations.unshift(conversation);
- this.activeConversationId = id;
- this.messages = [];
- this.hasStartedChat = true;
- this.isTopologyMinimized = true;
- this.isSidebarOpen = true; // Auto-open sidebar when chatting
-
- this.saveConversationsToStorage();
- return id;
- }
-
- /**
- * Load a conversation by ID
- */
- loadConversation(id: string): boolean {
- const conversation = this.conversations.find((c) => c.id === id);
- if (!conversation) return false;
-
- this.activeConversationId = id;
- this.messages = [...conversation.messages];
- this.hasStartedChat = true;
- this.isTopologyMinimized = true;
- this.isSidebarOpen = true; // Auto-open sidebar when chatting
- this.refreshConversationModelFromInstances();
-
- return true;
- }
-
- /**
- * Delete a conversation by ID
- */
- deleteConversation(id: string) {
- this.conversations = this.conversations.filter((c) => c.id !== id);
-
- if (this.activeConversationId === id) {
- this.activeConversationId = null;
- this.messages = [];
- this.hasStartedChat = false;
- this.isTopologyMinimized = false;
- }
-
- this.saveConversationsToStorage();
- }
-
- /**
- * Delete all conversations
- */
- deleteAllConversations() {
- this.conversations = [];
- this.activeConversationId = null;
- this.messages = [];
- this.hasStartedChat = false;
- this.isTopologyMinimized = false;
- this.saveConversationsToStorage();
- }
-
- /**
- * Rename a conversation
- */
- renameConversation(id: string, newName: string) {
- const conversation = this.conversations.find((c) => c.id === id);
- if (conversation) {
- conversation.name = newName;
- conversation.updatedAt = Date.now();
- this.saveConversationsToStorage();
- }
- }
-
- private getTaggedValue(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];
- }
-
- private extractInstanceModelId(instanceWrapped: unknown): string | null {
- const [, instance] = this.getTaggedValue(instanceWrapped);
- if (!instance || typeof instance !== "object") return null;
- const inst = instance as { shardAssignments?: { modelId?: string } };
- return inst.shardAssignments?.modelId ?? null;
- }
-
- private describeInstance(instanceWrapped: unknown): {
- sharding: string | null;
- instanceType: string | null;
- } {
- const [instanceTag, instance] = this.getTaggedValue(instanceWrapped);
- if (!instance || typeof instance !== "object") {
- return { sharding: null, instanceType: null };
- }
-
- let instanceType: string | null = null;
- if (instanceTag === "MlxRingInstance") instanceType = "MLX Ring";
- else if (
- instanceTag === "MlxIbvInstance" ||
- instanceTag === "MlxJacclInstance"
- )
- instanceType = "MLX RDMA";
-
- let sharding: string | null = null;
- const inst = instance as {
- shardAssignments?: { runnerToShard?: Record<string, unknown> };
- };
- const runnerToShard = inst.shardAssignments?.runnerToShard || {};
- const firstShardWrapped = Object.values(runnerToShard)[0];
- if (firstShardWrapped) {
- const [shardTag] = this.getTaggedValue(firstShardWrapped);
- if (shardTag === "PipelineShardMetadata") sharding = "Pipeline";
- else if (shardTag === "TensorShardMetadata") sharding = "Tensor";
- else if (shardTag === "PrefillDecodeShardMetadata")
- sharding = "Prefill/Decode";
- }
-
- return { sharding, instanceType };
- }
-
- private buildConversationModelInfo(modelId: string): {
- modelId: string;
- sharding: string | null;
- instanceType: string | null;
- } {
- let sharding: string | null = null;
- let instanceType: string | null = null;
-
- for (const [, instanceWrapper] of Object.entries(this.instances)) {
- const candidateModelId = this.extractInstanceModelId(instanceWrapper);
- if (candidateModelId === modelId) {
- const details = this.describeInstance(instanceWrapper);
- sharding = details.sharding;
- instanceType = details.instanceType;
- break;
- }
- }
-
- return { modelId, sharding, instanceType };
- }
-
- private applyConversationModelInfo(info: {
- modelId: string;
- sharding: string | null;
- instanceType: string | null;
- }) {
- if (!this.activeConversationId) return;
- const conversation = this.conversations.find(
- (c) => c.id === this.activeConversationId,
- );
- if (!conversation) return;
-
- // Keep the first known modelId stable; only backfill if missing
- if (!conversation.modelId) {
- conversation.modelId = info.modelId;
- }
- conversation.sharding = info.sharding;
- conversation.instanceType = info.instanceType;
- this.saveConversationsToStorage();
- }
-
- private getModelTail(modelId: string): string {
- const parts = modelId.split("/");
- return (parts[parts.length - 1] || modelId).toLowerCase();
- }
-
- private isBetterModelId(
- currentId: string | null,
- candidateId: string | null,
- ): boolean {
- if (!candidateId) return false;
- if (!currentId) return true;
- const currentTail = this.getModelTail(currentId);
- const candidateTail = this.getModelTail(candidateId);
- return (
- candidateTail.length > currentTail.length &&
- candidateTail.startsWith(currentTail)
- );
- }
-
- private refreshConversationModelFromInstances() {
- if (!this.activeConversationId) return;
- const conversation = this.conversations.find(
- (c) => c.id === this.activeConversationId,
- );
- if (!conversation) return;
-
- // Prefer stored model; do not replace it once set. Only backfill when missing.
- let modelId = conversation.modelId;
-
- // If missing, try the selected model
- if (!modelId && this.selectedChatModel) {
- modelId = this.selectedChatModel;
- }
-
- // If still missing, fall back to first instance model
- if (!modelId) {
- const firstInstance = Object.values(this.instances)[0];
- if (firstInstance) {
- modelId = this.extractInstanceModelId(firstInstance);
- }
- }
-
- if (!modelId) return;
-
- // If a more specific instance modelId is available (e.g., adds "-4bit"), prefer it
- let preferredModelId = modelId;
- for (const [, instanceWrapper] of Object.entries(this.instances)) {
- const candidate = this.extractInstanceModelId(instanceWrapper);
- if (!candidate) continue;
- if (candidate === preferredModelId) {
- break;
- }
- if (this.isBetterModelId(preferredModelId, candidate)) {
- preferredModelId = candidate;
- }
- }
-
- if (this.isBetterModelId(conversation.modelId, preferredModelId)) {
- conversation.modelId = preferredModelId;
- }
-
- const info = this.buildConversationModelInfo(preferredModelId);
- const hasNewInfo = Boolean(
- info.sharding || info.instanceType || !conversation.modelId,
- );
- if (hasNewInfo) {
- this.applyConversationModelInfo(info);
- }
- }
-
- getDebugMode(): boolean {
- return this.debugMode;
- }
-
- /**
- * Update the active conversation with current messages
- */
- private updateActiveConversation() {
- if (!this.activeConversationId) return;
-
- const conversation = this.conversations.find(
- (c) => c.id === this.activeConversationId,
- );
- if (conversation) {
- conversation.messages = [...this.messages];
- conversation.updatedAt = Date.now();
-
- // Auto-generate name from first user message if still has default name
- if (conversation.name.startsWith("Chat ")) {
- const firstUserMsg = conversation.messages.find(
- (m) => m.role === "user" && m.content.trim(),
- );
- if (firstUserMsg) {
- // Clean up the content - remove file context markers and whitespace
- let content = firstUserMsg.content
- .replace(/\[File:.*?\][\s\S]*?```[\s\S]*?```/g, "") // Remove file attachments
- .trim();
-
- if (content) {
- const preview = content.slice(0, 50);
- conversation.name =
- preview.length < content.length ? preview + "..." : preview;
- }
- }
- }
-
- this.saveConversationsToStorage();
- }
- }
-
- private persistActiveConversation(throttleMs = 400) {
- const now = Date.now();
- if (now - this.lastConversationPersistTs < throttleMs) return;
- this.lastConversationPersistTs = now;
- this.updateActiveConversation();
- }
-
- /**
- * Toggle sidebar visibility
- */
- toggleSidebar() {
- this.isSidebarOpen = !this.isSidebarOpen;
- }
-
- setDebugMode(enabled: boolean) {
- this.debugMode = enabled;
- this.saveDebugModeToStorage();
- }
-
- toggleDebugMode() {
- this.debugMode = !this.debugMode;
- this.saveDebugModeToStorage();
- }
-
- getTopologyOnlyMode(): boolean {
- return this.topologyOnlyMode;
- }
-
- setTopologyOnlyMode(enabled: boolean) {
- this.topologyOnlyMode = enabled;
- this.saveTopologyOnlyModeToStorage();
- }
-
- toggleTopologyOnlyMode() {
- this.topologyOnlyMode = !this.topologyOnlyMode;
- this.saveTopologyOnlyModeToStorage();
- }
-
- getChatSidebarVisible(): boolean {
- return this.chatSidebarVisible;
- }
-
- setChatSidebarVisible(visible: boolean) {
- this.chatSidebarVisible = visible;
- this.saveChatSidebarVisibleToStorage();
- }
-
- toggleChatSidebarVisible() {
- this.chatSidebarVisible = !this.chatSidebarVisible;
- this.saveChatSidebarVisibleToStorage();
- }
-
- startPolling() {
- this.fetchState();
- this.fetchInterval = setInterval(() => this.fetchState(), 1000);
- }
-
- stopPolling() {
- if (this.fetchInterval) {
- clearInterval(this.fetchInterval);
- this.fetchInterval = null;
- }
- this.stopPreviewsPolling();
- }
-
- async fetchState() {
- try {
- const response = await fetch("/state");
- if (!response.ok) {
- throw new Error(`Failed to fetch state: ${response.status}`);
- }
- const data: RawStateResponse = await response.json();
-
- if (data.topology) {
- this.topologyData = transformTopology(data.topology, {
- nodeIdentities: data.nodeIdentities,
- nodeMemory: data.nodeMemory,
- nodeSystem: data.nodeSystem,
- nodeNetwork: data.nodeNetwork,
- });
- }
- if (data.instances) {
- this.instances = data.instances;
- this.refreshConversationModelFromInstances();
- }
- if (data.runners) {
- this.runners = data.runners;
- }
- if (data.downloads) {
- this.downloads = data.downloads;
- }
- this.lastUpdate = Date.now();
- } catch (error) {
- console.error("Error fetching state:", error);
- }
- }
-
- async fetchPlacementPreviews(modelId: string, showLoading = true) {
- if (!modelId) return;
-
- if (showLoading) {
- this.isLoadingPreviews = true;
- }
- this.selectedPreviewModelId = modelId;
-
- try {
- const response = await fetch(
- `/instance/previews?model_id=${encodeURIComponent(modelId)}`,
- );
- if (!response.ok) {
- throw new Error(
- `Failed to fetch placement previews: ${response.status}`,
- );
- }
- const data: PlacementPreviewResponse = await response.json();
- this.placementPreviews = data.previews;
- } catch (error) {
- console.error("Error fetching placement previews:", error);
- this.placementPreviews = [];
- } finally {
- if (showLoading) {
- this.isLoadingPreviews = false;
- }
- }
- }
-
- startPreviewsPolling(modelId: string) {
- // Stop any existing preview polling
- this.stopPreviewsPolling();
-
- // Fetch immediately
- this.fetchPlacementPreviews(modelId);
-
- // Then poll every 15 seconds (don't show loading spinner for subsequent fetches)
- this.previewsInterval = setInterval(() => {
- if (this.selectedPreviewModelId) {
- this.fetchPlacementPreviews(this.selectedPreviewModelId, false);
- }
- }, 15000);
- }
-
- stopPreviewsPolling() {
- if (this.previewsInterval) {
- clearInterval(this.previewsInterval);
- this.previewsInterval = null;
- }
- }
-
- selectPreviewModel(modelId: string | null) {
- if (modelId) {
- this.startPreviewsPolling(modelId);
- } else {
- this.stopPreviewsPolling();
- this.selectedPreviewModelId = null;
- this.placementPreviews = [];
- }
- }
-
- /**
- * Starts a chat conversation - triggers the topology minimization animation
- * Creates a new conversation if none is active
- */
- startChat() {
- if (!this.activeConversationId) {
- this.createConversation();
- } else {
- this.hasStartedChat = true;
- this.isSidebarOpen = true; // Auto-open sidebar when chatting
- // Small delay before minimizing for a nice visual effect
- setTimeout(() => {
- this.isTopologyMinimized = true;
- }, 100);
- }
- }
-
- /**
- * Add a message to the conversation
- */
- addMessage(role: "user" | "assistant", content: string) {
- const message: Message = {
- id: generateUUID(),
- role,
- content,
- timestamp: Date.now(),
- };
- this.messages.push(message);
- return message;
- }
-
- /**
- * Delete a message and all subsequent messages
- */
- deleteMessage(messageId: string) {
- const messageIndex = this.messages.findIndex((m) => m.id === messageId);
- if (messageIndex === -1) return;
-
- // Remove this message and all subsequent messages
- this.messages = this.messages.slice(0, messageIndex);
- this.updateActiveConversation();
- }
-
- /**
- * Edit a user message content (does not regenerate response)
- */
- editMessage(messageId: string, newContent: string) {
- const message = this.messages.find((m) => m.id === messageId);
- if (!message) return;
-
- message.content = newContent;
- message.timestamp = Date.now();
- this.updateActiveConversation();
- }
-
- /**
- * Edit a user message and regenerate the response
- */
- async editAndRegenerate(
- messageId: string,
- newContent: string,
- ): Promise<void> {
- const messageIndex = this.messages.findIndex((m) => m.id === messageId);
- if (messageIndex === -1) return;
-
- const message = this.messages[messageIndex];
- if (message.role !== "user") return;
-
- // Update the message content
- message.content = newContent;
- message.timestamp = Date.now();
-
- // Remove all messages after this one (including the assistant response)
- this.messages = this.messages.slice(0, messageIndex + 1);
-
- // Regenerate the response
- await this.regenerateLastResponse();
- }
-
- /**
- * Regenerate the last assistant response
- */
- async regenerateLastResponse(): Promise<void> {
- if (this.isLoading) return;
-
- // Find the last user message
- let lastUserIndex = -1;
- for (let i = this.messages.length - 1; i >= 0; i--) {
- if (this.messages[i].role === "user") {
- lastUserIndex = i;
- break;
- }
- }
-
- if (lastUserIndex === -1) return;
-
- // Remove any messages after the user message
- this.messages = this.messages.slice(0, lastUserIndex + 1);
-
- // Resend the message to get a new response
- this.isLoading = true;
- this.currentResponse = "";
-
- // Create placeholder for assistant message
- const assistantMessage = this.addMessage("assistant", "");
-
- try {
- const systemPrompt = {
- role: "system" as const,
- content:
- "You are a helpful AI assistant. Respond directly and concisely. Do not show your reasoning or thought process.",
- };
-
- const apiMessages = [
- systemPrompt,
- ...this.messages.slice(0, -1).map((m) => {
- return { role: m.role, content: m.content };
- }),
- ];
-
- // Determine which model to use
- let modelToUse = this.selectedChatModel;
- if (!modelToUse) {
- const firstInstanceKey = Object.keys(this.instances)[0];
- if (firstInstanceKey) {
- const instance = this.instances[firstInstanceKey] as
- | Record<string, unknown>
- | undefined;
- if (instance) {
- const keys = Object.keys(instance);
- if (keys.length === 1) {
- const inst = instance[keys[0]] as
- | { shardAssignments?: { modelId?: string } }
- | undefined;
- modelToUse = inst?.shardAssignments?.modelId || "";
- }
- }
- }
- }
-
- if (!modelToUse) {
- const idx = this.messages.findIndex(
- (m) => m.id === assistantMessage.id,
- );
- if (idx !== -1) {
- this.messages[idx].content =
- "Error: No model available. Please launch an instance first.";
- }
- this.isLoading = false;
- this.updateActiveConversation();
- return;
- }
-
- const response = await fetch("/v1/chat/completions", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- model: modelToUse,
- messages: apiMessages,
- stream: true,
- }),
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- const idx = this.messages.findIndex(
- (m) => m.id === assistantMessage.id,
- );
- if (idx !== -1) {
- this.messages[idx].content =
- `Error: ${response.status} - ${errorText}`;
- }
- this.isLoading = false;
- this.updateActiveConversation();
- return;
- }
-
- const reader = response.body?.getReader();
- if (!reader) {
- const idx = this.messages.findIndex(
- (m) => m.id === assistantMessage.id,
- );
- if (idx !== -1) {
- this.messages[idx].content = "Error: No response stream available";
- }
- this.isLoading = false;
- this.updateActiveConversation();
- return;
- }
-
- const decoder = new TextDecoder();
- let fullContent = "";
- let partialLine = "";
-
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- const chunk = decoder.decode(value, { stream: true });
- const lines = (partialLine + chunk).split("\n");
- partialLine = lines.pop() || "";
-
- for (const line of lines) {
- const trimmed = line.trim();
- if (!trimmed || trimmed === "data: [DONE]") continue;
-
- if (trimmed.startsWith("data: ")) {
- try {
- const json = JSON.parse(trimmed.slice(6));
- const delta = json.choices?.[0]?.delta?.content;
- if (delta) {
- fullContent += delta;
- const { displayContent, thinkingContent } =
- this.stripThinkingTags(fullContent);
- this.currentResponse = displayContent;
-
- // Update the assistant message in place (triggers Svelte reactivity)
- const idx = this.messages.findIndex(
- (m) => m.id === assistantMessage.id,
- );
- if (idx !== -1) {
- this.messages[idx].content = displayContent;
- this.messages[idx].thinking = thinkingContent || undefined;
- }
- this.persistActiveConversation();
- }
- } catch {
- // Skip malformed JSON
- }
- }
- }
- }
-
- // Final cleanup of the message
- const { displayContent, thinkingContent } =
- this.stripThinkingTags(fullContent);
- const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
- if (idx !== -1) {
- this.messages[idx].content = displayContent;
- this.messages[idx].thinking = thinkingContent || undefined;
- }
- this.persistActiveConversation();
- } catch (error) {
- const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
- if (idx !== -1) {
- this.messages[idx].content =
- `Error: ${error instanceof Error ? error.message : "Unknown error"}`;
- }
- this.persistActiveConversation();
- } finally {
- this.isLoading = false;
- this.currentResponse = "";
- this.updateActiveConversation();
- }
- }
-
- /**
- * Selected model for chat (can be set by the UI)
- */
- selectedChatModel = $state("");
-
- /**
- * Set the model to use for chat
- */
- setSelectedModel(modelId: string) {
- this.selectedChatModel = modelId;
- // Clear stats when model changes
- this.ttftMs = null;
- this.tps = null;
- }
-
- /**
- * Strip thinking tags from content for display.
- * Handles both complete <think>...</think> blocks and in-progress <think>... blocks during streaming.
- */
- private stripThinkingTags(content: string): {
- displayContent: string;
- thinkingContent: string;
- } {
- const extracted: string[] = [];
- let displayContent = content;
-
- // Extract complete <think>...</think> blocks
- const completeBlockRegex = /<think>([\s\S]*?)<\/think>/gi;
- let match: RegExpExecArray | null;
- while ((match = completeBlockRegex.exec(content)) !== null) {
- const inner = match[1]?.trim();
- if (inner) extracted.push(inner);
- }
- displayContent = displayContent.replace(completeBlockRegex, "");
-
- // Handle in-progress thinking block (has <think> but no closing </think> yet)
- const openTagIndex = displayContent.lastIndexOf("<think>");
- if (openTagIndex !== -1) {
- const inProgressThinking = displayContent.slice(openTagIndex + 7).trim();
- if (inProgressThinking) {
- extracted.push(inProgressThinking);
- }
- displayContent = displayContent.slice(0, openTagIndex);
- }
-
- return {
- displayContent: displayContent.trim(),
- thinkingContent: extracted.join("\n\n"),
- };
- }
-
- /**
- * Send a message to the LLM and stream the response
- */
- async sendMessage(
- content: string,
- files?: {
- id: string;
- name: string;
- type: string;
- textContent?: string;
- preview?: string;
- }[],
- ): Promise<void> {
- if ((!content.trim() && (!files || files.length === 0)) || this.isLoading)
- return;
-
- if (!this.hasStartedChat) {
- this.startChat();
- }
-
- this.isLoading = true;
- this.currentResponse = "";
- this.ttftMs = null;
- this.tps = null;
- this.totalTokens = 0;
-
- // Build attachments from files
- const attachments: MessageAttachment[] = [];
- let fileContext = "";
-
- if (files && files.length > 0) {
- for (const file of files) {
- const isImage = file.type.startsWith("image/");
-
- if (isImage && file.preview) {
- attachments.push({
- type: "image",
- name: file.name,
- preview: file.preview,
- mimeType: file.type,
- });
- } else if (file.textContent) {
- attachments.push({
- type: "text",
- name: file.name,
- content: file.textContent,
- mimeType: file.type,
- });
- // Add text file content to the message context
- fileContext += `\n\n[File: ${file.name}]\n\`\`\`\n${file.textContent}\n\`\`\``;
- } else {
- attachments.push({
- type: "file",
- name: file.name,
- mimeType: file.type,
- });
- }
- }
- }
-
- // Combine content with file context
- const fullContent = content + fileContext;
-
- // Add user message with attachments
- const userMessage: Message = {
- id: generateUUID(),
- role: "user",
- content: content, // Store original content for display
- timestamp: Date.now(),
- attachments: attachments.length > 0 ? attachments : undefined,
- };
- this.messages.push(userMessage);
-
- // Create placeholder for assistant message
- const assistantMessage = this.addMessage("assistant", "");
- this.updateActiveConversation();
-
- try {
- // Build the messages array for the API with system prompt
- const systemPrompt = {
- role: "system" as const,
- content:
- "You are a helpful AI assistant. Respond directly and concisely. Do not show your reasoning or thought process. When files are shared with you, analyze them and respond helpfully.",
- };
-
- // Build API messages - include file content for text files
- const apiMessages = [
- systemPrompt,
- ...this.messages.slice(0, -1).map((m) => {
- // Build content including any text file attachments
- let msgContent = m.content;
-
- // Add text attachments as context
- if (m.attachments) {
- for (const attachment of m.attachments) {
- if (attachment.type === "text" && attachment.content) {
- msgContent += `\n\n[File: ${attachment.name}]\n\`\`\`\n${attachment.content}\n\`\`\``;
- }
- }
- }
-
- return {
- role: m.role,
- content: msgContent,
- };
- }),
- ];
-
- // Determine the model to use - prefer selectedChatModel, otherwise try to get from instances
- let modelToUse = this.selectedChatModel;
- if (!modelToUse) {
- // Try to get model from first running instance
- for (const [, instanceWrapper] of Object.entries(this.instances)) {
- if (instanceWrapper && typeof instanceWrapper === "object") {
- const keys = Object.keys(
- instanceWrapper as Record<string, unknown>,
- );
- if (keys.length === 1) {
- const instance = (instanceWrapper as Record<string, unknown>)[
- keys[0]
- ] as { shardAssignments?: { modelId?: string } };
- if (instance?.shardAssignments?.modelId) {
- modelToUse = instance.shardAssignments.modelId;
- break;
- }
- }
- }
- }
- }
-
- if (!modelToUse) {
- throw new Error(
- "No model selected and no running instances available. Please launch an instance first.",
- );
- }
-
- const conversationModelInfo = this.buildConversationModelInfo(modelToUse);
- this.applyConversationModelInfo(conversationModelInfo);
-
- // Start timing for TTFT measurement
- const requestStartTime = performance.now();
- let firstTokenTime: number | null = null;
- let tokenCount = 0;
-
- const response = await fetch("/v1/chat/completions", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- model: modelToUse,
- messages: apiMessages,
- temperature: 0.7,
- stream: true,
- }),
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`API error: ${response.status} - ${errorText}`);
- }
-
- const reader = response.body?.getReader();
- if (!reader) {
- throw new Error("No response body");
- }
-
- const decoder = new TextDecoder();
- let fullContent = "";
- let buffer = "";
-
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- buffer += decoder.decode(value, { stream: true });
-
- // Process complete lines
- const lines = buffer.split("\n");
- buffer = lines.pop() || ""; // Keep incomplete line in buffer
-
- for (const line of lines) {
- const trimmed = line.trim();
- if (!trimmed) continue;
-
- if (trimmed.startsWith("data: ")) {
- const data = trimmed.slice(6);
- if (data === "[DONE]") continue;
-
- try {
- const parsed = JSON.parse(data);
- const tokenContent = parsed.choices?.[0]?.delta?.content;
- if (tokenContent) {
- // Track first token for TTFT
- if (firstTokenTime === null) {
- firstTokenTime = performance.now();
- this.ttftMs = firstTokenTime - requestStartTime;
- }
-
- // Count tokens (each SSE chunk is typically one token)
- tokenCount += 1;
- this.totalTokens = tokenCount;
-
- // Update real-time TPS during streaming
- if (firstTokenTime !== null && tokenCount > 1) {
- const elapsed = performance.now() - firstTokenTime;
- this.tps = (tokenCount / elapsed) * 1000;
- }
-
- fullContent += tokenContent;
-
- // Strip thinking tags for display and extract thinking content
- const { displayContent, thinkingContent } =
- this.stripThinkingTags(fullContent);
- this.currentResponse = displayContent;
-
- // Update the assistant message in place
- const idx = this.messages.findIndex(
- (m) => m.id === assistantMessage.id,
- );
- if (idx !== -1) {
- this.messages[idx].content = displayContent;
- this.messages[idx].thinking = thinkingContent || undefined;
- }
- this.persistActiveConversation();
- }
- } catch {
- // Skip invalid JSON lines
- }
- }
- }
- }
-
- // Process any remaining buffer
- if (buffer.trim()) {
- const trimmed = buffer.trim();
- if (trimmed.startsWith("data: ") && trimmed.slice(6) !== "[DONE]") {
- try {
- const parsed = JSON.parse(trimmed.slice(6));
- const tokenContent = parsed.choices?.[0]?.delta?.content;
- if (tokenContent) {
- fullContent += tokenContent;
- this.persistActiveConversation();
- }
- } catch {
- // Skip
- }
- }
- }
-
- // Calculate final TPS
- if (firstTokenTime !== null && tokenCount > 1) {
- const totalGenerationTime = performance.now() - firstTokenTime;
- this.tps = (tokenCount / totalGenerationTime) * 1000; // tokens per second
- }
-
- // Final cleanup of the message
- const { displayContent, thinkingContent } =
- this.stripThinkingTags(fullContent);
- const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
- if (idx !== -1) {
- this.messages[idx].content = displayContent;
- this.messages[idx].thinking = thinkingContent || undefined;
- // Store performance metrics on the message
- if (this.ttftMs !== null) {
- this.messages[idx].ttftMs = this.ttftMs;
- }
- if (this.tps !== null) {
- this.messages[idx].tps = this.tps;
- }
- }
- this.persistActiveConversation();
- } catch (error) {
- console.error("Error sending message:", error);
- // Update the assistant message with error
- const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
- if (idx !== -1) {
- this.messages[idx].content =
- `Error: ${error instanceof Error ? error.message : "Failed to get response"}`;
- }
- this.persistActiveConversation();
- } finally {
- this.isLoading = false;
- this.currentResponse = "";
- this.updateActiveConversation();
- }
- }
-
- /**
- * Generate an image using the image generation API
- */
- async generateImage(prompt: string, modelId?: string): Promise<void> {
- if (!prompt.trim() || this.isLoading) return;
-
- if (!this.hasStartedChat) {
- this.startChat();
- }
-
- this.isLoading = true;
- this.currentResponse = "";
-
- // Add user message
- const userMessage: Message = {
- id: generateUUID(),
- role: "user",
- content: prompt,
- timestamp: Date.now(),
- };
- this.messages.push(userMessage);
-
- // Create placeholder for assistant message with generating state
- const assistantMessage = this.addMessage("assistant", "");
- this.messages[this.messages.length - 1].content = "Generating image...";
- this.updateActiveConversation();
-
- try {
- // Determine the model to use
- let model = modelId || this.selectedChatModel;
- if (!model) {
- throw new Error(
- "No model selected. Please select an image generation model.",
- );
- }
-
- // Build request body using image generation params
- const params = this.imageGenerationParams;
- const hasAdvancedParams =
- params.seed !== null ||
- params.numInferenceSteps !== null ||
- params.guidance !== null ||
- (params.negativePrompt !== null && params.negativePrompt.trim() !== "");
-
- const requestBody: Record<string, unknown> = {
- model,
- prompt,
- quality: params.quality,
- size: params.size,
- output_format: params.outputFormat,
- response_format: "b64_json",
- stream: true,
- partial_images: 3,
- };
-
- if (hasAdvancedParams) {
- requestBody.advanced_params = {
- ...(params.seed !== null && { seed: params.seed }),
- ...(params.numInferenceSteps !== null && {
- num_inference_steps: params.numInferenceSteps,
- }),
- ...(params.guidance !== null && { guidance: params.guidance }),
- ...(params.negativePrompt !== null &&
- params.negativePrompt.trim() !== "" && {
- negative_prompt: params.negativePrompt,
- }),
- };
- }
-
- const response = await fetch("/v1/images/generations", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(requestBody),
- });
-
- if (!response.ok) {
- const errorText = await response.text();
- throw new Error(`API error: ${response.status} - ${errorText}`);
- }
-
- const reader = response.body?.getReader();
- if (!reader) {
- throw new Error("No response body");
- }
-
- const decoder = new TextDecoder();
- let buffer = "";
- const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
-
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- buffer += decoder.decode(value, { stream: true });
-
- // Process complete lines
- const lines = buffer.split("\n");
- buffer = lines.pop() || ""; // Keep incomplete line in buffer
-
- for (const line of lines) {
- const trimmed = line.trim();
- if (!trimmed) continue;
-
- if (trimmed.startsWith("data: ")) {
- const data = trimmed.slice(6);
- if (data === "[DONE]") continue;
-
- try {
- const parsed = JSON.parse(data);
- const imageData = parsed.data?.b64_json;
-
- if (imageData && idx !== -1) {
- const format = parsed.format || "png";
- const mimeType = `image/${format}`;
- if (parsed.type === "partial") {
- // Update with partial image and progress
- const partialNum = (parsed.partial_index ?? 0) + 1;
- const totalPartials = parsed.total_partials ?? 3;
- this.messages[idx].content =
- `Generating... ${partialNum}/${totalPartials}`;
- this.messages[idx].attachments = [
- {
- type: "generated-image",
- name: `generated-image.${format}`,
- preview: `data:${mimeType};base64,${imageData}`,
- mimeType,
- },
- ];
- } else if (parsed.type === "final") {
- // Final image
- this.messages[idx].content = "";
- this.messages[idx].attachments = [
- {
- type: "generated-image",
- name: `generated-image.${format}`,
- preview: `data:${mimeType};base64,${imageData}`,
- mimeType,
- },
- ];
- }
- }
- } catch {
- // Ignore parse errors for incomplete JSON
- }
- }
- }
- }
- } catch (error) {
- console.error("Error generating image:", error);
- const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
- if (idx !== -1) {
- this.messages[idx].content =
- `Error: ${error instanceof Error ? error.message : "Failed to generate image"}`;
- }
- } finally {
- this.isLoading = false;
- this.updateActiveConversation();
- }
- }
-
- /**
- * Edit an image using the image edit API
- */
- async editImage(
- prompt: string,
- imageDataUrl: string,
- modelId?: string,
- ): Promise<void> {
- if (!prompt.trim() || !imageDataUrl || this.isLoading) return;
-
- if (!this.hasStartedChat) {
- this.startChat();
- }
-
- this.isLoading = true;
- this.currentResponse = "";
-
- // Add user message with the edit prompt
- const userMessage: Message = {
- id: generateUUID(),
- role: "user",
- content: prompt,
- timestamp: Date.now(),
- };
- this.messages.push(userMessage);
-
- // Create placeholder for assistant message with generating state
- const assistantMessage = this.addMessage("assistant", "");
- this.messages[this.messages.length - 1].content = "Editing image...";
- this.updateActiveConversation();
-
- // Clear editing state
- this.editingImage = null;
-
- try {
- // Determine the model to use
- let model = modelId || this.selectedChatModel;
- if (!model) {
- throw new Error(
- "No model selected. Please select an image generation model.",
- );
- }
-
- // Convert base64 data URL to blob
- const response = await fetch(imageDataUrl);
- const imageBlob = await response.blob();
-
- // Build FormData request
- const formData = new FormData();
- formData.append("model", model);
- formData.append("prompt", prompt);
- formData.append("image", imageBlob, "image.png");
-
- // Add params from image generation params
- const params = this.imageGenerationParams;
- formData.append("quality", params.quality);
- formData.append("size", params.size);
- formData.append("output_format", params.outputFormat);
- formData.append("response_format", "b64_json");
- formData.append("stream", "1"); // Use "1" instead of "true" for reliable FastAPI boolean parsing
- formData.append("partial_images", "3");
- formData.append("input_fidelity", params.inputFidelity);
-
- // Advanced params
- if (params.seed !== null) {
- formData.append(
- "advanced_params",
- JSON.stringify({
- seed: params.seed,
- ...(params.numInferenceSteps !== null && {
- num_inference_steps: params.numInferenceSteps,
- }),
- ...(params.guidance !== null && { guidance: params.guidance }),
- ...(params.negativePrompt !== null &&
- params.negativePrompt.trim() !== "" && {
- negative_prompt: params.negativePrompt,
- }),
- }),
- );
- } else if (
- params.numInferenceSteps !== null ||
- params.guidance !== null ||
- (params.negativePrompt !== null && params.negativePrompt.trim() !== "")
- ) {
- formData.append(
- "advanced_params",
- JSON.stringify({
- ...(params.numInferenceSteps !== null && {
- num_inference_steps: params.numInferenceSteps,
- }),
- ...(params.guidance !== null && { guidance: params.guidance }),
- ...(params.negativePrompt !== null &&
- params.negativePrompt.trim() !== "" && {
- negative_prompt: params.negativePrompt,
- }),
- }),
- );
- }
-
- const apiResponse = await fetch("/v1/images/edits", {
- method: "POST",
- body: formData,
- });
-
- if (!apiResponse.ok) {
- const errorText = await apiResponse.text();
- throw new Error(`API error: ${apiResponse.status} - ${errorText}`);
- }
-
- const reader = apiResponse.body?.getReader();
- if (!reader) {
- throw new Error("No response body");
- }
-
- const decoder = new TextDecoder();
- let buffer = "";
- const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
-
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- buffer += decoder.decode(value, { stream: true });
-
- // Process complete lines
- const lines = buffer.split("\n");
- buffer = lines.pop() || ""; // Keep incomplete line in buffer
-
- for (const line of lines) {
- const trimmed = line.trim();
- if (!trimmed) continue;
-
- if (trimmed.startsWith("data: ")) {
- const data = trimmed.slice(6);
- if (data === "[DONE]") continue;
-
- try {
- const parsed = JSON.parse(data);
- const imageData = parsed.data?.b64_json;
-
- if (imageData && idx !== -1) {
- const format = parsed.format || "png";
- const mimeType = `image/${format}`;
- if (parsed.type === "partial") {
- // Update with partial image and progress
- const partialNum = (parsed.partial_index ?? 0) + 1;
- const totalPartials = parsed.total_partials ?? 3;
- this.messages[idx].content =
- `Editing... ${partialNum}/${totalPartials}`;
- this.messages[idx].attachments = [
- {
- type: "generated-image",
- name: `edited-image.${format}`,
- preview: `data:${mimeType};base64,${imageData}`,
- mimeType,
- },
- ];
- } else if (parsed.type === "final") {
- // Final image
- this.messages[idx].content = "";
- this.messages[idx].attachments = [
- {
- type: "generated-image",
- name: `edited-image.${format}`,
- preview: `data:${mimeType};base64,${imageData}`,
- mimeType,
- },
- ];
- }
- }
- } catch {
- // Ignore parse errors for incomplete JSON
- }
- }
- }
- }
- } catch (error) {
- console.error("Error editing image:", error);
- const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
- if (idx !== -1) {
- this.messages[idx].content =
- `Error: ${error instanceof Error ? error.message : "Failed to edit image"}`;
- }
- } finally {
- this.isLoading = false;
- this.updateActiveConversation();
- }
- }
-
- /**
- * Clear current chat and go back to welcome state
- */
- clearChat() {
- this.activeConversationId = null;
- this.messages = [];
- this.hasStartedChat = false;
- this.isTopologyMinimized = false;
- this.currentResponse = "";
- // Clear performance stats
- this.ttftMs = null;
- this.tps = null;
- }
-
- /**
- * Get the active conversation
- */
- getActiveConversation(): Conversation | null {
- if (!this.activeConversationId) return null;
- return (
- this.conversations.find((c) => c.id === this.activeConversationId) || null
- );
- }
+ // Conversation state
+ conversations = $state<Conversation[]>([]);
+ activeConversationId = $state<string | null>(null);
+
+ // Chat state
+ hasStartedChat = $state(false);
+ messages = $state<Message[]>([]);
+ currentResponse = $state("");
+ isLoading = $state(false);
+
+ // Performance metrics
+ ttftMs = $state<number | null>(null); // Time to first token in ms
+ tps = $state<number | null>(null); // Tokens per second
+ totalTokens = $state<number>(0); // Total tokens in current response
+
+ // Topology state
+ topologyData = $state<TopologyData | null>(null);
+ instances = $state<Record<string, unknown>>({});
+ runners = $state<Record<string, unknown>>({});
+ downloads = $state<Record<string, unknown[]>>({});
+ placementPreviews = $state<PlacementPreview[]>([]);
+ selectedPreviewModelId = $state<string | null>(null);
+ isLoadingPreviews = $state(false);
+ lastUpdate = $state<number | null>(null);
+
+ // UI state
+ isTopologyMinimized = $state(false);
+ isSidebarOpen = $state(false); // Hidden by default, shown when in chat mode
+ debugMode = $state(false);
+ topologyOnlyMode = $state(false);
+ chatSidebarVisible = $state(true); // Shown by default
+
+ // Image generation params
+ imageGenerationParams = $state<ImageGenerationParams>({
+ ...DEFAULT_IMAGE_PARAMS,
+ });
+
+ // Image editing state
+ editingImage = $state<EditingImage | null>(null);
+
+ private fetchInterval: ReturnType<typeof setInterval> | null = null;
+ private previewsInterval: ReturnType<typeof setInterval> | null = null;
+ private lastConversationPersistTs = 0;
+
+ constructor() {
+ if (browser) {
+ this.startPolling();
+ this.loadConversationsFromStorage();
+ this.loadDebugModeFromStorage();
+ this.loadTopologyOnlyModeFromStorage();
+ this.loadChatSidebarVisibleFromStorage();
+ this.loadImageGenerationParamsFromStorage();
+ }
+ }
+
+ /**
+ * Load conversations from localStorage
+ */
+ private loadConversationsFromStorage() {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored) as Array<Partial<Conversation>>;
+ this.conversations = parsed.map((conversation) => ({
+ id: conversation.id ?? generateUUID(),
+ name: conversation.name ?? "Chat",
+ messages: conversation.messages ?? [],
+ createdAt: conversation.createdAt ?? Date.now(),
+ updatedAt: conversation.updatedAt ?? Date.now(),
+ modelId: conversation.modelId ?? null,
+ sharding: conversation.sharding ?? null,
+ instanceType: conversation.instanceType ?? null,
+ }));
+ }
+ } catch (error) {
+ console.error("Failed to load conversations:", error);
+ }
+ }
+
+ /**
+ * Save conversations to localStorage
+ */
+ private saveConversationsToStorage() {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(this.conversations));
+ } catch (error) {
+ console.error("Failed to save conversations:", error);
+ }
+ }
+
+ private loadDebugModeFromStorage() {
+ try {
+ const stored = localStorage.getItem("exo-debug-mode");
+ if (stored !== null) {
+ this.debugMode = stored === "true";
+ }
+ } catch (error) {
+ console.error("Failed to load debug mode:", error);
+ }
+ }
+
+ private saveDebugModeToStorage() {
+ try {
+ localStorage.setItem("exo-debug-mode", this.debugMode ? "true" : "false");
+ } catch (error) {
+ console.error("Failed to save debug mode:", error);
+ }
+ }
+
+ private loadTopologyOnlyModeFromStorage() {
+ try {
+ const stored = localStorage.getItem("exo-topology-only-mode");
+ if (stored !== null) {
+ this.topologyOnlyMode = stored === "true";
+ }
+ } catch (error) {
+ console.error("Failed to load topology only mode:", error);
+ }
+ }
+
+ private saveTopologyOnlyModeToStorage() {
+ try {
+ localStorage.setItem(
+ "exo-topology-only-mode",
+ this.topologyOnlyMode ? "true" : "false",
+ );
+ } catch (error) {
+ console.error("Failed to save topology only mode:", error);
+ }
+ }
+
+ private loadChatSidebarVisibleFromStorage() {
+ try {
+ const stored = localStorage.getItem("exo-chat-sidebar-visible");
+ if (stored !== null) {
+ this.chatSidebarVisible = stored === "true";
+ }
+ } catch (error) {
+ console.error("Failed to load chat sidebar visibility:", error);
+ }
+ }
+
+ private saveChatSidebarVisibleToStorage() {
+ try {
+ localStorage.setItem(
+ "exo-chat-sidebar-visible",
+ this.chatSidebarVisible ? "true" : "false",
+ );
+ } catch (error) {
+ console.error("Failed to save chat sidebar visibility:", error);
+ }
+ }
+
+ private loadImageGenerationParamsFromStorage() {
+ try {
+ const stored = localStorage.getItem(IMAGE_PARAMS_STORAGE_KEY);
+ if (stored) {
+ const parsed = JSON.parse(stored) as Partial<ImageGenerationParams>;
+ this.imageGenerationParams = {
+ ...DEFAULT_IMAGE_PARAMS,
+ ...parsed,
+ };
+ }
+ } catch (error) {
+ console.error("Failed to load image generation params:", error);
+ }
+ }
+
+ private saveImageGenerationParamsToStorage() {
+ try {
+ localStorage.setItem(
+ IMAGE_PARAMS_STORAGE_KEY,
+ JSON.stringify(this.imageGenerationParams),
+ );
+ } catch (error) {
+ console.error("Failed to save image generation params:", error);
+ }
+ }
+
+ getImageGenerationParams(): ImageGenerationParams {
+ return this.imageGenerationParams;
+ }
+
+ setImageGenerationParams(params: Partial<ImageGenerationParams>) {
+ this.imageGenerationParams = {
+ ...this.imageGenerationParams,
+ ...params,
+ };
+ this.saveImageGenerationParamsToStorage();
+ }
+
+ resetImageGenerationParams() {
+ this.imageGenerationParams = { ...DEFAULT_IMAGE_PARAMS };
+ this.saveImageGenerationParamsToStorage();
+ }
+
+ setEditingImage(imageDataUrl: string, sourceMessage: Message) {
+ this.editingImage = { imageDataUrl, sourceMessage };
+ }
+
+ clearEditingImage() {
+ this.editingImage = null;
+ }
+
+ /**
+ * Create a new conversation
+ */
+ createConversation(name?: string): string {
+ const id = generateUUID();
+ const now = Date.now();
+
+ // Try to derive model and strategy immediately from selected model or running instances
+ let derivedModelId = this.selectedChatModel || null;
+ let derivedInstanceType: string | null = null;
+ let derivedSharding: string | null = null;
+
+ // If no selected model, fall back to the first running instance
+ if (!derivedModelId) {
+ const firstInstance = Object.values(this.instances)[0];
+ if (firstInstance) {
+ const candidateModel = this.extractInstanceModelId(firstInstance);
+ derivedModelId = candidateModel ?? null;
+ const details = this.describeInstance(firstInstance);
+ derivedInstanceType = details.instanceType;
+ derivedSharding = details.sharding;
+ }
+ } else {
+ // If selected model is set, attempt to get its details from instances
+ for (const [, instanceWrapper] of Object.entries(this.instances)) {
+ const candidateModelId = this.extractInstanceModelId(instanceWrapper);
+ if (candidateModelId === derivedModelId) {
+ const details = this.describeInstance(instanceWrapper);
+ derivedInstanceType = details.instanceType;
+ derivedSharding = details.sharding;
+ break;
+ }
+ }
+ }
+
+ const conversation: Conversation = {
+ id,
+ name:
+ name ||
+ `Chat ${new Date(now).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}`,
+ messages: [],
+ createdAt: now,
+ updatedAt: now,
+ modelId: derivedModelId,
+ sharding: derivedSharding,
+ instanceType: derivedInstanceType,
+ };
+
+ this.conversations.unshift(conversation);
+ this.activeConversationId = id;
+ this.messages = [];
+ this.hasStartedChat = true;
+ this.isTopologyMinimized = true;
+ this.isSidebarOpen = true; // Auto-open sidebar when chatting
+
+ this.saveConversationsToStorage();
+ return id;
+ }
+
+ /**
+ * Load a conversation by ID
+ */
+ loadConversation(id: string): boolean {
+ const conversation = this.conversations.find((c) => c.id === id);
+ if (!conversation) return false;
+
+ this.activeConversationId = id;
+ this.messages = [...conversation.messages];
+ this.hasStartedChat = true;
+ this.isTopologyMinimized = true;
+ this.isSidebarOpen = true; // Auto-open sidebar when chatting
+ this.refreshConversationModelFromInstances();
+
+ return true;
+ }
+
+ /**
+ * Delete a conversation by ID
+ */
+ deleteConversation(id: string) {
+ this.conversations = this.conversations.filter((c) => c.id !== id);
+
+ if (this.activeConversationId === id) {
+ this.activeConversationId = null;
+ this.messages = [];
+ this.hasStartedChat = false;
+ this.isTopologyMinimized = false;
+ }
+
+ this.saveConversationsToStorage();
+ }
+
+ /**
+ * Delete all conversations
+ */
+ deleteAllConversations() {
+ this.conversations = [];
+ this.activeConversationId = null;
+ this.messages = [];
+ this.hasStartedChat = false;
+ this.isTopologyMinimized = false;
+ this.saveConversationsToStorage();
+ }
+
+ /**
+ * Rename a conversation
+ */
+ renameConversation(id: string, newName: string) {
+ const conversation = this.conversations.find((c) => c.id === id);
+ if (conversation) {
+ conversation.name = newName;
+ conversation.updatedAt = Date.now();
+ this.saveConversationsToStorage();
+ }
+ }
+
+ private getTaggedValue(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];
+ }
+
+ private extractInstanceModelId(instanceWrapped: unknown): string | null {
+ const [, instance] = this.getTaggedValue(instanceWrapped);
+ if (!instance || typeof instance !== "object") return null;
+ const inst = instance as { shardAssignments?: { modelId?: string } };
+ return inst.shardAssignments?.modelId ?? null;
+ }
+
+ private describeInstance(instanceWrapped: unknown): {
+ sharding: string | null;
+ instanceType: string | null;
+ } {
+ const [instanceTag, instance] = this.getTaggedValue(instanceWrapped);
+ if (!instance || typeof instance !== "object") {
+ return { sharding: null, instanceType: null };
+ }
+
+ let instanceType: string | null = null;
+ if (instanceTag === "MlxRingInstance") instanceType = "MLX Ring";
+ else if (
+ instanceTag === "MlxIbvInstance" ||
+ instanceTag === "MlxJacclInstance"
+ )
+ instanceType = "MLX RDMA";
+
+ let sharding: string | null = null;
+ const inst = instance as {
+ shardAssignments?: { runnerToShard?: Record<string, unknown> };
+ };
+ const runnerToShard = inst.shardAssignments?.runnerToShard || {};
+ const firstShardWrapped = Object.values(runnerToShard)[0];
+ if (firstShardWrapped) {
+ const [shardTag] = this.getTaggedValue(firstShardWrapped);
+ if (shardTag === "PipelineShardMetadata") sharding = "Pipeline";
+ else if (shardTag === "TensorShardMetadata") sharding = "Tensor";
+ else if (shardTag === "PrefillDecodeShardMetadata")
+ sharding = "Prefill/Decode";
+ }
+
+ return { sharding, instanceType };
+ }
+
+ private buildConversationModelInfo(modelId: string): {
+ modelId: string;
+ sharding: string | null;
+ instanceType: string | null;
+ } {
+ let sharding: string | null = null;
+ let instanceType: string | null = null;
+
+ for (const [, instanceWrapper] of Object.entries(this.instances)) {
+ const candidateModelId = this.extractInstanceModelId(instanceWrapper);
+ if (candidateModelId === modelId) {
+ const details = this.describeInstance(instanceWrapper);
+ sharding = details.sharding;
+ instanceType = details.instanceType;
+ break;
+ }
+ }
+
+ return { modelId, sharding, instanceType };
+ }
+
+ private applyConversationModelInfo(info: {
+ modelId: string;
+ sharding: string | null;
+ instanceType: string | null;
+ }) {
+ if (!this.activeConversationId) return;
+ const conversation = this.conversations.find(
+ (c) => c.id === this.activeConversationId,
+ );
+ if (!conversation) return;
+
+ // Keep the first known modelId stable; only backfill if missing
+ if (!conversation.modelId) {
+ conversation.modelId = info.modelId;
+ }
+ conversation.sharding = info.sharding;
+ conversation.instanceType = info.instanceType;
+ this.saveConversationsToStorage();
+ }
+
+ private getModelTail(modelId: string): string {
+ const parts = modelId.split("/");
+ return (parts[parts.length - 1] || modelId).toLowerCase();
+ }
+
+ private isBetterModelId(
+ currentId: string | null,
+ candidateId: string | null,
+ ): boolean {
+ if (!candidateId) return false;
+ if (!currentId) return true;
+ const currentTail = this.getModelTail(currentId);
+ const candidateTail = this.getModelTail(candidateId);
+ return (
+ candidateTail.length > currentTail.length &&
+ candidateTail.startsWith(currentTail)
+ );
+ }
+
+ private refreshConversationModelFromInstances() {
+ if (!this.activeConversationId) return;
+ const conversation = this.conversations.find(
+ (c) => c.id === this.activeConversationId,
+ );
+ if (!conversation) return;
+
+ // Prefer stored model; do not replace it once set. Only backfill when missing.
+ let modelId = conversation.modelId;
+
+ // If missing, try the selected model
+ if (!modelId && this.selectedChatModel) {
+ modelId = this.selectedChatModel;
+ }
+
+ // If still missing, fall back to first instance model
+ if (!modelId) {
+ const firstInstance = Object.values(this.instances)[0];
+ if (firstInstance) {
+ modelId = this.extractInstanceModelId(firstInstance);
+ }
+ }
+
+ if (!modelId) return;
+
+ // If a more specific instance modelId is available (e.g., adds "-4bit"), prefer it
+ let preferredModelId = modelId;
+ for (const [, instanceWrapper] of Object.entries(this.instances)) {
+ const candidate = this.extractInstanceModelId(instanceWrapper);
+ if (!candidate) continue;
+ if (candidate === preferredModelId) {
+ break;
+ }
+ if (this.isBetterModelId(preferredModelId, candidate)) {
+ preferredModelId = candidate;
+ }
+ }
+
+ if (this.isBetterModelId(conversation.modelId, preferredModelId)) {
+ conversation.modelId = preferredModelId;
+ }
+
+ const info = this.buildConversationModelInfo(preferredModelId);
+ const hasNewInfo = Boolean(
+ info.sharding || info.instanceType || !conversation.modelId,
+ );
+ if (hasNewInfo) {
+ this.applyConversationModelInfo(info);
+ }
+ }
+
+ getDebugMode(): boolean {
+ return this.debugMode;
+ }
+
+ /**
+ * Update the active conversation with current messages
+ */
+ private updateActiveConversation() {
+ if (!this.activeConversationId) return;
+
+ const conversation = this.conversations.find(
+ (c) => c.id === this.activeConversationId,
+ );
+ if (conversation) {
+ conversation.messages = [...this.messages];
+ conversation.updatedAt = Date.now();
+
+ // Auto-generate name from first user message if still has default name
+ if (conversation.name.startsWith("Chat ")) {
+ const firstUserMsg = conversation.messages.find(
+ (m) => m.role === "user" && m.content.trim(),
+ );
+ if (firstUserMsg) {
+ // Clean up the content - remove file context markers and whitespace
+ let content = firstUserMsg.content
+ .replace(/\[File:.*?\][\s\S]*?```[\s\S]*?```/g, "") // Remove file attachments
+ .trim();
+
+ if (content) {
+ const preview = content.slice(0, 50);
+ conversation.name =
+ preview.length < content.length ? preview + "..." : preview;
+ }
+ }
+ }
+
+ this.saveConversationsToStorage();
+ }
+ }
+
+ private persistActiveConversation(throttleMs = 400) {
+ const now = Date.now();
+ if (now - this.lastConversationPersistTs < throttleMs) return;
+ this.lastConversationPersistTs = now;
+ this.updateActiveConversation();
+ }
+
+ /**
+ * Toggle sidebar visibility
+ */
+ toggleSidebar() {
+ this.isSidebarOpen = !this.isSidebarOpen;
+ }
+
+ setDebugMode(enabled: boolean) {
+ this.debugMode = enabled;
+ this.saveDebugModeToStorage();
+ }
+
+ toggleDebugMode() {
+ this.debugMode = !this.debugMode;
+ this.saveDebugModeToStorage();
+ }
+
+ getTopologyOnlyMode(): boolean {
+ return this.topologyOnlyMode;
+ }
+
+ setTopologyOnlyMode(enabled: boolean) {
+ this.topologyOnlyMode = enabled;
+ this.saveTopologyOnlyModeToStorage();
+ }
+
+ toggleTopologyOnlyMode() {
+ this.topologyOnlyMode = !this.topologyOnlyMode;
+ this.saveTopologyOnlyModeToStorage();
+ }
+
+ getChatSidebarVisible(): boolean {
+ return this.chatSidebarVisible;
+ }
+
+ setChatSidebarVisible(visible: boolean) {
+ this.chatSidebarVisible = visible;
+ this.saveChatSidebarVisibleToStorage();
+ }
+
+ toggleChatSidebarVisible() {
+ this.chatSidebarVisible = !this.chatSidebarVisible;
+ this.saveChatSidebarVisibleToStorage();
+ }
+
+ startPolling() {
+ this.fetchState();
+ this.fetchInterval = setInterval(() => this.fetchState(), 1000);
+ }
+
+ stopPolling() {
+ if (this.fetchInterval) {
+ clearInterval(this.fetchInterval);
+ this.fetchInterval = null;
+ }
+ this.stopPreviewsPolling();
+ }
+
+ async fetchState() {
+ try {
+ const response = await fetch("/state");
+ if (!response.ok) {
+ throw new Error(`Failed to fetch state: ${response.status}`);
+ }
+ const data: RawStateResponse = await response.json();
+
+ if (data.topology) {
+ this.topologyData = transformTopology(data.topology, {
+ nodeIdentities: data.nodeIdentities,
+ nodeMemory: data.nodeMemory,
+ nodeSystem: data.nodeSystem,
+ nodeNetwork: data.nodeNetwork,
+ });
+ }
+ if (data.instances) {
+ this.instances = data.instances;
+ this.refreshConversationModelFromInstances();
+ }
+ if (data.runners) {
+ this.runners = data.runners;
+ }
+ if (data.downloads) {
+ this.downloads = data.downloads;
+ }
+ this.lastUpdate = Date.now();
+ } catch (error) {
+ console.error("Error fetching state:", error);
+ }
+ }
+
+ async fetchPlacementPreviews(modelId: string, showLoading = true) {
+ if (!modelId) return;
+
+ if (showLoading) {
+ this.isLoadingPreviews = true;
+ }
+ this.selectedPreviewModelId = modelId;
+
+ try {
+ const response = await fetch(
+ `/instance/previews?model_id=${encodeURIComponent(modelId)}`,
+ );
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch placement previews: ${response.status}`,
+ );
+ }
+ const data: PlacementPreviewResponse = await response.json();
+ this.placementPreviews = data.previews;
+ } catch (error) {
+ console.error("Error fetching placement previews:", error);
+ this.placementPreviews = [];
+ } finally {
+ if (showLoading) {
+ this.isLoadingPreviews = false;
+ }
+ }
+ }
+
+ startPreviewsPolling(modelId: string) {
+ // Stop any existing preview polling
+ this.stopPreviewsPolling();
+
+ // Fetch immediately
+ this.fetchPlacementPreviews(modelId);
+
+ // Then poll every 15 seconds (don't show loading spinner for subsequent fetches)
+ this.previewsInterval = setInterval(() => {
+ if (this.selectedPreviewModelId) {
+ this.fetchPlacementPreviews(this.selectedPreviewModelId, false);
+ }
+ }, 15000);
+ }
+
+ stopPreviewsPolling() {
+ if (this.previewsInterval) {
+ clearInterval(this.previewsInterval);
+ this.previewsInterval = null;
+ }
+ }
+
+ selectPreviewModel(modelId: string | null) {
+ if (modelId) {
+ this.startPreviewsPolling(modelId);
+ } else {
+ this.stopPreviewsPolling();
+ this.selectedPreviewModelId = null;
+ this.placementPreviews = [];
+ }
+ }
+
+ /**
+ * Starts a chat conversation - triggers the topology minimization animation
+ * Creates a new conversation if none is active
+ */
+ startChat() {
+ if (!this.activeConversationId) {
+ this.createConversation();
+ } else {
+ this.hasStartedChat = true;
+ this.isSidebarOpen = true; // Auto-open sidebar when chatting
+ // Small delay before minimizing for a nice visual effect
+ setTimeout(() => {
+ this.isTopologyMinimized = true;
+ }, 100);
+ }
+ }
+
+ /**
+ * Add a message to the conversation
+ */
+ addMessage(role: "user" | "assistant", content: string) {
+ const message: Message = {
+ id: generateUUID(),
+ role,
+ content,
+ timestamp: Date.now(),
+ };
+ this.messages.push(message);
+ return message;
+ }
+
+ /**
+ * Delete a message and all subsequent messages
+ */
+ deleteMessage(messageId: string) {
+ const messageIndex = this.messages.findIndex((m) => m.id === messageId);
+ if (messageIndex === -1) return;
+
+ // Remove this message and all subsequent messages
+ this.messages = this.messages.slice(0, messageIndex);
+ this.updateActiveConversation();
+ }
+
+ /**
+ * Edit a user message content (does not regenerate response)
+ */
+ editMessage(messageId: string, newContent: string) {
+ const message = this.messages.find((m) => m.id === messageId);
+ if (!message) return;
+
+ message.content = newContent;
+ message.timestamp = Date.now();
+ this.updateActiveConversation();
+ }
+
+ /**
+ * Edit a user message and regenerate the response
+ */
+ async editAndRegenerate(
+ messageId: string,
+ newContent: string,
+ ): Promise<void> {
+ const messageIndex = this.messages.findIndex((m) => m.id === messageId);
+ if (messageIndex === -1) return;
+
+ const message = this.messages[messageIndex];
+ if (message.role !== "user") return;
+
+ // Update the message content
+ message.content = newContent;
+ message.timestamp = Date.now();
+
+ // Remove all messages after this one (including the assistant response)
+ this.messages = this.messages.slice(0, messageIndex + 1);
+
+ // Regenerate the response
+ await this.regenerateLastResponse();
+ }
+
+ /**
+ * Regenerate the last assistant response
+ */
+ async regenerateLastResponse(): Promise<void> {
+ if (this.isLoading) return;
+
+ // Find the last user message
+ let lastUserIndex = -1;
+ for (let i = this.messages.length - 1; i >= 0; i--) {
+ if (this.messages[i].role === "user") {
+ lastUserIndex = i;
+ break;
+ }
+ }
+
+ if (lastUserIndex === -1) return;
+
+ // Remove any messages after the user message
+ this.messages = this.messages.slice(0, lastUserIndex + 1);
+
+ // Resend the message to get a new response
+ this.isLoading = true;
+ this.currentResponse = "";
+
+ // Create placeholder for assistant message
+ const assistantMessage = this.addMessage("assistant", "");
+
+ try {
+ const systemPrompt = {
+ role: "system" as const,
+ content:
+ "You are a helpful AI assistant. Respond directly and concisely. Do not show your reasoning or thought process.",
+ };
+
+ const apiMessages = [
+ systemPrompt,
+ ...this.messages.slice(0, -1).map((m) => {
+ return { role: m.role, content: m.content };
+ }),
+ ];
+
+ // Determine which model to use
+ let modelToUse = this.selectedChatModel;
+ if (!modelToUse) {
+ const firstInstanceKey = Object.keys(this.instances)[0];
+ if (firstInstanceKey) {
+ const instance = this.instances[firstInstanceKey] as
+ | Record<string, unknown>
+ | undefined;
+ if (instance) {
+ const keys = Object.keys(instance);
+ if (keys.length === 1) {
+ const inst = instance[keys[0]] as
+ | { shardAssignments?: { modelId?: string } }
+ | undefined;
+ modelToUse = inst?.shardAssignments?.modelId || "";
+ }
+ }
+ }
+ }
+
+ if (!modelToUse) {
+ const idx = this.messages.findIndex(
+ (m) => m.id === assistantMessage.id,
+ );
+ if (idx !== -1) {
+ this.messages[idx].content =
+ "Error: No model available. Please launch an instance first.";
+ }
+ this.isLoading = false;
+ this.updateActiveConversation();
+ return;
+ }
+
+ const response = await fetch("/v1/chat/completions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model: modelToUse,
+ messages: apiMessages,
+ stream: true,
+ }),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ const idx = this.messages.findIndex(
+ (m) => m.id === assistantMessage.id,
+ );
+ if (idx !== -1) {
+ this.messages[idx].content =
+ `Error: ${response.status} - ${errorText}`;
+ }
+ this.isLoading = false;
+ this.updateActiveConversation();
+ return;
+ }
+
+ const reader = response.body?.getReader();
+ if (!reader) {
+ const idx = this.messages.findIndex(
+ (m) => m.id === assistantMessage.id,
+ );
+ if (idx !== -1) {
+ this.messages[idx].content = "Error: No response stream available";
+ }
+ this.isLoading = false;
+ this.updateActiveConversation();
+ return;
+ }
+
+ const decoder = new TextDecoder();
+ let fullContent = "";
+ let partialLine = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ const chunk = decoder.decode(value, { stream: true });
+ const lines = (partialLine + chunk).split("\n");
+ partialLine = lines.pop() || "";
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed === "data: [DONE]") continue;
+
+ if (trimmed.startsWith("data: ")) {
+ try {
+ const json = JSON.parse(trimmed.slice(6));
+ const delta = json.choices?.[0]?.delta?.content;
+ if (delta) {
+ fullContent += delta;
+ const { displayContent, thinkingContent } =
+ this.stripThinkingTags(fullContent);
+ this.currentResponse = displayContent;
+
+ // Update the assistant message in place (triggers Svelte reactivity)
+ const idx = this.messages.findIndex(
+ (m) => m.id === assistantMessage.id,
+ );
+ if (idx !== -1) {
+ this.messages[idx].content = displayContent;
+ this.messages[idx].thinking = thinkingContent || undefined;
+ }
+ this.persistActiveConversation();
+ }
+ } catch {
+ // Skip malformed JSON
+ }
+ }
+ }
+ }
+
+ // Final cleanup of the message
+ const { displayContent, thinkingContent } =
+ this.stripThinkingTags(fullContent);
+ const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
+ if (idx !== -1) {
+ this.messages[idx].content = displayContent;
+ this.messages[idx].thinking = thinkingContent || undefined;
+ }
+ this.persistActiveConversation();
+ } catch (error) {
+ const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
+ if (idx !== -1) {
+ this.messages[idx].content =
+ `Error: ${error instanceof Error ? error.message : "Unknown error"}`;
+ }
+ this.persistActiveConversation();
+ } finally {
+ this.isLoading = false;
+ this.currentResponse = "";
+ this.updateActiveConversation();
+ }
+ }
+
+ /**
+ * Selected model for chat (can be set by the UI)
+ */
+ selectedChatModel = $state("");
+
+ /**
+ * Set the model to use for chat
+ */
+ setSelectedModel(modelId: string) {
+ this.selectedChatModel = modelId;
+ // Clear stats when model changes
+ this.ttftMs = null;
+ this.tps = null;
+ }
+
+ /**
+ * Strip thinking tags from content for display.
+ * Handles both complete <think>...</think> blocks and in-progress <think>... blocks during streaming.
+ */
+ private stripThinkingTags(content: string): {
+ displayContent: string;
+ thinkingContent: string;
+ } {
+ const extracted: string[] = [];
+ let displayContent = content;
+
+ // Extract complete <think>...</think> blocks
+ const completeBlockRegex = /<think>([\s\S]*?)<\/think>/gi;
+ let match: RegExpExecArray | null;
+ while ((match = completeBlockRegex.exec(content)) !== null) {
+ const inner = match[1]?.trim();
+ if (inner) extracted.push(inner);
+ }
+ displayContent = displayContent.replace(completeBlockRegex, "");
+
+ // Handle in-progress thinking block (has <think> but no closing </think> yet)
+ const openTagIndex = displayContent.lastIndexOf("<think>");
+ if (openTagIndex !== -1) {
+ const inProgressThinking = displayContent.slice(openTagIndex + 7).trim();
+ if (inProgressThinking) {
+ extracted.push(inProgressThinking);
+ }
+ displayContent = displayContent.slice(0, openTagIndex);
+ }
+
+ return {
+ displayContent: displayContent.trim(),
+ thinkingContent: extracted.join("\n\n"),
+ };
+ }
+
+ /**
+ * Send a message to the LLM and stream the response
+ */
+ async sendMessage(
+ content: string,
+ files?: {
+ id: string;
+ name: string;
+ type: string;
+ textContent?: string;
+ preview?: string;
+ }[],
+ ): Promise<void> {
+ if ((!content.trim() && (!files || files.length === 0)) || this.isLoading)
+ return;
+
+ if (!this.hasStartedChat) {
+ this.startChat();
+ }
+
+ this.isLoading = true;
+ this.currentResponse = "";
+ this.ttftMs = null;
+ this.tps = null;
+ this.totalTokens = 0;
+
+ // Build attachments from files
+ const attachments: MessageAttachment[] = [];
+ let fileContext = "";
+
+ if (files && files.length > 0) {
+ for (const file of files) {
+ const isImage = file.type.startsWith("image/");
+
+ if (isImage && file.preview) {
+ attachments.push({
+ type: "image",
+ name: file.name,
+ preview: file.preview,
+ mimeType: file.type,
+ });
+ } else if (file.textContent) {
+ attachments.push({
+ type: "text",
+ name: file.name,
+ content: file.textContent,
+ mimeType: file.type,
+ });
+ // Add text file content to the message context
+ fileContext += `\n\n[File: ${file.name}]\n\`\`\`\n${file.textContent}\n\`\`\``;
+ } else {
+ attachments.push({
+ type: "file",
+ name: file.name,
+ mimeType: file.type,
+ });
+ }
+ }
+ }
+
+ // Combine content with file context
+ const fullContent = content + fileContext;
+
+ // Add user message with attachments
+ const userMessage: Message = {
+ id: generateUUID(),
+ role: "user",
+ content: content, // Store original content for display
+ timestamp: Date.now(),
+ attachments: attachments.length > 0 ? attachments : undefined,
+ };
+ this.messages.push(userMessage);
+
+ // Create placeholder for assistant message
+ const assistantMessage = this.addMessage("assistant", "");
+ this.updateActiveConversation();
+
+ try {
+ // Build the messages array for the API with system prompt
+ const systemPrompt = {
+ role: "system" as const,
+ content:
+ "You are a helpful AI assistant. Respond directly and concisely. Do not show your reasoning or thought process. When files are shared with you, analyze them and respond helpfully.",
+ };
+
+ // Build API messages - include file content for text files
+ const apiMessages = [
+ systemPrompt,
+ ...this.messages.slice(0, -1).map((m) => {
+ // Build content including any text file attachments
+ let msgContent = m.content;
+
+ // Add text attachments as context
+ if (m.attachments) {
+ for (const attachment of m.attachments) {
+ if (attachment.type === "text" && attachment.content) {
+ msgContent += `\n\n[File: ${attachment.name}]\n\`\`\`\n${attachment.content}\n\`\`\``;
+ }
+ }
+ }
+
+ return {
+ role: m.role,
+ content: msgContent,
+ };
+ }),
+ ];
+
+ // Determine the model to use - prefer selectedChatModel, otherwise try to get from instances
+ let modelToUse = this.selectedChatModel;
+ if (!modelToUse) {
+ // Try to get model from first running instance
+ for (const [, instanceWrapper] of Object.entries(this.instances)) {
+ if (instanceWrapper && typeof instanceWrapper === "object") {
+ const keys = Object.keys(
+ instanceWrapper as Record<string, unknown>,
+ );
+ if (keys.length === 1) {
+ const instance = (instanceWrapper as Record<string, unknown>)[
+ keys[0]
+ ] as { shardAssignments?: { modelId?: string } };
+ if (instance?.shardAssignments?.modelId) {
+ modelToUse = instance.shardAssignments.modelId;
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ if (!modelToUse) {
+ throw new Error(
+ "No model selected and no running instances available. Please launch an instance first.",
+ );
+ }
+
+ const conversationModelInfo = this.buildConversationModelInfo(modelToUse);
+ this.applyConversationModelInfo(conversationModelInfo);
+
+ // Start timing for TTFT measurement
+ const requestStartTime = performance.now();
+ let firstTokenTime: number | null = null;
+ let tokenCount = 0;
+
+ const response = await fetch("/v1/chat/completions", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ model: modelToUse,
+ messages: apiMessages,
+ temperature: 0.7,
+ stream: true,
+ }),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`API error: ${response.status} - ${errorText}`);
+ }
+
+ const reader = response.body?.getReader();
+ if (!reader) {
+ throw new Error("No response body");
+ }
+
+ const decoder = new TextDecoder();
+ let fullContent = "";
+ let buffer = "";
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+
+ // Process complete lines
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || ""; // Keep incomplete line in buffer
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+
+ if (trimmed.startsWith("data: ")) {
+ const data = trimmed.slice(6);
+ if (data === "[DONE]") continue;
+
+ try {
+ const parsed = JSON.parse(data);
+ const tokenContent = parsed.choices?.[0]?.delta?.content;
+ if (tokenContent) {
+ // Track first token for TTFT
+ if (firstTokenTime === null) {
+ firstTokenTime = performance.now();
+ this.ttftMs = firstTokenTime - requestStartTime;
+ }
+
+ // Count tokens (each SSE chunk is typically one token)
+ tokenCount += 1;
+ this.totalTokens = tokenCount;
+
+ // Update real-time TPS during streaming
+ if (firstTokenTime !== null && tokenCount > 1) {
+ const elapsed = performance.now() - firstTokenTime;
+ this.tps = (tokenCount / elapsed) * 1000;
+ }
+
+ fullContent += tokenContent;
+
+ // Strip thinking tags for display and extract thinking content
+ const { displayContent, thinkingContent } =
+ this.stripThinkingTags(fullContent);
+ this.currentResponse = displayContent;
+
+ // Update the assistant message in place
+ const idx = this.messages.findIndex(
+ (m) => m.id === assistantMessage.id,
+ );
+ if (idx !== -1) {
+ this.messages[idx].content = displayContent;
+ this.messages[idx].thinking = thinkingContent || undefined;
+ }
+ this.persistActiveConversation();
+ }
+ } catch {
+ // Skip invalid JSON lines
+ }
+ }
+ }
+ }
+
+ // Process any remaining buffer
+ if (buffer.trim()) {
+ const trimmed = buffer.trim();
+ if (trimmed.startsWith("data: ") && trimmed.slice(6) !== "[DONE]") {
+ try {
+ const parsed = JSON.parse(trimmed.slice(6));
+ const tokenContent = parsed.choices?.[0]?.delta?.content;
+ if (tokenContent) {
+ fullContent += tokenContent;
+ this.persistActiveConversation();
+ }
+ } catch {
+ // Skip
+ }
+ }
+ }
+
+ // Calculate final TPS
+ if (firstTokenTime !== null && tokenCount > 1) {
+ const totalGenerationTime = performance.now() - firstTokenTime;
+ this.tps = (tokenCount / totalGenerationTime) * 1000; // tokens per second
+ }
+
+ // Final cleanup of the message
+ const { displayContent, thinkingContent } =
+ this.stripThinkingTags(fullContent);
+ const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
+ if (idx !== -1) {
+ this.messages[idx].content = displayContent;
+ this.messages[idx].thinking = thinkingContent || undefined;
+ // Store performance metrics on the message
+ if (this.ttftMs !== null) {
+ this.messages[idx].ttftMs = this.ttftMs;
+ }
+ if (this.tps !== null) {
+ this.messages[idx].tps = this.tps;
+ }
+ }
+ this.persistActiveConversation();
+ } catch (error) {
+ console.error("Error sending message:", error);
+ // Update the assistant message with error
+ const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
+ if (idx !== -1) {
+ this.messages[idx].content =
+ `Error: ${error instanceof Error ? error.message : "Failed to get response"}`;
+ }
+ this.persistActiveConversation();
+ } finally {
+ this.isLoading = false;
+ this.currentResponse = "";
+ this.updateActiveConversation();
+ }
+ }
+
+ /**
+ * Generate an image using the image generation API
+ */
+ async generateImage(prompt: string, modelId?: string): Promise<void> {
+ if (!prompt.trim() || this.isLoading) return;
+
+ if (!this.hasStartedChat) {
+ this.startChat();
+ }
+
+ this.isLoading = true;
+ this.currentResponse = "";
+
+ // Add user message
+ const userMessage: Message = {
+ id: generateUUID(),
+ role: "user",
+ content: prompt,
+ timestamp: Date.now(),
+ };
+ this.messages.push(userMessage);
+
+ // Create placeholder for assistant message with generating state
+ const assistantMessage = this.addMessage("assistant", "");
+ this.messages[this.messages.length - 1].content = "Generating image...";
+ this.updateActiveConversation();
+
+ try {
+ // Determine the model to use
+ let model = modelId || this.selectedChatModel;
+ if (!model) {
+ throw new Error(
+ "No model selected. Please select an image generation model.",
+ );
+ }
+
+ // Build request body using image generation params
+ const params = this.imageGenerationParams;
+ const hasAdvancedParams =
+ params.seed !== null ||
+ params.numInferenceSteps !== null ||
+ params.guidance !== null ||
+ (params.negativePrompt !== null && params.negativePrompt.trim() !== "");
+
+ const requestBody: Record<string, unknown> = {
+ model,
+ prompt,
+ quality: params.quality,
+ size: params.size,
+ output_format: params.outputFormat,
+ response_format: "b64_json",
+ stream: true,
+ partial_images: 3,
+ };
+
+ if (hasAdvancedParams) {
+ requestBody.advanced_params = {
+ ...(params.seed !== null && { seed: params.seed }),
+ ...(params.numInferenceSteps !== null && {
+ num_inference_steps: params.numInferenceSteps,
+ }),
+ ...(params.guidance !== null && { guidance: params.guidance }),
+ ...(params.negativePrompt !== null &&
+ params.negativePrompt.trim() !== "" && {
+ negative_prompt: params.negativePrompt,
+ }),
+ };
+ }
+
+ const response = await fetch("/v1/images/generations", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(requestBody),
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`API error: ${response.status} - ${errorText}`);
+ }
+
+ const reader = response.body?.getReader();
+ if (!reader) {
+ throw new Error("No response body");
+ }
+
+ const decoder = new TextDecoder();
+ let buffer = "";
+ const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+
+ // Process complete lines
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || ""; // Keep incomplete line in buffer
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+
+ if (trimmed.startsWith("data: ")) {
+ const data = trimmed.slice(6);
+ if (data === "[DONE]") continue;
+
+ try {
+ const parsed = JSON.parse(data);
+ const imageData = parsed.data?.b64_json;
+
+ if (imageData && idx !== -1) {
+ const format = parsed.format || "png";
+ const mimeType = `image/${format}`;
+ if (parsed.type === "partial") {
+ // Update with partial image and progress
+ const partialNum = (parsed.partial_index ?? 0) + 1;
+ const totalPartials = parsed.total_partials ?? 3;
+ this.messages[idx].content =
+ `Generating... ${partialNum}/${totalPartials}`;
+ this.messages[idx].attachments = [
+ {
+ type: "generated-image",
+ name: `generated-image.${format}`,
+ preview: `data:${mimeType};base64,${imageData}`,
+ mimeType,
+ },
+ ];
+ } else if (parsed.type === "final") {
+ // Final image
+ this.messages[idx].content = "";
+ this.messages[idx].attachments = [
+ {
+ type: "generated-image",
+ name: `generated-image.${format}`,
+ preview: `data:${mimeType};base64,${imageData}`,
+ mimeType,
+ },
+ ];
+ }
+ }
+ } catch {
+ // Ignore parse errors for incomplete JSON
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.error("Error generating image:", error);
+ const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
+ if (idx !== -1) {
+ this.messages[idx].content =
+ `Error: ${error instanceof Error ? error.message : "Failed to generate image"}`;
+ }
+ } finally {
+ this.isLoading = false;
+ this.updateActiveConversation();
+ }
+ }
+
+ /**
+ * Edit an image using the image edit API
+ */
+ async editImage(
+ prompt: string,
+ imageDataUrl: string,
+ modelId?: string,
+ ): Promise<void> {
+ if (!prompt.trim() || !imageDataUrl || this.isLoading) return;
+
+ if (!this.hasStartedChat) {
+ this.startChat();
+ }
+
+ this.isLoading = true;
+ this.currentResponse = "";
+
+ // Add user message with the edit prompt
+ const userMessage: Message = {
+ id: generateUUID(),
+ role: "user",
+ content: prompt,
+ timestamp: Date.now(),
+ };
+ this.messages.push(userMessage);
+
+ // Create placeholder for assistant message with generating state
+ const assistantMessage = this.addMessage("assistant", "");
+ this.messages[this.messages.length - 1].content = "Editing image...";
+ this.updateActiveConversation();
+
+ // Clear editing state
+ this.editingImage = null;
+
+ try {
+ // Determine the model to use
+ let model = modelId || this.selectedChatModel;
+ if (!model) {
+ throw new Error(
+ "No model selected. Please select an image generation model.",
+ );
+ }
+
+ // Convert base64 data URL to blob
+ const response = await fetch(imageDataUrl);
+ const imageBlob = await response.blob();
+
+ // Build FormData request
+ const formData = new FormData();
+ formData.append("model", model);
+ formData.append("prompt", prompt);
+ formData.append("image", imageBlob, "image.png");
+
+ // Add params from image generation params
+ const params = this.imageGenerationParams;
+ formData.append("quality", params.quality);
+ formData.append("size", params.size);
+ formData.append("output_format", params.outputFormat);
+ formData.append("response_format", "b64_json");
+ formData.append("stream", "1"); // Use "1" instead of "true" for reliable FastAPI boolean parsing
+ formData.append("partial_images", "3");
+ formData.append("input_fidelity", params.inputFidelity);
+
+ // Advanced params
+ if (params.seed !== null) {
+ formData.append(
+ "advanced_params",
+ JSON.stringify({
+ seed: params.seed,
+ ...(params.numInferenceSteps !== null && {
+ num_inference_steps: params.numInferenceSteps,
+ }),
+ ...(params.guidance !== null && { guidance: params.guidance }),
+ ...(params.negativePrompt !== null &&
+ params.negativePrompt.trim() !== "" && {
+ negative_prompt: params.negativePrompt,
+ }),
+ }),
+ );
+ } else if (
+ params.numInferenceSteps !== null ||
+ params.guidance !== null ||
+ (params.negativePrompt !== null && params.negativePrompt.trim() !== "")
+ ) {
+ formData.append(
+ "advanced_params",
+ JSON.stringify({
+ ...(params.numInferenceSteps !== null && {
+ num_inference_steps: params.numInferenceSteps,
+ }),
+ ...(params.guidance !== null && { guidance: params.guidance }),
+ ...(params.negativePrompt !== null &&
+ params.negativePrompt.trim() !== "" && {
+ negative_prompt: params.negativePrompt,
+ }),
+ }),
+ );
+ }
+
+ const apiResponse = await fetch("/v1/images/edits", {
+ method: "POST",
+ body: formData,
+ });
+
+ if (!apiResponse.ok) {
+ const errorText = await apiResponse.text();
+ throw new Error(`API error: ${apiResponse.status} - ${errorText}`);
+ }
+
+ const reader = apiResponse.body?.getReader();
+ if (!reader) {
+ throw new Error("No response body");
+ }
+
+ const decoder = new TextDecoder();
+ let buffer = "";
+ const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+
+ // Process complete lines
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || ""; // Keep incomplete line in buffer
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+
+ if (trimmed.startsWith("data: ")) {
+ const data = trimmed.slice(6);
+ if (data === "[DONE]") continue;
+
+ try {
+ const parsed = JSON.parse(data);
+ const imageData = parsed.data?.b64_json;
+
+ if (imageData && idx !== -1) {
+ const format = parsed.format || "png";
+ const mimeType = `image/${format}`;
+ if (parsed.type === "partial") {
+ // Update with partial image and progress
+ const partialNum = (parsed.partial_index ?? 0) + 1;
+ const totalPartials = parsed.total_partials ?? 3;
+ this.messages[idx].content =
+ `Editing... ${partialNum}/${totalPartials}`;
+ this.messages[idx].attachments = [
+ {
+ type: "generated-image",
+ name: `edited-image.${format}`,
+ preview: `data:${mimeType};base64,${imageData}`,
+ mimeType,
+ },
+ ];
+ } else if (parsed.type === "final") {
+ // Final image
+ this.messages[idx].content = "";
+ this.messages[idx].attachments = [
+ {
+ type: "generated-image",
+ name: `edited-image.${format}`,
+ preview: `data:${mimeType};base64,${imageData}`,
+ mimeType,
+ },
+ ];
+ }
+ }
+ } catch {
+ // Ignore parse errors for incomplete JSON
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.error("Error editing image:", error);
+ const idx = this.messages.findIndex((m) => m.id === assistantMessage.id);
+ if (idx !== -1) {
+ this.messages[idx].content =
+ `Error: ${error instanceof Error ? error.message : "Failed to edit image"}`;
+ }
+ } finally {
+ this.isLoading = false;
+ this.updateActiveConversation();
+ }
+ }
+
+ /**
+ * Clear current chat and go back to welcome state
+ */
+ clearChat() {
+ this.activeConversationId = null;
+ this.messages = [];
+ this.hasStartedChat = false;
+ this.isTopologyMinimized = false;
+ this.currentResponse = "";
+ // Clear performance stats
+ this.ttftMs = null;
+ this.tps = null;
+ }
+
+ /**
+ * Get the active conversation
+ */
+ getActiveConversation(): Conversation | null {
+ if (!this.activeConversationId) return null;
+ return (
+ this.conversations.find((c) => c.id === this.activeConversationId) || null
+ );
+ }
}
export const appStore = new AppStore();
@@ -2073,50 +2073,50 @@ export const chatSidebarVisible = () => appStore.getChatSidebarVisible();
// Actions
export const startChat = () => appStore.startChat();
export const sendMessage = (
- content: string,
- files?: {
- id: string;
- name: string;
- type: string;
- textContent?: string;
- preview?: string;
- }[],
+ content: string,
+ files?: {
+ id: string;
+ name: string;
+ type: string;
+ textContent?: string;
+ preview?: string;
+ }[],
) => appStore.sendMessage(content, files);
export const generateImage = (prompt: string, modelId?: string) =>
- appStore.generateImage(prompt, modelId);
+ appStore.generateImage(prompt, modelId);
export const editImage = (
- prompt: string,
- imageDataUrl: string,
- modelId?: string,
+ prompt: string,
+ imageDataUrl: string,
+ modelId?: string,
) => appStore.editImage(prompt, imageDataUrl, modelId);
export const editingImage = () => appStore.editingImage;
export const setEditingImage = (imageDataUrl: string, sourceMessage: Message) =>
- appStore.setEditingImage(imageDataUrl, sourceMessage);
+ appStore.setEditingImage(imageDataUrl, sourceMessage);
export const clearEditingImage = () => appStore.clearEditingImage();
export const clearChat = () => appStore.clearChat();
export const setSelectedChatModel = (modelId: string) =>
- appStore.setSelectedModel(modelId);
+ appStore.setSelectedModel(modelId);
export const selectPreviewModel = (modelId: string | null) =>
- appStore.selectPreviewModel(modelId);
+ appStore.selectPreviewModel(modelId);
export const deleteMessage = (messageId: string) =>
- appStore.deleteMessage(messageId);
+ appStore.deleteMessage(messageId);
export const editMessage = (messageId: string, newContent: string) =>
- appStore.editMessage(messageId, newContent);
+ appStore.editMessage(messageId, newContent);
export const editAndRegenerate = (messageId: string, newContent: string) =>
- appStore.editAndRegenerate(messageId, newContent);
+ appStore.editAndRegenerate(messageId, newContent);
export const regenerateLastResponse = () => appStore.regenerateLastResponse();
// Conversation actions
export const conversations = () => appStore.conversations;
export const activeConversationId = () => appStore.activeConversationId;
export const createConversation = (name?: string) =>
- appStore.createConversation(name);
+ appStore.createConversation(name);
export const loadConversation = (id: string) => appStore.loadConversation(id);
export const deleteConversation = (id: string) =>
- appStore.deleteConversation(id);
+ appStore.deleteConversation(id);
export const deleteAllConversations = () => appStore.deleteAllConversations();
export const renameConversation = (id: string, name: string) =>
- appStore.renameConversation(id, name);
+ appStore.renameConversation(id, name);
export const getActiveConversation = () => appStore.getActiveConversation();
// Sidebar actions
@@ -2124,20 +2124,20 @@ export const isSidebarOpen = () => appStore.isSidebarOpen;
export const toggleSidebar = () => appStore.toggleSidebar();
export const toggleDebugMode = () => appStore.toggleDebugMode();
export const setDebugMode = (enabled: boolean) =>
- appStore.setDebugMode(enabled);
+ appStore.setDebugMode(enabled);
export const toggleTopologyOnlyMode = () => appStore.toggleTopologyOnlyMode();
export const setTopologyOnlyMode = (enabled: boolean) =>
- appStore.setTopologyOnlyMode(enabled);
+ appStore.setTopologyOnlyMode(enabled);
export const toggleChatSidebarVisible = () =>
- appStore.toggleChatSidebarVisible();
+ appStore.toggleChatSidebarVisible();
export const setChatSidebarVisible = (visible: boolean) =>
- appStore.setChatSidebarVisible(visible);
+ appStore.setChatSidebarVisible(visible);
export const refreshState = () => appStore.fetchState();
// Image generation params
export const imageGenerationParams = () => appStore.getImageGenerationParams();
export const setImageGenerationParams = (
- params: Partial<ImageGenerationParams>,
+ params: Partial<ImageGenerationParams>,
) => appStore.setImageGenerationParams(params);
export const resetImageGenerationParams = () =>
- appStore.resetImageGenerationParams();
+ appStore.resetImageGenerationParams();
diff --git a/dashboard/src/lib/types/files.ts b/dashboard/src/lib/types/files.ts
index ecd9a41b..4b11f128 100644
--- a/dashboard/src/lib/types/files.ts
+++ b/dashboard/src/lib/types/files.ts
@@ -3,89 +3,89 @@
*/
export interface ChatUploadedFile {
- id: string;
- name: string;
- size: number;
- type: string;
- file: File;
- preview?: string;
- textContent?: string;
+ id: string;
+ name: string;
+ size: number;
+ type: string;
+ file: File;
+ preview?: string;
+ textContent?: string;
}
export interface ChatAttachment {
- type: "image" | "text" | "pdf" | "audio";
- name: string;
- content?: string;
- base64Url?: string;
- mimeType?: string;
+ type: "image" | "text" | "pdf" | "audio";
+ name: string;
+ content?: string;
+ base64Url?: string;
+ mimeType?: string;
}
export type FileCategory = "image" | "text" | "pdf" | "audio" | "unknown";
export const IMAGE_EXTENSIONS = [
- ".jpg",
- ".jpeg",
- ".png",
- ".gif",
- ".webp",
- ".svg",
+ ".jpg",
+ ".jpeg",
+ ".png",
+ ".gif",
+ ".webp",
+ ".svg",
];
export const IMAGE_MIME_TYPES = [
- "image/jpeg",
- "image/png",
- "image/gif",
- "image/webp",
- "image/svg+xml",
+ "image/jpeg",
+ "image/png",
+ "image/gif",
+ "image/webp",
+ "image/svg+xml",
];
export const TEXT_EXTENSIONS = [
- ".txt",
- ".md",
- ".json",
- ".xml",
- ".yaml",
- ".yml",
- ".csv",
- ".log",
- ".js",
- ".ts",
- ".jsx",
- ".tsx",
- ".py",
- ".java",
- ".cpp",
- ".c",
- ".h",
- ".css",
- ".html",
- ".htm",
- ".sql",
- ".sh",
- ".bat",
- ".rs",
- ".go",
- ".rb",
- ".php",
- ".swift",
- ".kt",
- ".scala",
- ".r",
- ".dart",
- ".vue",
- ".svelte",
+ ".txt",
+ ".md",
+ ".json",
+ ".xml",
+ ".yaml",
+ ".yml",
+ ".csv",
+ ".log",
+ ".js",
+ ".ts",
+ ".jsx",
+ ".tsx",
+ ".py",
+ ".java",
+ ".cpp",
+ ".c",
+ ".h",
+ ".css",
+ ".html",
+ ".htm",
+ ".sql",
+ ".sh",
+ ".bat",
+ ".rs",
+ ".go",
+ ".rb",
+ ".php",
+ ".swift",
+ ".kt",
+ ".scala",
+ ".r",
+ ".dart",
+ ".vue",
+ ".svelte",
];
export const TEXT_MIME_TYPES = [
- "text/plain",
- "text/markdown",
- "text/csv",
- "text/html",
- "text/css",
- "application/json",
- "application/xml",
- "text/xml",
- "application/javascript",
- "text/javascript",
- "application/typescript",
+ "text/plain",
+ "text/markdown",
+ "text/csv",
+ "text/html",
+ "text/css",
+ "application/json",
+ "application/xml",
+ "text/xml",
+ "application/javascript",
+ "text/javascript",
+ "application/typescript",
];
export const PDF_EXTENSIONS = [".pdf"];
@@ -93,148 +93,148 @@ export const PDF_MIME_TYPES = ["application/pdf"];
export const AUDIO_EXTENSIONS = [".mp3", ".wav", ".ogg", ".m4a"];
export const AUDIO_MIME_TYPES = [
- "audio/mpeg",
- "audio/wav",
- "audio/ogg",
- "audio/mp4",
+ "audio/mpeg",
+ "audio/wav",
+ "audio/ogg",
+ "audio/mp4",
];
/**
* Get file category based on MIME type and extension
*/
export function getFileCategory(
- mimeType: string,
- fileName: string,
+ mimeType: string,
+ fileName: string,
): FileCategory {
- const extension = fileName.toLowerCase().slice(fileName.lastIndexOf("."));
-
- if (
- IMAGE_MIME_TYPES.includes(mimeType) ||
- IMAGE_EXTENSIONS.includes(extension)
- ) {
- return "image";
- }
- if (PDF_MIME_TYPES.includes(mimeType) || PDF_EXTENSIONS.includes(extension)) {
- return "pdf";
- }
- if (
- AUDIO_MIME_TYPES.includes(mimeType) ||
- AUDIO_EXTENSIONS.includes(extension)
- ) {
- return "audio";
- }
- if (
- TEXT_MIME_TYPES.includes(mimeType) ||
- TEXT_EXTENSIONS.includes(extension) ||
- mimeType.startsWith("text/")
- ) {
- return "text";
- }
- return "unknown";
+ const extension = fileName.toLowerCase().slice(fileName.lastIndexOf("."));
+
+ if (
+ IMAGE_MIME_TYPES.includes(mimeType) ||
+ IMAGE_EXTENSIONS.includes(extension)
+ ) {
+ return "image";
+ }
+ if (PDF_MIME_TYPES.includes(mimeType) || PDF_EXTENSIONS.includes(extension)) {
+ return "pdf";
+ }
+ if (
+ AUDIO_MIME_TYPES.includes(mimeType) ||
+ AUDIO_EXTENSIONS.includes(extension)
+ ) {
+ return "audio";
+ }
+ if (
+ TEXT_MIME_TYPES.includes(mimeType) ||
+ TEXT_EXTENSIONS.includes(extension) ||
+ mimeType.startsWith("text/")
+ ) {
+ return "text";
+ }
+ return "unknown";
}
/**
* Get accept string for file input based on categories
*/
export function getAcceptString(categories: FileCategory[]): string {
- const accepts: string[] = [];
-
- for (const category of categories) {
- switch (category) {
- case "image":
- accepts.push(...IMAGE_EXTENSIONS, ...IMAGE_MIME_TYPES);
- break;
- case "text":
- accepts.push(...TEXT_EXTENSIONS, ...TEXT_MIME_TYPES);
- break;
- case "pdf":
- accepts.push(...PDF_EXTENSIONS, ...PDF_MIME_TYPES);
- break;
- case "audio":
- accepts.push(...AUDIO_EXTENSIONS, ...AUDIO_MIME_TYPES);
- break;
- }
- }
-
- return accepts.join(",");
+ const accepts: string[] = [];
+
+ for (const category of categories) {
+ switch (category) {
+ case "image":
+ accepts.push(...IMAGE_EXTENSIONS, ...IMAGE_MIME_TYPES);
+ break;
+ case "text":
+ accepts.push(...TEXT_EXTENSIONS, ...TEXT_MIME_TYPES);
+ break;
+ case "pdf":
+ accepts.push(...PDF_EXTENSIONS, ...PDF_MIME_TYPES);
+ break;
+ case "audio":
+ accepts.push(...AUDIO_EXTENSIONS, ...AUDIO_MIME_TYPES);
+ break;
+ }
+ }
+
+ return accepts.join(",");
}
/**
* Format file size for display
*/
export function formatFileSize(bytes: number): string {
- if (bytes === 0) return "0 B";
- const k = 1024;
- const sizes = ["B", "KB", "MB", "GB"];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
/**
* Read file as data URL (base64)
*/
export function readFileAsDataURL(file: File): Promise<string> {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = () => resolve(reader.result as string);
- reader.onerror = () => reject(reader.error);
- reader.readAsDataURL(file);
- });
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = () => reject(reader.error);
+ reader.readAsDataURL(file);
+ });
}
/**
* Read file as text
*/
export function readFileAsText(file: File): Promise<string> {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = () => resolve(reader.result as string);
- reader.onerror = () => reject(reader.error);
- reader.readAsText(file);
- });
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = () => reject(reader.error);
+ reader.readAsText(file);
+ });
}
/**
* Process uploaded files into ChatUploadedFile format
*/
export async function processUploadedFiles(
- files: File[],
+ files: File[],
): Promise<ChatUploadedFile[]> {
- const results: ChatUploadedFile[] = [];
-
- for (const file of files) {
- const id =
- Date.now().toString() + Math.random().toString(36).substring(2, 9);
- const category = getFileCategory(file.type, file.name);
-
- const base: ChatUploadedFile = {
- id,
- name: file.name,
- size: file.size,
- type: file.type,
- file,
- };
-
- try {
- if (category === "image") {
- const preview = await readFileAsDataURL(file);
- results.push({ ...base, preview });
- } else if (category === "text" || category === "unknown") {
- const textContent = await readFileAsText(file);
- results.push({ ...base, textContent });
- } else if (category === "pdf") {
- results.push(base);
- } else if (category === "audio") {
- const preview = await readFileAsDataURL(file);
- results.push({ ...base, preview });
- } else {
- results.push(base);
- }
- } catch (error) {
- console.error("Error processing file:", file.name, error);
- results.push(base);
- }
- }
-
- return results;
+ const results: ChatUploadedFile[] = [];
+
+ for (const file of files) {
+ const id =
+ Date.now().toString() + Math.random().toString(36).substring(2, 9);
+ const category = getFileCategory(file.type, file.name);
+
+ const base: ChatUploadedFile = {
+ id,
+ name: file.name,
+ size: file.size,
+ type: file.type,
+ file,
+ };
+
+ try {
+ if (category === "image") {
+ const preview = await readFileAsDataURL(file);
+ results.push({ ...base, preview });
+ } else if (category === "text" || category === "unknown") {
+ const textContent = await readFileAsText(file);
+ results.push({ ...base, textContent });
+ } else if (category === "pdf") {
+ results.push(base);
+ } else if (category === "audio") {
+ const preview = await readFileAsDataURL(file);
+ results.push({ ...base, preview });
+ } else {
+ results.push(base);
+ }
+ } catch (error) {
+ console.error("Error processing file:", file.name, error);
+ results.push(base);
+ }
+ }
+
+ return results;
}
diff --git a/dashboard/src/routes/+layout.svelte b/dashboard/src/routes/+layout.svelte
index 7e75b676..d249e9cf 100644
--- a/dashboard/src/routes/+layout.svelte
+++ b/dashboard/src/routes/+layout.svelte
@@ -1,15 +1,14 @@
<script lang="ts">
- import '../app.css';
-
- let { children } = $props();
+ import "../app.css";
+
+ let { children } = $props();
</script>
<svelte:head>
- <title>EXO</title>
- <meta name="description" content="EXO - Distributed AI Cluster Dashboard" />
+ <title>EXO</title>
+ <meta name="description" content="EXO - Distributed AI Cluster Dashboard" />
</svelte:head>
<div class="min-h-screen bg-background text-foreground">
- {@render children?.()}
+ {@render children?.()}
</div>
-
diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte
index 4be37a3d..f32948fc 100644
--- a/dashboard/src/routes/+page.svelte
+++ b/dashboard/src/routes/+page.svelte
@@ -1,2025 +1,3009 @@
<script lang="ts">
- import { TopologyGraph, ChatForm, ChatMessages, ChatSidebar, ModelCard } from '$lib/components';
- import {
- hasStartedChat,
- isTopologyMinimized,
- topologyData,
- lastUpdate,
- clearChat,
- instances,
- runners,
- downloads,
- placementPreviews,
- selectedPreviewModelId,
- isLoadingPreviews,
- selectPreviewModel,
- createConversation,
- setSelectedChatModel,
- selectedChatModel,
- debugMode,
- toggleDebugMode,
- topologyOnlyMode,
- toggleTopologyOnlyMode,
- chatSidebarVisible,
- toggleChatSidebarVisible,
- type DownloadProgress,
- type PlacementPreview
- } from '$lib/stores/app.svelte';
- import HeaderNav from '$lib/components/HeaderNav.svelte';
- import { fade, fly } from 'svelte/transition';
- import { cubicInOut } 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());
-
- let mounted = $state(false);
-
- // Instance launch state
- let models = $state<Array<{id: string, name?: string, storage_size_megabytes?: number, tasks?: string[], hugging_face_id?: string}>>([]);
-
- // 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;
- });
-
- // 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');
- }
- let selectedSharding = $state<'Pipeline' | 'Tensor'>('Pipeline');
- type InstanceMeta = 'MlxRing' | 'MlxIbv' | 'MlxJaccl';
-
- // Launch defaults persistence
- const LAUNCH_DEFAULTS_KEY = 'exo-launch-defaults';
- 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;
-
- // 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);
- }
- }
-
- 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());
-
- // Custom dropdown state
- let isModelDropdownOpen = $state(false);
- let modelDropdownSearch = $state('');
-
- // 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());
-
- // 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 === 'MlxIbv' || 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);
- });
-
- // Check if a model has enough memory to run
- function hasEnoughMemory(model: {id: string, name?: string, storage_size_megabytes?: number}): boolean {
- const modelSizeGB = getModelSizeGB(model);
- return modelSizeGB <= availableMemoryGB();
- }
-
- // 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(() => {
- mounted = true;
- fetchModels();
- });
-
- 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 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 instanceData: unknown;
-
- if (preview?.instance) {
- // Use the instance from the preview
- instanceData = preview.instance;
- } else {
- // Fallback: GET placement from API
- const placementResponse = await fetch(
- `/instance/placement?model_id=${encodeURIComponent(modelId)}&sharding=${selectedSharding}&instance_meta=${selectedInstanceType}&min_nodes=${selectedMinNodes}`
- );
-
- if (!placementResponse.ok) {
- const errorText = await placementResponse.text();
- console.error('Failed to get placement:', errorText);
- return;
- }
-
- instanceData = await placementResponse.json();
- }
-
- // POST the instance to create it
- 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();
- console.error('Failed to launch instance:', errorText);
- } else {
- // Always auto-select the newly launched model so the user chats to what they just launched
- setSelectedChatModel(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);
- } 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_bytes ?? prog.totalBytes);
- const downloadedBytes = getBytes(prog.downloaded_bytes ?? prog.downloadedBytes);
- 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_bytes ?? fd.totalBytes);
- const fDownloaded = getBytes(fd.downloaded_bytes ?? fd.downloadedBytes);
- 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)
- function getModelDownloadStatus(modelId: string): {
- isDownloading: boolean;
- progress: DownloadProgress | null;
- perNode: Array<{ nodeId: string; nodeName: string; progress: DownloadProgress }>;
- } {
- if (!downloadsData || Object.keys(downloadsData).length === 0) {
- return { isDownloading: false, progress: null, perNode: [] };
- }
-
- let totalBytes = 0;
- let downloadedBytes = 0;
- let totalSpeed = 0;
- let completedFiles = 0;
- let totalFiles = 0;
- let isDownloading = false;
- const allFiles: DownloadProgress['files'] = [];
- const perNode: Array<{ nodeId: string; nodeName: string; progress: DownloadProgress }> = [];
-
- // Check all nodes for downloads matching this model
- for (const [nodeId, nodeDownloads] of Object.entries(downloadsData)) {
- 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 (downloadKind !== 'DownloadOngoing') continue;
- if (!downloadPayload) continue;
-
- const downloadModelId = extractModelIdFromDownload(downloadPayload);
-
- // Match if the model ID contains or equals the requested model
- // (handles cases like "mlx-community/Meta-Llama..." matching)
- if (!downloadModelId || !downloadModelId.includes(modelId.split('/').pop() || modelId)) {
- // Try exact match or partial match
- if (downloadModelId !== modelId) continue;
- }
-
- isDownloading = true;
-
- const progress = parseDownloadProgress(downloadPayload);
- if (progress) {
- // Sum all values across nodes - each node downloads independently
- totalBytes += progress.totalBytes;
- downloadedBytes += progress.downloadedBytes;
- totalSpeed += progress.speed;
- completedFiles += progress.completedFiles;
- totalFiles += progress.totalFiles;
- allFiles.push(...progress.files);
-
- const nodeName = data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
- perNode.push({ nodeId, nodeName, progress });
- }
- }
- }
-
- if (!isDownloading) {
- return { isDownloading: false, progress: null, perNode: [] };
- }
-
- // ETA = total remaining bytes / total speed across all nodes
- 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
- };
- }
-
- // Debug: Log downloads data when it changes
- $effect(() => {
- if (downloadsData && Object.keys(downloadsData).length > 0) {
- console.log('[Download Debug] Current downloads:', downloadsData);
- }
- });
-
- // Helper to get download status for an instance
- function getInstanceDownloadStatus(instanceId: string, instanceWrapped: unknown): {
- isDownloading: boolean;
- progress: DownloadProgress | null;
- statusText: string;
- perNode: Array<{ nodeId: string; nodeName: string; progress: DownloadProgress }>;
- } {
- if (!downloadsData || Object.keys(downloadsData).length === 0) {
- return { isDownloading: false, progress: null, statusText: 'RUNNING', perNode: [] };
- }
-
- // Unwrap the instance
- const [instanceTag, instance] = getTagged(instanceWrapped);
- if (!instance || typeof instance !== 'object') {
- return { isDownloading: false, progress: null, statusText: 'PREPARING', perNode: [] };
- }
-
- const inst = instance as { shardAssignments?: { nodeToRunner?: Record<string, string>; runnerToShard?: Record<string, unknown>; modelId?: string } };
- const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
- const runnerToShard = inst.shardAssignments?.runnerToShard || {};
- const instanceModelId = inst.shardAssignments?.modelId;
-
- // Build reverse mapping: runnerId -> nodeId
- const runnerToNode: Record<string, string> = {};
- for (const [nodeId, runnerId] of Object.entries(nodeToRunner)) {
- runnerToNode[runnerId] = nodeId;
- }
-
- let totalBytes = 0;
- let downloadedBytes = 0;
- let totalSpeed = 0;
- let completedFiles = 0;
- let totalFiles = 0;
- let isDownloading = false;
- const allFiles: DownloadProgress['files'] = [];
- const perNode: Array<{ nodeId: string; nodeName: string; progress: DownloadProgress }> = [];
-
- // Check downloads for nodes that are part of this instance
- for (const runnerId of Object.keys(runnerToShard)) {
- const nodeId = runnerToNode[runnerId];
- if (!nodeId) continue;
-
- const nodeDownloads = downloadsData[nodeId];
- 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 (downloadKind !== 'DownloadOngoing') continue;
- if (!downloadPayload) continue;
-
- // Check if this download is for this instance's model
- const downloadModelId = extractModelIdFromDownload(downloadPayload);
- if (instanceModelId && downloadModelId && downloadModelId === instanceModelId) {
- isDownloading = true;
-
- const progress = parseDownloadProgress(downloadPayload);
- if (progress) {
- // Sum all values across nodes - each node downloads independently
- totalBytes += progress.totalBytes;
- downloadedBytes += progress.downloadedBytes;
- totalSpeed += progress.speed;
- completedFiles += progress.completedFiles;
- totalFiles += progress.totalFiles;
- allFiles.push(...progress.files);
-
- const nodeName = data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
- perNode.push({ nodeId, nodeName, progress });
- }
- }
- }
- }
-
- if (!isDownloading) {
- // Check runner status for other states
- const statusInfo = deriveInstanceStatus(instanceWrapped);
- return { isDownloading: false, progress: null, statusText: statusInfo.statusText, perNode: [] };
- }
-
- // ETA = total remaining bytes / total speed across all nodes
- 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
- },
- statusText: 'DOWNLOADING',
- 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 } {
- const [, 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')) return { statusText: 'LOADING', statusClass: 'starting' };
- 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.in_bytes === 'number') return v.in_bytes;
- 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);
- } 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 === 'MlxIbvInstance' || 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 };
- }
-
- 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() {
- createConversation();
- }
-
- function handleGoHome() {
- 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);
-
- // 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)
- // We filter by sharding/instance type and min nodes, returning ALL eligible previews
- 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 });
- });
-
+ import {
+ TopologyGraph,
+ ChatForm,
+ ChatMessages,
+ ChatSidebar,
+ ModelCard,
+ } from "$lib/components";
+ import {
+ hasStartedChat,
+ isTopologyMinimized,
+ topologyData,
+ lastUpdate,
+ clearChat,
+ instances,
+ runners,
+ downloads,
+ placementPreviews,
+ selectedPreviewModelId,
+ isLoadingPreviews,
+ selectPreviewModel,
+ createConversation,
+ setSelectedChatModel,
+ selectedChatModel,
+ debugMode,
+ toggleDebugMode,
+ topologyOnlyMode,
+ toggleTopologyOnlyMode,
+ chatSidebarVisible,
+ toggleChatSidebarVisible,
+ type DownloadProgress,
+ type PlacementPreview,
+ } from "$lib/stores/app.svelte";
+ import HeaderNav from "$lib/components/HeaderNav.svelte";
+ import { fade, fly } from "svelte/transition";
+ import { cubicInOut } 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());
+
+ let mounted = $state(false);
+
+ // Instance launch state
+ let models = $state<
+ Array<{
+ id: string;
+ name?: string;
+ storage_size_megabytes?: number;
+ tasks?: string[];
+ hugging_face_id?: string;
+ }>
+ >([]);
+
+ // 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;
+ });
+
+ // 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")
+ );
+ }
+ let selectedSharding = $state<"Pipeline" | "Tensor">("Pipeline");
+ type InstanceMeta = "MlxRing" | "MlxIbv" | "MlxJaccl";
+
+ // Launch defaults persistence
+ const LAUNCH_DEFAULTS_KEY = "exo-launch-defaults";
+ 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;
+
+ // 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);
+ }
+ }
+
+ 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());
+
+ // Custom dropdown state
+ let isModelDropdownOpen = $state(false);
+ let modelDropdownSearch = $state("");
+
+ // 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());
+
+ // 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 === "MlxIbv" || 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)
+ );
+ });
+
+ // Check if a model has enough memory to run
+ function hasEnoughMemory(model: {
+ id: string;
+ name?: string;
+ storage_size_megabytes?: number;
+ }): boolean {
+ const modelSizeGB = getModelSizeGB(model);
+ return modelSizeGB <= availableMemoryGB();
+ }
+
+ // 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(() => {
+ mounted = true;
+ fetchModels();
+ });
+
+ 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 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 instanceData: unknown;
+
+ if (preview?.instance) {
+ // Use the instance from the preview
+ instanceData = preview.instance;
+ } else {
+ // Fallback: GET placement from API
+ const placementResponse = await fetch(
+ `/instance/placement?model_id=${encodeURIComponent(modelId)}&sharding=${selectedSharding}&instance_meta=${selectedInstanceType}&min_nodes=${selectedMinNodes}`,
+ );
+
+ if (!placementResponse.ok) {
+ const errorText = await placementResponse.text();
+ console.error("Failed to get placement:", errorText);
+ return;
+ }
+
+ instanceData = await placementResponse.json();
+ }
+
+ // POST the instance to create it
+ 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();
+ console.error("Failed to launch instance:", errorText);
+ } else {
+ // Always auto-select the newly launched model so the user chats to what they just launched
+ setSelectedChatModel(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);
+ } 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_bytes ?? prog.totalBytes);
+ const downloadedBytes = getBytes(
+ prog.downloaded_bytes ?? prog.downloadedBytes,
+ );
+ 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_bytes ?? fd.totalBytes);
+ const fDownloaded = getBytes(fd.downloaded_bytes ?? fd.downloadedBytes);
+ 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)
+ function getModelDownloadStatus(modelId: string): {
+ isDownloading: boolean;
+ progress: DownloadProgress | null;
+ perNode: Array<{
+ nodeId: string;
+ nodeName: string;
+ progress: DownloadProgress;
+ }>;
+ } {
+ if (!downloadsData || Object.keys(downloadsData).length === 0) {
+ return { isDownloading: false, progress: null, perNode: [] };
+ }
+
+ let totalBytes = 0;
+ let downloadedBytes = 0;
+ let totalSpeed = 0;
+ let completedFiles = 0;
+ let totalFiles = 0;
+ let isDownloading = false;
+ const allFiles: DownloadProgress["files"] = [];
+ const perNode: Array<{
+ nodeId: string;
+ nodeName: string;
+ progress: DownloadProgress;
+ }> = [];
+
+ // Check all nodes for downloads matching this model
+ for (const [nodeId, nodeDownloads] of Object.entries(downloadsData)) {
+ 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 (downloadKind !== "DownloadOngoing") continue;
+ if (!downloadPayload) continue;
+
+ const downloadModelId = extractModelIdFromDownload(downloadPayload);
+
+ // Match if the model ID contains or equals the requested model
+ // (handles cases like "mlx-community/Meta-Llama..." matching)
+ if (
+ !downloadModelId ||
+ !downloadModelId.includes(modelId.split("/").pop() || modelId)
+ ) {
+ // Try exact match or partial match
+ if (downloadModelId !== modelId) continue;
+ }
+
+ isDownloading = true;
+
+ const progress = parseDownloadProgress(downloadPayload);
+ if (progress) {
+ // Sum all values across nodes - each node downloads independently
+ totalBytes += progress.totalBytes;
+ downloadedBytes += progress.downloadedBytes;
+ totalSpeed += progress.speed;
+ completedFiles += progress.completedFiles;
+ totalFiles += progress.totalFiles;
+ allFiles.push(...progress.files);
+
+ const nodeName =
+ data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
+ perNode.push({ nodeId, nodeName, progress });
+ }
+ }
+ }
+
+ if (!isDownloading) {
+ return { isDownloading: false, progress: null, perNode: [] };
+ }
+
+ // ETA = total remaining bytes / total speed across all nodes
+ 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,
+ };
+ }
+
+ // Debug: Log downloads data when it changes
+ $effect(() => {
+ if (downloadsData && Object.keys(downloadsData).length > 0) {
+ console.log("[Download Debug] Current downloads:", downloadsData);
+ }
+ });
+
+ // Helper to get download status for an instance
+ function getInstanceDownloadStatus(
+ instanceId: string,
+ instanceWrapped: unknown,
+ ): {
+ isDownloading: boolean;
+ progress: DownloadProgress | null;
+ statusText: string;
+ perNode: Array<{
+ nodeId: string;
+ nodeName: string;
+ progress: DownloadProgress;
+ }>;
+ } {
+ if (!downloadsData || Object.keys(downloadsData).length === 0) {
+ return {
+ isDownloading: false,
+ progress: null,
+ statusText: "RUNNING",
+ perNode: [],
+ };
+ }
+
+ // Unwrap the instance
+ const [instanceTag, instance] = getTagged(instanceWrapped);
+ if (!instance || typeof instance !== "object") {
+ return {
+ isDownloading: false,
+ progress: null,
+ statusText: "PREPARING",
+ perNode: [],
+ };
+ }
+
+ const inst = instance as {
+ shardAssignments?: {
+ nodeToRunner?: Record<string, string>;
+ runnerToShard?: Record<string, unknown>;
+ modelId?: string;
+ };
+ };
+ const nodeToRunner = inst.shardAssignments?.nodeToRunner || {};
+ const runnerToShard = inst.shardAssignments?.runnerToShard || {};
+ const instanceModelId = inst.shardAssignments?.modelId;
+
+ // Build reverse mapping: runnerId -> nodeId
+ const runnerToNode: Record<string, string> = {};
+ for (const [nodeId, runnerId] of Object.entries(nodeToRunner)) {
+ runnerToNode[runnerId] = nodeId;
+ }
+
+ let totalBytes = 0;
+ let downloadedBytes = 0;
+ let totalSpeed = 0;
+ let completedFiles = 0;
+ let totalFiles = 0;
+ let isDownloading = false;
+ const allFiles: DownloadProgress["files"] = [];
+ const perNode: Array<{
+ nodeId: string;
+ nodeName: string;
+ progress: DownloadProgress;
+ }> = [];
+
+ // Check downloads for nodes that are part of this instance
+ for (const runnerId of Object.keys(runnerToShard)) {
+ const nodeId = runnerToNode[runnerId];
+ if (!nodeId) continue;
+
+ const nodeDownloads = downloadsData[nodeId];
+ 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 (downloadKind !== "DownloadOngoing") continue;
+ if (!downloadPayload) continue;
+
+ // Check if this download is for this instance's model
+ const downloadModelId = extractModelIdFromDownload(downloadPayload);
+ if (
+ instanceModelId &&
+ downloadModelId &&
+ downloadModelId === instanceModelId
+ ) {
+ isDownloading = true;
+
+ const progress = parseDownloadProgress(downloadPayload);
+ if (progress) {
+ // Sum all values across nodes - each node downloads independently
+ totalBytes += progress.totalBytes;
+ downloadedBytes += progress.downloadedBytes;
+ totalSpeed += progress.speed;
+ completedFiles += progress.completedFiles;
+ totalFiles += progress.totalFiles;
+ allFiles.push(...progress.files);
+
+ const nodeName =
+ data?.nodes?.[nodeId]?.friendly_name ?? nodeId.slice(0, 8);
+ perNode.push({ nodeId, nodeName, progress });
+ }
+ }
+ }
+ }
+
+ if (!isDownloading) {
+ // Check runner status for other states
+ const statusInfo = deriveInstanceStatus(instanceWrapped);
+ return {
+ isDownloading: false,
+ progress: null,
+ statusText: statusInfo.statusText,
+ perNode: [],
+ };
+ }
+
+ // ETA = total remaining bytes / total speed across all nodes
+ 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,
+ },
+ statusText: "DOWNLOADING",
+ 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;
+ } {
+ const [, 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"))
+ return { statusText: "LOADING", statusClass: "starting" };
+ 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.in_bytes === "number") return v.in_bytes;
+ 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);
+ } 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 === "MlxIbvInstance" ||
+ 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,
+ };
+ }
+
+ 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() {
+ createConversation();
+ }
+
+ function handleGoHome() {
+ 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);
+
+ // 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)
+ // We filter by sharding/instance type and min nodes, returning ALL eligible previews
+ 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>
<!-- Global event listeners for slider dragging -->
-<svelte:window
- onmousemove={handleSliderMouseMove}
- onmouseup={handleSliderMouseUp}
- ontouchmove={handleSliderTouchMove}
- ontouchend={handleSliderTouchEnd}
+<svelte:window
+ onmousemove={handleSliderMouseMove}
+ onmouseup={handleSliderMouseUp}
+ ontouchmove={handleSliderTouchMove}
+ ontouchend={handleSliderTouchEnd}
/>
-<div class="relative h-screen w-full flex flex-col bg-exo-dark-gray overflow-hidden">
- <!-- Scanline overlay -->
- <div class="fixed inset-0 pointer-events-none z-50 scanlines opacity-20"></div>
-
- <!-- Shooting Stars Background - one every ~15s -->
- <div class="shooting-stars">
- <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 !topologyOnlyEnabled}
- <HeaderNav
- showHome={chatStarted}
- onHome={handleGoHome}
- showSidebarToggle={true}
- sidebarVisible={sidebarVisible}
- onToggleSidebar={toggleChatSidebarVisible}
- />
- {/if}
-
- <!-- Main Content -->
- <main class="flex-1 flex overflow-hidden relative">
- <!-- Left: Conversation History Sidebar (hidden in topology-only mode or when toggled off) -->
- {#if !topologyOnlyEnabled && sidebarVisible}
- <div class="w-80 flex-shrink-0 border-r border-exo-yellow/10">
- <ChatSidebar class="h-full" />
- </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()} />
- <!-- 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"
- >
- <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-visible 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()} />
- </div>
-
- <!-- Chat Input - Below topology -->
- <div class="px-4 pt-6 pb-8">
- <div class="max-w-3xl mx-auto">
- <ChatForm
- placeholder="Ask anything"
- showHelperText={false}
- showModelSelector={true}
- modelTasks={modelTasks()}
- />
- </div>
- </div>
- </div>
-
- <!-- Right Sidebar: Instance Controls (wider on welcome page for better visibility) -->
- <aside class="w-80 border-l border-exo-yellow/10 bg-exo-dark-gray flex flex-col flex-shrink-0">
- <!-- 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' || 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') {
- 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="text-white/60 text-xs font-mono">Strategy: <span class="text-white/80">{instanceInfo.sharding} ({instanceInfo.instanceType})</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 as nodeProg}
- {@const nodePercent = Math.min(100, Math.max(0, nodeProg.progress.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)} / {formatBytes(nodeProg.progress.totalBytes)}</span>
- <span>{formatSpeed(nodeProg.progress.speed)} • ETA {formatEta(nodeProg.progress.etaMs)}</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="text-xs text-blue-400 font-mono tracking-wider mt-1">DOWNLOADING</div>
- {:else}
- <div class="text-xs {getStatusColor(downloadInfo.statusText)} font-mono tracking-wider mt-1">{downloadInfo.statusText}</div>
- {/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">Launch Instance</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 Dropdown (Custom) -->
- <div class="flex-shrink-0 mb-3 relative">
- <button
- type="button"
- onclick={() => isModelDropdownOpen = !isModelDropdownOpen}
- 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 {isModelDropdownOpen ? 'border-exo-yellow/70' : ''}"
- >
- {#if selectedModelId}
- {@const foundModel = models.find(m => m.id === selectedModelId)}
- {#if foundModel}
- {@const sizeGB = getModelSizeGB(foundModel)}
- {@const isImageModel = modelSupportsImageGeneration(foundModel.id)}
- <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">
- {#if isImageModel}
- <svg class="w-4 h-4 flex-shrink-0 text-exo-yellow" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
- <circle cx="8.5" cy="8.5" r="1.5"/>
- <polyline points="21 15 16 10 5 21"/>
- </svg>
- {/if}
- <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}
- <span class="text-white/50">— SELECT MODEL —</span>
- {/if}
- </button>
- <div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isModelDropdownOpen ? 'rotate-180' : ''}">
- <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>
-
- {#if isModelDropdownOpen}
- <!-- Backdrop to close dropdown -->
- <button
- type="button"
- class="fixed inset-0 z-40 cursor-default"
- onclick={() => isModelDropdownOpen = false}
- aria-label="Close dropdown"
- ></button>
-
- <!-- Dropdown Panel -->
- <div class="absolute top-full left-0 right-0 mt-1 bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-50 max-h-64 overflow-y-auto">
- <!-- Search within dropdown -->
- <div class="sticky top-0 bg-exo-dark-gray border-b border-exo-medium-gray/30 p-2">
- <input
- type="text"
- placeholder="Search models..."
- bind:value={modelDropdownSearch}
- class="w-full bg-exo-dark-gray/60 border border-exo-medium-gray/30 rounded px-2 py-1.5 text-xs font-mono text-white/80 placeholder:text-white/40 focus:outline-none focus:border-exo-yellow/50"
- />
- </div>
-
- <!-- Options -->
- <div class="py-1">
- {#each sortedModels().filter(m =>
- !modelDropdownSearch ||
- (m.name || m.id).toLowerCase().includes(modelDropdownSearch.toLowerCase())
- ) as model}
- {@const sizeGB = getModelSizeGB(model)}
- {@const modelCanFit = hasEnoughMemory(model)}
- {@const isImageModel = modelSupportsImageGeneration(model.id)}
- <button
- type="button"
- onclick={() => {
- if (modelCanFit) {
- selectPreviewModel(model.id);
- saveLaunchDefaults();
- isModelDropdownOpen = false;
- modelDropdownSearch = '';
- }
- }}
- disabled={!modelCanFit}
- class="w-full px-3 py-2 text-left text-sm font-mono tracking-wide transition-colors duration-100 flex items-center justify-between gap-2 {
- selectedModelId === model.id
- ? 'bg-transparent text-exo-yellow cursor-pointer'
- : modelCanFit
- ? 'text-white/80 hover:text-exo-yellow cursor-pointer'
- : 'text-white/30 cursor-default'
- }"
- >
- <span class="flex items-center gap-2 truncate flex-1">
- {#if isImageModel}
- <svg class="w-4 h-4 flex-shrink-0 text-exo-yellow" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-label="Image generation model">
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
- <circle cx="8.5" cy="8.5" r="1.5"/>
- <polyline points="21 15 16 10 5 21"/>
- </svg>
- {/if}
- <span class="truncate">{model.name || model.id}</span>
- </span>
- <span class="flex-shrink-0 text-xs {modelCanFit ? 'text-white/50' : 'text-red-400/60'}">
- {sizeGB >= 1 ? sizeGB.toFixed(0) : sizeGB.toFixed(1)}GB
- </span>
- </button>
- {:else}
- <div class="px-3 py-2 text-xs text-white/50 font-mono">No models found</div>
- {/each}
- </div>
- </div>
- {/if}
- </div>
-
- <!-- Configuration Options -->
- <div class="flex-shrink-0 mb-4 space-y-3">
- <!-- Sharding -->
- <div>
- <div class="text-xs text-white/70 font-mono mb-2">Sharding:</div>
- <div class="flex gap-2">
- <button
- onclick={() => { selectedSharding = 'Pipeline'; saveLaunchDefaults(); }}
- class="flex items-center gap-2 py-2 px-4 text-sm 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-4 h-4 rounded-full border-2 flex items-center justify-center {selectedSharding === 'Pipeline' ? 'border-exo-yellow' : 'border-exo-medium-gray'}">
- {#if selectedSharding === 'Pipeline'}
- <span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
- {/if}
- </span>
- Pipeline
- </button>
- <button
- onclick={() => { selectedSharding = 'Tensor'; saveLaunchDefaults(); }}
- class="flex items-center gap-2 py-2 px-4 text-sm 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-4 h-4 rounded-full border-2 flex items-center justify-center {selectedSharding === 'Tensor' ? 'border-exo-yellow' : 'border-exo-medium-gray'}">
- {#if selectedSharding === 'Tensor'}
- <span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
- {/if}
- </span>
- Tensor
- </button>
- </div>
- </div>
-
- <!-- Instance Type -->
- <div>
- <div class="text-xs text-white/70 font-mono mb-2">Instance Type:</div>
- <div class="flex gap-2">
- <button
- onclick={() => { selectedInstanceType = 'MlxRing'; saveLaunchDefaults(); }}
- class="flex items-center gap-2 py-2 px-4 text-sm 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-4 h-4 rounded-full border-2 flex items-center justify-center {selectedInstanceType === 'MlxRing' ? 'border-exo-yellow' : 'border-exo-medium-gray'}">
- {#if selectedInstanceType === 'MlxRing'}
- <span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
- {/if}
- </span>
- MLX Ring
- </button>
- <button
- onclick={() => { selectedInstanceType = 'MlxIbv'; saveLaunchDefaults(); }}
- class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType === 'MlxIbv' ? '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-4 h-4 rounded-full border-2 flex items-center justify-center {selectedInstanceType === 'MlxIbv' ? 'border-exo-yellow' : 'border-exo-medium-gray'}">
- {#if selectedInstanceType === 'MlxIbv'}
- <span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
- {/if}
- </span>
- MLX RDMA
- </button>
- </div>
- </div>
-
- <!-- Minimum Nodes (discrete slider with drag support) -->
- <div>
- <div class="text-xs text-white/70 font-mono mb-2">Minimum Nodes:</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 - extends full width to align with edge dots -->
- <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 node 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%);"
- >
- <!-- Dot -->
- <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>
- <!-- Number label below dot -->
- <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>
-
- <!-- 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 downloadStatus = getModelDownloadStatus(selectedModel.id)}
- {@const tags = modelTags()[selectedModel.id] || []}
- <div class="space-y-3">
- {#each allPreviews as apiPreview, i}
- <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>
- </aside>
-
- </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 }}
- >
- <div class="flex-1 overflow-y-auto px-8 py-6" bind:this={chatScrollRef}>
- <div class="max-w-7xl mx-auto">
- <ChatMessages scrollParent={chatScrollRef} />
- </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()} />
- </div>
- </div>
- </div>
-
- <!-- Right: Mini-Map Sidebar -->
- {#if minimized}
- <aside
- class="w-80 border-l border-exo-yellow/20 bg-exo-dark-gray flex flex-col flex-shrink-0 overflow-y-auto"
- in:fly={{ x: 100, duration: 400, easing: cubicInOut }}
- >
- <!-- 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">
-
- <TopologyGraph highlightedNodes={highlightedNodes()} />
- </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' || 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') {
- 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="text-white/60 text-xs font-mono">Strategy: <span class="text-white/80">{instanceInfo.sharding} ({instanceInfo.instanceType})</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 as nodeProg}
- {@const nodePercent = Math.min(100, Math.max(0, nodeProg.progress.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)} / {formatBytes(nodeProg.progress.totalBytes)}</span>
- <span>{formatSpeed(nodeProg.progress.speed)} • ETA {formatEta(nodeProg.progress.etaMs)}</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="text-xs text-blue-400 font-mono tracking-wider mt-1">DOWNLOADING</div>
- {:else}
- <div class="text-xs {getStatusColor(downloadInfo.statusText)} font-mono tracking-wider mt-1">{downloadInfo.statusText}</div>
- {/if}
- </div>
- </div>
- </div>
- {/each}
- </div>
- </div>
- {/if}
- </aside>
- {/if}
- </div>
- {/if}
- </main>
-
+<div
+ class="relative h-screen w-full flex flex-col bg-exo-dark-gray overflow-hidden"
+>
+ <!-- Scanline overlay -->
+ <div
+ class="fixed inset-0 pointer-events-none z-50 scanlines opacity-20"
+ ></div>
+
+ <!-- Shooting Stars Background - one every ~15s -->
+ <div class="shooting-stars">
+ <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 !topologyOnlyEnabled}
+ <HeaderNav
+ showHome={chatStarted}
+ onHome={handleGoHome}
+ showSidebarToggle={true}
+ {sidebarVisible}
+ onToggleSidebar={toggleChatSidebarVisible}
+ />
+ {/if}
+
+ <!-- Main Content -->
+ <main class="flex-1 flex overflow-hidden relative">
+ <!-- Left: Conversation History Sidebar (hidden in topology-only mode or when toggled off) -->
+ {#if !topologyOnlyEnabled && sidebarVisible}
+ <div class="w-80 flex-shrink-0 border-r border-exo-yellow/10">
+ <ChatSidebar class="h-full" />
+ </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()}
+ />
+ <!-- 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"
+ >
+ <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-visible 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()}
+ />
+ </div>
+
+ <!-- Chat Input - Below topology -->
+ <div class="px-4 pt-6 pb-8">
+ <div class="max-w-3xl mx-auto">
+ <ChatForm
+ placeholder="Ask anything"
+ showHelperText={false}
+ showModelSelector={true}
+ modelTasks={modelTasks()}
+ />
+ </div>
+ </div>
+ </div>
+
+ <!-- Right Sidebar: Instance Controls (wider on welcome page for better visibility) -->
+ <aside
+ class="w-80 border-l border-exo-yellow/10 bg-exo-dark-gray flex flex-col flex-shrink-0"
+ >
+ <!-- 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" ||
+ 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"
+ ) {
+ 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="text-white/60 text-xs font-mono">
+ Strategy: <span class="text-white/80"
+ >{instanceInfo.sharding} ({instanceInfo.instanceType})</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 as nodeProg}
+ {@const nodePercent = Math.min(
+ 100,
+ Math.max(0, nodeProg.progress.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,
+ )} / {formatBytes(
+ nodeProg.progress.totalBytes,
+ )}</span
+ >
+ <span
+ >{formatSpeed(nodeProg.progress.speed)} •
+ ETA {formatEta(
+ nodeProg.progress.etaMs,
+ )}</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="text-xs text-blue-400 font-mono tracking-wider mt-1"
+ >
+ DOWNLOADING
+ </div>
+ {:else}
+ <div
+ class="text-xs {getStatusColor(
+ downloadInfo.statusText,
+ )} font-mono tracking-wider mt-1"
+ >
+ {downloadInfo.statusText}
+ </div>
+ {/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"
+ >
+ Launch Instance
+ </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 Dropdown (Custom) -->
+ <div class="flex-shrink-0 mb-3 relative">
+ <button
+ type="button"
+ onclick={() => (isModelDropdownOpen = !isModelDropdownOpen)}
+ 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 {isModelDropdownOpen
+ ? 'border-exo-yellow/70'
+ : ''}"
+ >
+ {#if selectedModelId}
+ {@const foundModel = models.find(
+ (m) => m.id === selectedModelId,
+ )}
+ {#if foundModel}
+ {@const sizeGB = getModelSizeGB(foundModel)}
+ {@const isImageModel = modelSupportsImageGeneration(
+ foundModel.id,
+ )}
+ <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"
+ >
+ {#if isImageModel}
+ <svg
+ class="w-4 h-4 flex-shrink-0 text-exo-yellow"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+ >
+ <rect
+ x="3"
+ y="3"
+ width="18"
+ height="18"
+ rx="2"
+ ry="2"
+ />
+ <circle cx="8.5" cy="8.5" r="1.5" />
+ <polyline points="21 15 16 10 5 21" />
+ </svg>
+ {/if}
+ <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}
+ <span class="text-white/50">— SELECT MODEL —</span>
+ {/if}
+ </button>
+ <div
+ class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isModelDropdownOpen
+ ? 'rotate-180'
+ : ''}"
+ >
+ <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>
+
+ {#if isModelDropdownOpen}
+ <!-- Backdrop to close dropdown -->
+ <button
+ type="button"
+ class="fixed inset-0 z-40 cursor-default"
+ onclick={() => (isModelDropdownOpen = false)}
+ aria-label="Close dropdown"
+ ></button>
+
+ <!-- Dropdown Panel -->
+ <div
+ class="absolute top-full left-0 right-0 mt-1 bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-50 max-h-64 overflow-y-auto"
+ >
+ <!-- Search within dropdown -->
+ <div
+ class="sticky top-0 bg-exo-dark-gray border-b border-exo-medium-gray/30 p-2"
+ >
+ <input
+ type="text"
+ placeholder="Search models..."
+ bind:value={modelDropdownSearch}
+ class="w-full bg-exo-dark-gray/60 border border-exo-medium-gray/30 rounded px-2 py-1.5 text-xs font-mono text-white/80 placeholder:text-white/40 focus:outline-none focus:border-exo-yellow/50"
+ />
+ </div>
+
+ <!-- Options -->
+ <div class="py-1">
+ {#each sortedModels().filter((m) => !modelDropdownSearch || (m.name || m.id)
+ .toLowerCase()
+ .includes(modelDropdownSearch.toLowerCase())) as model}
+ {@const sizeGB = getModelSizeGB(model)}
+ {@const modelCanFit = hasEnoughMemory(model)}
+ {@const isImageModel = modelSupportsImageGeneration(
+ model.id,
+ )}
+ <button
+ type="button"
+ onclick={() => {
+ if (modelCanFit) {
+ selectPreviewModel(model.id);
+ saveLaunchDefaults();
+ isModelDropdownOpen = false;
+ modelDropdownSearch = "";
+ }
+ }}
+ disabled={!modelCanFit}
+ class="w-full px-3 py-2 text-left text-sm font-mono tracking-wide transition-colors duration-100 flex items-center justify-between gap-2 {selectedModelId ===
+ model.id
+ ? 'bg-transparent text-exo-yellow cursor-pointer'
+ : modelCanFit
+ ? 'text-white/80 hover:text-exo-yellow cursor-pointer'
+ : 'text-white/30 cursor-default'}"
+ >
+ <span class="flex items-center gap-2 truncate flex-1">
+ {#if isImageModel}
+ <svg
+ class="w-4 h-4 flex-shrink-0 text-exo-yellow"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+ aria-label="Image generation model"
+ >
+ <rect
+ x="3"
+ y="3"
+ width="18"
+ height="18"
+ rx="2"
+ ry="2"
+ />
+ <circle cx="8.5" cy="8.5" r="1.5" />
+ <polyline points="21 15 16 10 5 21" />
+ </svg>
+ {/if}
+ <span class="truncate">{model.name || model.id}</span>
+ </span>
+ <span
+ class="flex-shrink-0 text-xs {modelCanFit
+ ? 'text-white/50'
+ : 'text-red-400/60'}"
+ >
+ {sizeGB >= 1
+ ? sizeGB.toFixed(0)
+ : sizeGB.toFixed(1)}GB
+ </span>
+ </button>
+ {:else}
+ <div class="px-3 py-2 text-xs text-white/50 font-mono">
+ No models found
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+
+ <!-- Configuration Options -->
+ <div class="flex-shrink-0 mb-4 space-y-3">
+ <!-- Sharding -->
+ <div>
+ <div class="text-xs text-white/70 font-mono mb-2">
+ Sharding:
+ </div>
+ <div class="flex gap-2">
+ <button
+ onclick={() => {
+ selectedSharding = "Pipeline";
+ saveLaunchDefaults();
+ }}
+ class="flex items-center gap-2 py-2 px-4 text-sm 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-4 h-4 rounded-full border-2 flex items-center justify-center {selectedSharding ===
+ 'Pipeline'
+ ? 'border-exo-yellow'
+ : 'border-exo-medium-gray'}"
+ >
+ {#if selectedSharding === "Pipeline"}
+ <span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
+ {/if}
+ </span>
+ Pipeline
+ </button>
+ <button
+ onclick={() => {
+ selectedSharding = "Tensor";
+ saveLaunchDefaults();
+ }}
+ class="flex items-center gap-2 py-2 px-4 text-sm 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-4 h-4 rounded-full border-2 flex items-center justify-center {selectedSharding ===
+ 'Tensor'
+ ? 'border-exo-yellow'
+ : 'border-exo-medium-gray'}"
+ >
+ {#if selectedSharding === "Tensor"}
+ <span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
+ {/if}
+ </span>
+ Tensor
+ </button>
+ </div>
+ </div>
+
+ <!-- Instance Type -->
+ <div>
+ <div class="text-xs text-white/70 font-mono mb-2">
+ Instance Type:
+ </div>
+ <div class="flex gap-2">
+ <button
+ onclick={() => {
+ selectedInstanceType = "MlxRing";
+ saveLaunchDefaults();
+ }}
+ class="flex items-center gap-2 py-2 px-4 text-sm 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-4 h-4 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
+ 'MlxRing'
+ ? 'border-exo-yellow'
+ : 'border-exo-medium-gray'}"
+ >
+ {#if selectedInstanceType === "MlxRing"}
+ <span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
+ {/if}
+ </span>
+ MLX Ring
+ </button>
+ <button
+ onclick={() => {
+ selectedInstanceType = "MlxIbv";
+ saveLaunchDefaults();
+ }}
+ class="flex items-center gap-2 py-2 px-4 text-sm font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType ===
+ 'MlxIbv'
+ ? '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-4 h-4 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
+ 'MlxIbv'
+ ? 'border-exo-yellow'
+ : 'border-exo-medium-gray'}"
+ >
+ {#if selectedInstanceType === "MlxIbv"}
+ <span class="w-2 h-2 rounded-full bg-exo-yellow"></span>
+ {/if}
+ </span>
+ MLX RDMA
+ </button>
+ </div>
+ </div>
+
+ <!-- Minimum Nodes (discrete slider with drag support) -->
+ <div>
+ <div class="text-xs text-white/70 font-mono mb-2">
+ Minimum Nodes:
+ </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 - extends full width to align with edge dots -->
+ <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 node 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%);"
+ >
+ <!-- Dot -->
+ <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>
+ <!-- Number label below dot -->
+ <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>
+
+ <!-- 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 downloadStatus = getModelDownloadStatus(
+ selectedModel.id,
+ )}
+ {@const tags = modelTags()[selectedModel.id] || []}
+ <div class="space-y-3">
+ {#each allPreviews as apiPreview, i}
+ <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>
+ </aside>
+ </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 }}
+ >
+ <div
+ class="flex-1 overflow-y-auto px-8 py-6"
+ bind:this={chatScrollRef}
+ >
+ <div class="max-w-7xl mx-auto">
+ <ChatMessages scrollParent={chatScrollRef} />
+ </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()}
+ />
+ </div>
+ </div>
+ </div>
+
+ <!-- Right: Mini-Map Sidebar -->
+ {#if minimized}
+ <aside
+ class="w-80 border-l border-exo-yellow/20 bg-exo-dark-gray flex flex-col flex-shrink-0 overflow-y-auto"
+ in:fly={{ x: 100, duration: 400, easing: cubicInOut }}
+ >
+ <!-- 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"
+ >
+ <TopologyGraph highlightedNodes={highlightedNodes()} />
+ </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" ||
+ 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"
+ ) {
+ 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="text-white/60 text-xs font-mono">
+ Strategy: <span class="text-white/80"
+ >{instanceInfo.sharding} ({instanceInfo.instanceType})</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 as nodeProg}
+ {@const nodePercent = Math.min(
+ 100,
+ Math.max(0, nodeProg.progress.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,
+ )} / {formatBytes(
+ nodeProg.progress.totalBytes,
+ )}</span
+ >
+ <span
+ >{formatSpeed(
+ nodeProg.progress.speed,
+ )} • ETA {formatEta(
+ nodeProg.progress.etaMs,
+ )}</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="text-xs text-blue-400 font-mono tracking-wider mt-1"
+ >
+ DOWNLOADING
+ </div>
+ {:else}
+ <div
+ class="text-xs {getStatusColor(
+ downloadInfo.statusText,
+ )} font-mono tracking-wider mt-1"
+ >
+ {downloadInfo.statusText}
+ </div>
+ {/if}
+ </div>
+ </div>
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </aside>
+ {/if}
+ </div>
+ {/if}
+ </main>
</div>
diff --git a/dashboard/src/routes/downloads/+page.svelte b/dashboard/src/routes/downloads/+page.svelte
index bfdef5ca..a7ee2003 100644
--- a/dashboard/src/routes/downloads/+page.svelte
+++ b/dashboard/src/routes/downloads/+page.svelte
@@ -1,458 +1,599 @@
<script lang="ts">
- import { onMount } from 'svelte';
- import {
- topologyData,
- downloads,
- type DownloadProgress,
- refreshState,
- lastUpdate as lastUpdateStore
- } from '$lib/stores/app.svelte';
- import HeaderNav from '$lib/components/HeaderNav.svelte';
-
- type FileProgress = {
- name: string;
- totalBytes: number;
- downloadedBytes: number;
- speed: number;
- etaMs: number;
- percentage: number;
- };
-
- type ModelEntry = {
- modelId: string;
- prettyName?: string | null;
- percentage: number;
- downloadedBytes: number;
- totalBytes: number;
- speed: number;
- etaMs: number;
- status: 'completed' | 'downloading';
- files: FileProgress[];
- };
-
- type NodeEntry = {
- nodeId: string;
- nodeName: string;
- models: ModelEntry[];
- };
-
- const data = $derived(topologyData());
- const downloadsData = $derived(downloads());
-
- function getNodeLabel(nodeId: string): string {
- const node = data?.nodes?.[nodeId];
- if (!node) return nodeId.slice(0, 8);
- return node.friendly_name || node.system_info?.model_id || nodeId.slice(0, 8);
- }
-
- 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.in_bytes === 'number') return v.in_bytes;
- if (typeof v.inBytes === 'number') return v.inBytes;
- }
- return 0;
- }
-
- function formatBytes(bytes: number): string {
- if (!bytes || bytes <= 0) return '0B';
- const units = ['B', 'KB', 'MB', 'GB', 'TB'];
- const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
- const val = bytes / Math.pow(1024, i);
- return `${val.toFixed(val >= 10 ? 0 : 1)}${units[i]}`;
- }
-
- 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 formatSpeed(bytesPerSecond: number): string {
- if (!bytesPerSecond || bytesPerSecond <= 0) return '--';
- const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
- const i = Math.min(Math.floor(Math.log(bytesPerSecond) / Math.log(1024)), units.length - 1);
- const val = bytesPerSecond / Math.pow(1024, i);
- return `${val.toFixed(val >= 10 ? 0 : 1)}${units[i]}`;
- }
-
- function clampPercent(value: number | undefined): number {
- if (!Number.isFinite(value)) return 0;
- return Math.min(100, Math.max(0, value as number));
- }
-
- function extractModelIdFromDownload(downloadPayload: Record<string, unknown>): string | null {
- const shardMetadata = downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
- if (!shardMetadata || typeof shardMetadata !== 'object') return null;
-
- 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;
-
- 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;
- }
-
- 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_bytes ?? prog.totalBytes);
- const downloadedBytes = getBytes(prog.downloaded_bytes ?? prog.downloadedBytes);
- 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_bytes ?? fd.totalBytes);
- const fDownloaded = getBytes(fd.downloaded_bytes ?? fd.downloadedBytes);
- 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
- };
- }
-
- function getBarGradient(percentage: number): string {
- if (percentage >= 100) return 'from-green-500 to-green-400';
- if (percentage <= 0) return 'from-red-500 to-red-400';
- return 'from-exo-yellow to-exo-yellow/70';
- }
-
- let downloadOverview = $state<NodeEntry[]>([]);
-
- $effect(() => {
- try {
- if (!downloadsData || Object.keys(downloadsData).length === 0) {
- downloadOverview = [];
- return;
- }
-
- const entries = Object.entries(downloadsData);
- const built: NodeEntry[] = [];
-
- for (const [nodeId, nodeDownloads] of entries) {
- const modelMap = new Map<string, ModelEntry>();
- const nodeEntries = Array.isArray(nodeDownloads)
- ? nodeDownloads
- : nodeDownloads && typeof nodeDownloads === 'object'
- ? Object.values(nodeDownloads as Record<string, unknown>)
- : [];
-
- for (const downloadWrapped of nodeEntries) {
- 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 modelId = extractModelIdFromDownload(downloadPayload) ?? 'unknown-model';
- const prettyName = (() => {
- const shardMetadata = downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
- if (!shardMetadata || typeof shardMetadata !== 'object') return null;
- 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>;
- const modelMeta = shardData?.model_card ?? shardData?.modelCard;
- if (!modelMeta || typeof modelMeta !== 'object') return null;
- const meta = modelMeta as Record<string, unknown>;
- return (meta.prettyName as string) ?? null;
- })();
-
- const rawProgress = (downloadPayload as Record<string, unknown>).download_progress
- ?? (downloadPayload as Record<string, unknown>).downloadProgress
- ?? {};
- // For DownloadCompleted, total_bytes is at top level; for DownloadOngoing, it's inside download_progress
- const totalBytes = getBytes(
- (downloadPayload as Record<string, unknown>).total_bytes
- ?? (downloadPayload as Record<string, unknown>).totalBytes
- ?? (rawProgress as Record<string, unknown>).total_bytes
- ?? (rawProgress as Record<string, unknown>).totalBytes
- );
- const downloadedBytes = getBytes((rawProgress as Record<string, unknown>).downloaded_bytes ?? (rawProgress as Record<string, unknown>).downloadedBytes);
- const speed = (rawProgress as Record<string, unknown>).speed as number ?? 0;
- const etaMs = (rawProgress as Record<string, unknown>).eta_ms as number ?? (rawProgress as Record<string, unknown>).etaMs as number ?? 0;
- const percentage = totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0;
-
- const files: FileProgress[] = [];
- const filesObj = (rawProgress as Record<string, unknown>).files as Record<string, unknown> | undefined;
- if (filesObj && typeof filesObj === 'object') {
- 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_bytes ?? fd.totalBytes);
- const fDownloaded = getBytes(fd.downloaded_bytes ?? fd.downloadedBytes);
- 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: clampPercent(fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0)
- });
- }
- }
-
- const entry: ModelEntry = {
- modelId,
- prettyName,
- percentage: downloadKind === 'DownloadCompleted' ? 100 : clampPercent(percentage),
- downloadedBytes,
- totalBytes,
- speed,
- etaMs,
- status: downloadKind === 'DownloadCompleted' ? 'completed' : 'downloading',
- files
- };
-
- const existing = modelMap.get(modelId);
- if (!existing) {
- modelMap.set(modelId, entry);
- } else if (
- (entry.status === 'completed' && existing.status !== 'completed') ||
- (entry.status === existing.status && entry.downloadedBytes > existing.downloadedBytes)
- ) {
- modelMap.set(modelId, entry);
- }
- }
-
- let models = Array.from(modelMap.values()).sort((a, b) => b.percentage - a.percentage);
- if (models.length === 0 && nodeEntries.length > 0) {
- models = [{
- modelId: 'Unknown download',
- percentage: 0,
- downloadedBytes: 0,
- totalBytes: 0,
- speed: 0,
- etaMs: 0,
- status: 'downloading',
- files: []
- }];
- }
-
- built.push({
- nodeId,
- nodeName: getNodeLabel(nodeId),
- models
- });
- }
-
- downloadOverview = built;
- } catch (err) {
- console.error('Parse downloads error', err);
- downloadOverview = [];
- }
- });
-
- const hasDownloads = $derived(downloadOverview.length > 0);
- const lastUpdateTs = $derived(lastUpdateStore());
- const downloadKeys = $derived(Object.keys(downloadsData || {}));
-
- let expanded = $state<Set<string>>(new Set());
- function toggleExpand(key: string): void {
- const next = new Set(expanded);
- if (next.has(key)) next.delete(key);
- else next.add(key);
- expanded = next;
- }
-
- onMount(() => {
- // Ensure we fetch at least once when visiting downloads directly
- refreshState();
- });
+ import { onMount } from "svelte";
+ import {
+ topologyData,
+ downloads,
+ type DownloadProgress,
+ refreshState,
+ lastUpdate as lastUpdateStore,
+ } from "$lib/stores/app.svelte";
+ import HeaderNav from "$lib/components/HeaderNav.svelte";
+
+ type FileProgress = {
+ name: string;
+ totalBytes: number;
+ downloadedBytes: number;
+ speed: number;
+ etaMs: number;
+ percentage: number;
+ };
+
+ type ModelEntry = {
+ modelId: string;
+ prettyName?: string | null;
+ percentage: number;
+ downloadedBytes: number;
+ totalBytes: number;
+ speed: number;
+ etaMs: number;
+ status: "completed" | "downloading";
+ files: FileProgress[];
+ };
+
+ type NodeEntry = {
+ nodeId: string;
+ nodeName: string;
+ models: ModelEntry[];
+ };
+
+ const data = $derived(topologyData());
+ const downloadsData = $derived(downloads());
+
+ function getNodeLabel(nodeId: string): string {
+ const node = data?.nodes?.[nodeId];
+ if (!node) return nodeId.slice(0, 8);
+ return (
+ node.friendly_name || node.system_info?.model_id || nodeId.slice(0, 8)
+ );
+ }
+
+ 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.in_bytes === "number") return v.in_bytes;
+ if (typeof v.inBytes === "number") return v.inBytes;
+ }
+ return 0;
+ }
+
+ function formatBytes(bytes: number): string {
+ if (!bytes || bytes <= 0) return "0B";
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ const i = Math.min(
+ Math.floor(Math.log(bytes) / Math.log(1024)),
+ units.length - 1,
+ );
+ const val = bytes / Math.pow(1024, i);
+ return `${val.toFixed(val >= 10 ? 0 : 1)}${units[i]}`;
+ }
+
+ 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 formatSpeed(bytesPerSecond: number): string {
+ if (!bytesPerSecond || bytesPerSecond <= 0) return "--";
+ const units = ["B/s", "KB/s", "MB/s", "GB/s"];
+ const i = Math.min(
+ Math.floor(Math.log(bytesPerSecond) / Math.log(1024)),
+ units.length - 1,
+ );
+ const val = bytesPerSecond / Math.pow(1024, i);
+ return `${val.toFixed(val >= 10 ? 0 : 1)}${units[i]}`;
+ }
+
+ function clampPercent(value: number | undefined): number {
+ if (!Number.isFinite(value)) return 0;
+ return Math.min(100, Math.max(0, value as number));
+ }
+
+ function extractModelIdFromDownload(
+ downloadPayload: Record<string, unknown>,
+ ): string | null {
+ const shardMetadata =
+ downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
+ if (!shardMetadata || typeof shardMetadata !== "object") return null;
+
+ 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;
+
+ 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;
+ }
+
+ 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_bytes ?? prog.totalBytes);
+ const downloadedBytes = getBytes(
+ prog.downloaded_bytes ?? prog.downloadedBytes,
+ );
+ 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_bytes ?? fd.totalBytes);
+ const fDownloaded = getBytes(fd.downloaded_bytes ?? fd.downloadedBytes);
+ 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,
+ };
+ }
+
+ function getBarGradient(percentage: number): string {
+ if (percentage >= 100) return "from-green-500 to-green-400";
+ if (percentage <= 0) return "from-red-500 to-red-400";
+ return "from-exo-yellow to-exo-yellow/70";
+ }
+
+ let downloadOverview = $state<NodeEntry[]>([]);
+
+ $effect(() => {
+ try {
+ if (!downloadsData || Object.keys(downloadsData).length === 0) {
+ downloadOverview = [];
+ return;
+ }
+
+ const entries = Object.entries(downloadsData);
+ const built: NodeEntry[] = [];
+
+ for (const [nodeId, nodeDownloads] of entries) {
+ const modelMap = new Map<string, ModelEntry>();
+ const nodeEntries = Array.isArray(nodeDownloads)
+ ? nodeDownloads
+ : nodeDownloads && typeof nodeDownloads === "object"
+ ? Object.values(nodeDownloads as Record<string, unknown>)
+ : [];
+
+ for (const downloadWrapped of nodeEntries) {
+ 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 modelId =
+ extractModelIdFromDownload(downloadPayload) ?? "unknown-model";
+ const prettyName = (() => {
+ const shardMetadata =
+ downloadPayload.shard_metadata ?? downloadPayload.shardMetadata;
+ if (!shardMetadata || typeof shardMetadata !== "object")
+ return null;
+ 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>;
+ const modelMeta = shardData?.model_card ?? shardData?.modelCard;
+ if (!modelMeta || typeof modelMeta !== "object") return null;
+ const meta = modelMeta as Record<string, unknown>;
+ return (meta.prettyName as string) ?? null;
+ })();
+
+ const rawProgress =
+ (downloadPayload as Record<string, unknown>).download_progress ??
+ (downloadPayload as Record<string, unknown>).downloadProgress ??
+ {};
+ // For DownloadCompleted, total_bytes is at top level; for DownloadOngoing, it's inside download_progress
+ const totalBytes = getBytes(
+ (downloadPayload as Record<string, unknown>).total_bytes ??
+ (downloadPayload as Record<string, unknown>).totalBytes ??
+ (rawProgress as Record<string, unknown>).total_bytes ??
+ (rawProgress as Record<string, unknown>).totalBytes,
+ );
+ const downloadedBytes = getBytes(
+ (rawProgress as Record<string, unknown>).downloaded_bytes ??
+ (rawProgress as Record<string, unknown>).downloadedBytes,
+ );
+ const speed =
+ ((rawProgress as Record<string, unknown>).speed as number) ?? 0;
+ const etaMs =
+ ((rawProgress as Record<string, unknown>).eta_ms as number) ??
+ ((rawProgress as Record<string, unknown>).etaMs as number) ??
+ 0;
+ const percentage =
+ totalBytes > 0 ? (downloadedBytes / totalBytes) * 100 : 0;
+
+ const files: FileProgress[] = [];
+ const filesObj = (rawProgress as Record<string, unknown>).files as
+ | Record<string, unknown>
+ | undefined;
+ if (filesObj && typeof filesObj === "object") {
+ 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_bytes ?? fd.totalBytes);
+ const fDownloaded = getBytes(
+ fd.downloaded_bytes ?? fd.downloadedBytes,
+ );
+ 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: clampPercent(
+ fTotal > 0 ? (fDownloaded / fTotal) * 100 : 0,
+ ),
+ });
+ }
+ }
+
+ const entry: ModelEntry = {
+ modelId,
+ prettyName,
+ percentage:
+ downloadKind === "DownloadCompleted"
+ ? 100
+ : clampPercent(percentage),
+ downloadedBytes,
+ totalBytes,
+ speed,
+ etaMs,
+ status:
+ downloadKind === "DownloadCompleted"
+ ? "completed"
+ : "downloading",
+ files,
+ };
+
+ const existing = modelMap.get(modelId);
+ if (!existing) {
+ modelMap.set(modelId, entry);
+ } else if (
+ (entry.status === "completed" && existing.status !== "completed") ||
+ (entry.status === existing.status &&
+ entry.downloadedBytes > existing.downloadedBytes)
+ ) {
+ modelMap.set(modelId, entry);
+ }
+ }
+
+ let models = Array.from(modelMap.values()).sort(
+ (a, b) => b.percentage - a.percentage,
+ );
+ if (models.length === 0 && nodeEntries.length > 0) {
+ models = [
+ {
+ modelId: "Unknown download",
+ percentage: 0,
+ downloadedBytes: 0,
+ totalBytes: 0,
+ speed: 0,
+ etaMs: 0,
+ status: "downloading",
+ files: [],
+ },
+ ];
+ }
+
+ built.push({
+ nodeId,
+ nodeName: getNodeLabel(nodeId),
+ models,
+ });
+ }
+
+ downloadOverview = built;
+ } catch (err) {
+ console.error("Parse downloads error", err);
+ downloadOverview = [];
+ }
+ });
+
+ const hasDownloads = $derived(downloadOverview.length > 0);
+ const lastUpdateTs = $derived(lastUpdateStore());
+ const downloadKeys = $derived(Object.keys(downloadsData || {}));
+
+ let expanded = $state<Set<string>>(new Set());
+ function toggleExpand(key: string): void {
+ const next = new Set(expanded);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ expanded = next;
+ }
+
+ onMount(() => {
+ // Ensure we fetch at least once when visiting downloads directly
+ refreshState();
+ });
</script>
<div class="min-h-screen bg-exo-dark-gray text-white">
- <HeaderNav showHome={true} />
- <div class="max-w-7xl mx-auto px-4 lg:px-8 py-6 space-y-6">
- <div class="flex items-center justify-between gap-4 flex-wrap">
- <div>
- <h1 class="text-2xl font-mono tracking-[0.2em] uppercase text-exo-yellow">Downloads</h1>
- <p class="text-sm text-exo-light-gray">Overview of models on each node</p>
- </div>
- <div class="flex items-center gap-3">
- <button
- type="button"
- class="text-xs font-mono text-exo-light-gray hover:text-exo-yellow transition-colors uppercase border border-exo-medium-gray/40 px-2 py-1 rounded"
- onclick={() => refreshState()}
- title="Force refresh from /state"
- >
- Refresh
- </button>
- <div class="text-[11px] font-mono text-exo-light-gray">
- Last update: {lastUpdateTs ? new Date(lastUpdateTs).toLocaleTimeString() : 'n/a'}
- </div>
- </div>
- </div>
-
- {#if !hasDownloads}
- <div class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-6 text-center text-exo-light-gray space-y-2">
- <div class="text-sm">No downloads found. Start a model download to see progress here.</div>
- <div class="text-[11px] text-exo-light-gray/70">
- Download keys detected: {downloadKeys.length === 0 ? 'none' : downloadKeys.join(', ')}
- </div>
- </div>
- {:else}
- <div class="downloads-grid gap-4">
- {#each downloadOverview as node}
- <div class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-4 space-y-3 flex flex-col">
- <div class="flex items-center justify-between gap-3">
- <div class="min-w-0 flex-1">
- <div class="text-lg font-mono text-white truncate">{node.nodeName}</div>
- <div class="text-xs text-exo-light-gray font-mono truncate">{node.nodeId}</div>
- </div>
- <div class="text-xs font-mono uppercase tracking-wider whitespace-nowrap shrink-0 text-right">
- <div>
- <span class="text-green-400">{node.models.filter(m => m.status === 'completed').length}</span><span class="text-exo-yellow"> / {node.models.length} models</span>
- </div>
- <div class="text-exo-light-gray normal-case tracking-normal">
- {formatBytes(node.models.filter(m => m.status === 'completed').reduce((sum, m) => sum + m.totalBytes, 0))} on disk
- </div>
- </div>
- </div>
-
- {#each node.models as model}
- {@const key = `${node.nodeId}|${model.modelId}`}
- {@const pct = clampPercent(model.percentage)}
- {@const gradient = getBarGradient(pct)}
- {@const isExpanded = expanded.has(key)}
- <div class="rounded border border-exo-medium-gray/30 bg-exo-dark-gray/60 p-3 space-y-2">
- <div class="flex items-center justify-between gap-3">
- <div class="min-w-0 space-y-0.5">
- <div
- class="text-xs font-mono text-white truncate"
- title={model.prettyName ?? model.modelId}
- >{model.prettyName ?? model.modelId}</div>
- <div
- class="text-[10px] text-exo-light-gray font-mono truncate"
- title={model.modelId}
- >{model.modelId}</div>
- {#if model.status !== 'completed'}
- <div class="text-[11px] text-exo-light-gray font-mono">
- {formatBytes(model.downloadedBytes)} / {formatBytes(model.totalBytes)}
- </div>
- {/if}
- </div>
- <div class="flex items-center gap-2">
- <span class="text-xs font-mono {pct >= 100 ? 'text-green-400' : pct <= 0 ? 'text-red-400' : 'text-exo-yellow'}">
- {pct.toFixed(1)}%
- </span>
- <button
- type="button"
- class="text-exo-light-gray hover:text-exo-yellow transition-colors"
- onclick={() => toggleExpand(key)}
- aria-expanded={isExpanded}
- title="Toggle file details"
- >
- <svg class="w-4 h-4" 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>
- </button>
- </div>
- </div>
-
- <div class="relative h-2 bg-exo-black/60 rounded-sm overflow-hidden">
- <div
- class={`absolute inset-y-0 left-0 bg-gradient-to-r ${gradient} transition-all duration-300`}
- style={`width: ${pct.toFixed(1)}%`}
- ></div>
- </div>
-
- <div class="flex items-center justify-between text-xs font-mono text-exo-light-gray">
- <span>{model.status === 'completed' ? `Completed (${formatBytes(model.totalBytes)})` : `${formatSpeed(model.speed)} • ETA ${formatEta(model.etaMs)}`}</span>
- {#if model.status !== 'completed'}
- <span>{model.files.length} file{model.files.length === 1 ? '' : 's'}</span>
- {/if}
- </div>
-
- {#if isExpanded}
- <div class="mt-2 space-y-1.5">
- {#if model.files.length === 0}
- <div class="text-[11px] font-mono text-exo-light-gray/70">No file details reported.</div>
- {:else}
- {#each model.files as f}
- {@const fpct = clampPercent(f.percentage)}
- {@const fgradient = getBarGradient(fpct)}
- <div class="rounded border border-exo-medium-gray/20 bg-exo-black/40 p-2 space-y-1">
- <div class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray/90">
- <span class="truncate pr-2">{f.name}</span>
- <span class="{fpct >= 100 ? 'text-green-400' : fpct <= 0 ? 'text-red-400' : 'text-exo-yellow'}">{fpct.toFixed(1)}%</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 ${fgradient} transition-all duration-300`}
- style={`width: ${fpct.toFixed(1)}%`}
- ></div>
- </div>
- <div class="flex items-center justify-between text-[10px] text-exo-light-gray/70">
- <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>
- {/each}
- </div>
- {/if}
-
- </div>
+ <HeaderNav showHome={true} />
+ <div class="max-w-7xl mx-auto px-4 lg:px-8 py-6 space-y-6">
+ <div class="flex items-center justify-between gap-4 flex-wrap">
+ <div>
+ <h1
+ class="text-2xl font-mono tracking-[0.2em] uppercase text-exo-yellow"
+ >
+ Downloads
+ </h1>
+ <p class="text-sm text-exo-light-gray">
+ Overview of models on each node
+ </p>
+ </div>
+ <div class="flex items-center gap-3">
+ <button
+ type="button"
+ class="text-xs font-mono text-exo-light-gray hover:text-exo-yellow transition-colors uppercase border border-exo-medium-gray/40 px-2 py-1 rounded"
+ onclick={() => refreshState()}
+ title="Force refresh from /state"
+ >
+ Refresh
+ </button>
+ <div class="text-[11px] font-mono text-exo-light-gray">
+ Last update: {lastUpdateTs
+ ? new Date(lastUpdateTs).toLocaleTimeString()
+ : "n/a"}
+ </div>
+ </div>
+ </div>
+
+ {#if !hasDownloads}
+ <div
+ class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-6 text-center text-exo-light-gray space-y-2"
+ >
+ <div class="text-sm">
+ No downloads found. Start a model download to see progress here.
+ </div>
+ <div class="text-[11px] text-exo-light-gray/70">
+ Download keys detected: {downloadKeys.length === 0
+ ? "none"
+ : downloadKeys.join(", ")}
+ </div>
+ </div>
+ {:else}
+ <div class="downloads-grid gap-4">
+ {#each downloadOverview as node}
+ <div
+ class="rounded border border-exo-medium-gray/30 bg-exo-black/30 p-4 space-y-3 flex flex-col"
+ >
+ <div class="flex items-center justify-between gap-3">
+ <div class="min-w-0 flex-1">
+ <div class="text-lg font-mono text-white truncate">
+ {node.nodeName}
+ </div>
+ <div class="text-xs text-exo-light-gray font-mono truncate">
+ {node.nodeId}
+ </div>
+ </div>
+ <div
+ class="text-xs font-mono uppercase tracking-wider whitespace-nowrap shrink-0 text-right"
+ >
+ <div>
+ <span class="text-green-400"
+ >{node.models.filter((m) => m.status === "completed")
+ .length}</span
+ ><span class="text-exo-yellow">
+ / {node.models.length} models</span
+ >
+ </div>
+ <div class="text-exo-light-gray normal-case tracking-normal">
+ {formatBytes(
+ node.models
+ .filter((m) => m.status === "completed")
+ .reduce((sum, m) => sum + m.totalBytes, 0),
+ )} on disk
+ </div>
+ </div>
+ </div>
+
+ {#each node.models as model}
+ {@const key = `${node.nodeId}|${model.modelId}`}
+ {@const pct = clampPercent(model.percentage)}
+ {@const gradient = getBarGradient(pct)}
+ {@const isExpanded = expanded.has(key)}
+ <div
+ class="rounded border border-exo-medium-gray/30 bg-exo-dark-gray/60 p-3 space-y-2"
+ >
+ <div class="flex items-center justify-between gap-3">
+ <div class="min-w-0 space-y-0.5">
+ <div
+ class="text-xs font-mono text-white truncate"
+ title={model.prettyName ?? model.modelId}
+ >
+ {model.prettyName ?? model.modelId}
+ </div>
+ <div
+ class="text-[10px] text-exo-light-gray font-mono truncate"
+ title={model.modelId}
+ >
+ {model.modelId}
+ </div>
+ {#if model.status !== "completed"}
+ <div class="text-[11px] text-exo-light-gray font-mono">
+ {formatBytes(model.downloadedBytes)} / {formatBytes(
+ model.totalBytes,
+ )}
+ </div>
+ {/if}
+ </div>
+ <div class="flex items-center gap-2">
+ <span
+ class="text-xs font-mono {pct >= 100
+ ? 'text-green-400'
+ : pct <= 0
+ ? 'text-red-400'
+ : 'text-exo-yellow'}"
+ >
+ {pct.toFixed(1)}%
+ </span>
+ <button
+ type="button"
+ class="text-exo-light-gray hover:text-exo-yellow transition-colors"
+ onclick={() => toggleExpand(key)}
+ aria-expanded={isExpanded}
+ title="Toggle file details"
+ >
+ <svg
+ class="w-4 h-4"
+ 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>
+ </button>
+ </div>
+ </div>
+
+ <div
+ class="relative h-2 bg-exo-black/60 rounded-sm overflow-hidden"
+ >
+ <div
+ class={`absolute inset-y-0 left-0 bg-gradient-to-r ${gradient} transition-all duration-300`}
+ style={`width: ${pct.toFixed(1)}%`}
+ ></div>
+ </div>
+
+ <div
+ class="flex items-center justify-between text-xs font-mono text-exo-light-gray"
+ >
+ <span
+ >{model.status === "completed"
+ ? `Completed (${formatBytes(model.totalBytes)})`
+ : `${formatSpeed(model.speed)} • ETA ${formatEta(model.etaMs)}`}</span
+ >
+ {#if model.status !== "completed"}
+ <span
+ >{model.files.length} file{model.files.length === 1
+ ? ""
+ : "s"}</span
+ >
+ {/if}
+ </div>
+
+ {#if isExpanded}
+ <div class="mt-2 space-y-1.5">
+ {#if model.files.length === 0}
+ <div class="text-[11px] font-mono text-exo-light-gray/70">
+ No file details reported.
+ </div>
+ {:else}
+ {#each model.files as f}
+ {@const fpct = clampPercent(f.percentage)}
+ {@const fgradient = getBarGradient(fpct)}
+ <div
+ class="rounded border border-exo-medium-gray/20 bg-exo-black/40 p-2 space-y-1"
+ >
+ <div
+ class="flex items-center justify-between text-[11px] font-mono text-exo-light-gray/90"
+ >
+ <span class="truncate pr-2">{f.name}</span>
+ <span
+ class={fpct >= 100
+ ? "text-green-400"
+ : fpct <= 0
+ ? "text-red-400"
+ : "text-exo-yellow"}>{fpct.toFixed(1)}%</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 ${fgradient} transition-all duration-300`}
+ style={`width: ${fpct.toFixed(1)}%`}
+ ></div>
+ </div>
+ <div
+ class="flex items-center justify-between text-[10px] text-exo-light-gray/70"
+ >
+ <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>
+ {/each}
+ </div>
+ {/if}
+ </div>
</div>
<style>
- .downloads-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
- }
- @media (min-width: 1024px) {
- .downloads-grid {
- grid-template-columns: repeat(3, minmax(0, 1fr));
- }
- }
- @media (min-width: 1600px) {
- .downloads-grid {
- grid-template-columns: repeat(4, minmax(0, 1fr));
- }
- }
+ .downloads-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ }
+ @media (min-width: 1024px) {
+ .downloads-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+ }
+ @media (min-width: 1600px) {
+ .downloads-grid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+ }
</style>
diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts
index 466c7c88..d7b0b539 100644
--- a/dashboard/vite.config.ts
+++ b/dashboard/vite.config.ts
@@ -3,13 +3,13 @@ import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
export default defineConfig({
- plugins: [tailwindcss(), sveltekit()],
- server: {
- proxy: {
- "/v1": "http://localhost:52415",
- "/state": "http://localhost:52415",
- "/models": "http://localhost:52415",
- "/instance": "http://localhost:52415",
- },
- },
+ plugins: [tailwindcss(), sveltekit()],
+ server: {
+ proxy: {
+ "/v1": "http://localhost:52415",
+ "/state": "http://localhost:52415",
+ "/models": "http://localhost:52415",
+ "/instance": "http://localhost:52415",
+ },
+ },
});
diff --git a/flake.nix b/flake.nix
index 1c049537..c4c018c6 100644
--- a/flake.nix
+++ b/flake.nix
@@ -72,7 +72,8 @@
};
prettier = {
enable = true;
- includes = [ "*.ts" ];
+ package = self'.packages.prettier-svelte;
+ includes = [ "*.ts" "*.svelte" ];
};
swift-format = {
enable = true;
← a354aaa3 Fix tests broken in recent commits (#1239)
·
back to Exo
·
Reduce log spam (#1241) ede77921 →