diff --git a/.github/workflows/build-app.yml b/.github/workflows/build-app.yml index d20bfc12a..861f46979 100644 --- a/.github/workflows/build-app.yml +++ b/.github/workflows/build-app.yml @@ -303,11 +303,8 @@ jobs: SIGNING_IDENTITY=$(security find-identity -v -p codesigning "$BUILD_KEYCHAIN_PATH" | awk -F '"' '{print $2}') /usr/bin/codesign --deep --force --timestamp --options runtime \ --sign "$SIGNING_IDENTITY" EXO.app - mkdir -p dmg-root - cp -R EXO.app dmg-root/ - ln -s /Applications dmg-root/Applications DMG_NAME="EXO-${RELEASE_VERSION}.dmg" - hdiutil create -volname "EXO" -srcfolder dmg-root -ov -format UDZO "$DMG_NAME" + bash "$GITHUB_WORKSPACE/packaging/dmg/create-dmg.sh" EXO.app "$DMG_NAME" "EXO" /usr/bin/codesign --force --timestamp --options runtime \ --sign "$SIGNING_IDENTITY" "$DMG_NAME" if [[ -n "$APPLE_NOTARIZATION_USERNAME" ]]; then diff --git a/dashboard/src/lib/components/ChatSidebar.svelte b/dashboard/src/lib/components/ChatSidebar.svelte index b721b0339..450be3f2f 100644 --- a/dashboard/src/lib/components/ChatSidebar.svelte +++ b/dashboard/src/lib/components/ChatSidebar.svelte @@ -307,7 +307,7 @@
{searchQuery ? "SEARCH RESULTS" : "CONVERSATIONS"} @@ -376,39 +376,37 @@ 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 + class="group w-full flex items-center justify-between p-2.5 rounded-lg 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'}" + ? 'bg-exo-yellow/5 border border-exo-yellow/30' + : 'hover:bg-white/[0.03] hover:border-white/10 border border-transparent'}" >
{conversation.name}
-
+
{formatDate(conversation.updatedAt)}
-
+
{info.modelLabel}
-
- Strategy: {info.strategyLabel} -
{#if stats} -
- {#if stats.ttftMs}TTFT - {stats.ttftMs.toFixed( - 0, - )}ms{/if}{#if stats.ttftMs && stats.tps}{/if}{#if stats.tps}{stats.tps.toFixed(1)} - tok/s{/if} +
+ {#if stats.ttftMs}TTFT + {stats.ttftMs.toFixed(0)}ms{/if}{#if stats.ttftMs && stats.tps}·{/if}{#if stats.tps}{stats.tps.toFixed(1)} + tok/s{/if}
{/if}
diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte index 6e7123803..81f9ed577 100644 --- a/dashboard/src/routes/+page.svelte +++ b/dashboard/src/routes/+page.svelte @@ -366,6 +366,9 @@ // Model picker modal state let isModelPickerOpen = $state(false); + // Advanced options toggle (hides technical jargon for new users) + let showAdvancedOptions = $state(false); + // Favorites state (reactive) const favoritesSet = $derived(getFavoritesSet()); @@ -2557,6 +2560,47 @@ onNodeClick={togglePreviewNodeFilter} /> + + {#if instanceCount === 0} +
+
+
+
+ Welcome to exo +
+

+ Your devices are connected. Choose a model to start + running AI locally. +

+
+ +
+
+ {/if} + {@render clusterWarnings()} @@ -2670,8 +2714,18 @@
+ {#if instanceCount === 0} + +
+

+ No model loaded yet. Select a model to get started. +

+
+ {/if} {getInstanceModelId(instance)}
-
- Strategy: {instanceInfo.sharding} ({instanceInfo.instanceType}) -
+ {#if debugEnabled} +
+ {instanceInfo.sharding} · {instanceInfo.instanceType} +
+ {/if} {#if instanceModelId && instanceModelId !== "Unknown" && instanceModelId !== "Unknown Model"} {/if} -
- DOWNLOADING +
+
+ DOWNLOADING +
+

+ Downloading model files. This runs locally on your + device and needs to finish before you can chat. +

{:else} -
- {downloadInfo.statusText} +
+
+ {downloadInfo.statusText} +
+ {#if isLoading} +

+ Loading model into memory for fast inference... +

+ {:else if isReady || isRunning} +

+ Ready to chat! Type a message below. +

+ {/if}
{#if downloadInfo.isFailed && downloadInfo.errorMessage}
- -
- -
-
- Sharding: -
-
- - -
-
- - -
-
- Instance Type: -
-
- - -
-
- - -
-
- Minimum Nodes: -
- - -
+
+ + + {#if showAdvancedOptions} +
+ +
+
+ Splitting Strategy:
- {/each} +
+ + +
+
+ + +
+
+ Runtime: +
+
+ + +
+
+ + +
+
+ Minimum Devices: +
+ + +
+ +
+ + {#if availableMinNodes > 1} +
+ {/if} + + {#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} +
+ + {n} +
+ {/each} +
+
-
+ {/if}
@@ -3655,11 +3761,11 @@ > {getInstanceModelId(instance)}
-
- Strategy: {instanceInfo.sharding} ({instanceInfo.instanceType}) -
+ {#if debugEnabled} +
+ {instanceInfo.sharding} · {instanceInfo.instanceType} +
+ {/if} {#if instanceModelId && instanceModelId !== "Unknown" && instanceModelId !== "Unknown Model"}
{/if} -
- DOWNLOADING +
+
+ DOWNLOADING +
+

+ Downloading model files. This runs locally on + your device and needs to finish before you can + chat. +

{:else} -
- {downloadInfo.statusText} +
+
+ {downloadInfo.statusText} +
+ {#if isLoading} +

+ Loading model into memory for fast + inference... +

+ {:else if isReady || isRunning} +

+ Ready to chat! Type a message below. +

+ {/if}
{#if downloadInfo.isFailed && downloadInfo.errorMessage}
[volume-name] +# +# Example: +# ./packaging/dmg/create-dmg.sh output/EXO.app EXO-1.0.0.dmg "EXO" +# +# Creates a DMG with: +# - Custom background image with drag-to-Applications arrow +# - App icon on left, Applications alias on right +# - Proper window size and icon positioning +set -euo pipefail + +APP_PATH="${1:?Usage: create-dmg.sh [volume-name]}" +OUTPUT_DMG="${2:?Usage: create-dmg.sh [volume-name]}" +VOLUME_NAME="${3:-EXO}" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BACKGROUND_SCRIPT="${SCRIPT_DIR}/generate-background.py" +TEMP_DIR="$(mktemp -d)" +DMG_STAGING="${TEMP_DIR}/dmg-root" +TEMP_DMG="${TEMP_DIR}/temp.dmg" +BACKGROUND_PNG="${TEMP_DIR}/background.png" + +cleanup() { rm -rf "$TEMP_DIR"; } +trap cleanup EXIT + +echo "==> Creating DMG installer for ${VOLUME_NAME}" + +# ── Step 1: Generate background image ──────────────────────────────────────── +if command -v python3 &>/dev/null; then + python3 "$BACKGROUND_SCRIPT" "$BACKGROUND_PNG" + echo " Background image generated" +else + echo " Warning: python3 not found, skipping custom background" + BACKGROUND_PNG="" +fi + +# ── Step 2: Prepare staging directory ───────────────────────────────────────── +mkdir -p "$DMG_STAGING" +cp -R "$APP_PATH" "$DMG_STAGING/" +ln -s /Applications "$DMG_STAGING/Applications" + +# ── Step 3: Create writable DMG ────────────────────────────────────────────── +# Calculate required size (app size + 20MB headroom) +APP_SIZE_KB=$(du -sk "$APP_PATH" | cut -f1) +DMG_SIZE_KB=$((APP_SIZE_KB + 20480)) + +hdiutil create \ + -volname "$VOLUME_NAME" \ + -size "${DMG_SIZE_KB}k" \ + -fs HFS+ \ + -layout SPUD \ + "$TEMP_DMG" + +# ── Step 4: Mount and configure ────────────────────────────────────────────── +MOUNT_DIR=$(hdiutil attach "$TEMP_DMG" -readwrite -noverify | awk '/Apple_HFS/ {print substr($0, index($0, "/"))}') +echo " Mounted at: $MOUNT_DIR" + +# Copy contents +cp -R "$DMG_STAGING/"* "$MOUNT_DIR/" + +# Add background image +if [[ -n $BACKGROUND_PNG && -f $BACKGROUND_PNG ]]; then + mkdir -p "$MOUNT_DIR/.background" + cp "$BACKGROUND_PNG" "$MOUNT_DIR/.background/background.png" +fi + +# ── Step 5: Configure window appearance via AppleScript ────────────────────── +# Window: 660×400, icons at 128px, app on left, Applications on right +APP_NAME="$(basename "$APP_PATH")" + +osascript < DMG created: $OUTPUT_DMG" +echo " Size: $(du -h "$OUTPUT_DMG" | cut -f1)" diff --git a/packaging/dmg/generate-background.py b/packaging/dmg/generate-background.py new file mode 100644 index 000000000..f501f97b3 --- /dev/null +++ b/packaging/dmg/generate-background.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Generate a DMG background image for the EXO installer. + +Creates a 660x400 PNG with: +- Dark gradient background matching the EXO brand +- Right-pointing arrow between app and Applications +- "Drag to install" instruction text + +Usage: + python3 generate-background.py output.png +""" + +from __future__ import annotations + +import struct +import sys +import zlib +from pathlib import Path + + +def _png_chunk(chunk_type: bytes, data: bytes) -> bytes: + """Build a single PNG chunk (type + data + CRC).""" + raw = chunk_type + data + return ( + struct.pack(">I", len(data)) + + raw + + struct.pack(">I", zlib.crc32(raw) & 0xFFFFFFFF) + ) + + +def _create_png( + width: int, height: int, pixels: list[list[tuple[int, int, int, int]]] +) -> bytes: + """Create a minimal RGBA PNG from pixel data.""" + signature = b"\x89PNG\r\n\x1a\n" + + # IHDR + ihdr_data = struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0) # 8-bit RGBA + ihdr = _png_chunk(b"IHDR", ihdr_data) + + # IDAT — build raw scanlines then deflate + raw_lines = bytearray() + for row in pixels: + raw_lines.append(0) # filter: None + for r, g, b, a in row: + raw_lines.extend((r, g, b, a)) + idat = _png_chunk(b"IDAT", zlib.compress(bytes(raw_lines), 9)) + + # IEND + iend = _png_chunk(b"IEND", b"") + + return signature + ihdr + idat + iend + + +def _lerp(a: int, b: int, t: float) -> int: + return max(0, min(255, int(a + (b - a) * t))) + + +def _draw_arrow( + pixels: list[list[tuple[int, int, int, int]]], + cx: int, + cy: int, + color: tuple[int, int, int, int], +) -> None: + """Draw a simple right-pointing arrow at (cx, cy).""" + # Shaft: horizontal line + shaft_len = 60 + shaft_thickness = 3 + for dx in range(-shaft_len, shaft_len + 1): + for dy in range(-shaft_thickness, shaft_thickness + 1): + y = cy + dy + x = cx + dx + if 0 <= y < len(pixels) and 0 <= x < len(pixels[0]): + pixels[y][x] = color + + # Arrowhead: triangle pointing right + head_size = 20 + for i in range(head_size): + spread = int(i * 1.2) + x = cx + shaft_len + i + for dy in range(-spread, spread + 1): + y = cy + dy + if 0 <= y < len(pixels) and 0 <= x < len(pixels[0]): + pixels[y][x] = color + + +def _draw_text_pixel( + pixels: list[list[tuple[int, int, int, int]]], + x: int, + y: int, + text: str, + color: tuple[int, int, int, int], + scale: int = 1, +) -> None: + """Draw simple pixel text. Limited to the phrase 'Drag to install'.""" + # 5x7 pixel font for uppercase + lowercase letters we need + glyphs: dict[str, list[str]] = { + "D": ["1110 ", "1 01", "1 01", "1 01", "1 01", "1 01", "1110 "], + "r": [" ", " ", " 110 ", "1 ", "1 ", "1 ", "1 "], + "a": [" ", " ", " 111 ", " 01", " 1111", "1 01", " 1111"], + "g": [" ", " ", " 1111", "1 01", " 1111", " 01", " 110 "], + "t": [" ", " 1 ", "1111 ", " 1 ", " 1 ", " 1 ", " 11 "], + "o": [" ", " ", " 110 ", "1 01", "1 01", "1 01", " 110 "], + "i": [" ", " 1 ", " ", " 1 ", " 1 ", " 1 ", " 1 "], + "n": [" ", " ", "1 10 ", "11 01", "1 01", "1 01", "1 01"], + "s": [" ", " ", " 111 ", "1 ", " 11 ", " 01", "111 "], + "l": [" ", " 1 ", " 1 ", " 1 ", " 1 ", " 1 ", " 1 "], + " ": [" ", " ", " ", " ", " ", " ", " "], + } + + cursor_x = x + for ch in text: + glyph = glyphs.get(ch) + if glyph is None: + cursor_x += 6 * scale + continue + for row_idx, row_str in enumerate(glyph): + for col_idx, pixel_ch in enumerate(row_str): + if pixel_ch == "1": + for sy in range(scale): + for sx in range(scale): + py = y + row_idx * scale + sy + px = cursor_x + col_idx * scale + sx + if 0 <= py < len(pixels) and 0 <= px < len(pixels[0]): + pixels[py][px] = color + cursor_x += (len(glyph[0]) + 1) * scale + + +def generate_background(output_path: str) -> None: + """Generate the DMG background image.""" + width, height = 660, 400 + + # Build gradient background: dark gray to slightly darker + top_color = (30, 30, 30) # #1e1e1e — matches exo-dark-gray + bottom_color = (18, 18, 18) # #121212 — matches exo-black + + pixels: list[list[tuple[int, int, int, int]]] = [] + for y in range(height): + t = y / (height - 1) + r = _lerp(top_color[0], bottom_color[0], t) + g = _lerp(top_color[1], bottom_color[1], t) + b = _lerp(top_color[2], bottom_color[2], t) + pixels.append([(r, g, b, 255)] * width) + + # Draw subtle grid lines (matches the exo dashboard grid) + grid_color = (40, 40, 40, 255) + for y in range(0, height, 40): + for x in range(width): + pixels[y][x] = grid_color + for x in range(0, width, 40): + for y in range(height): + pixels[y][x] = grid_color + + # Draw the arrow in the center (between app icon at x=155 and Applications at x=505) + arrow_color = (200, 180, 50, 255) # EXO yellow + _draw_arrow(pixels, width // 2, 200, arrow_color) + + # Draw instruction text below the arrow + text_color = (150, 150, 150, 200) + _draw_text_pixel(pixels, 268, 310, "Drag to install", text_color, scale=2) + + # Write PNG + png_data = _create_png(width, height, pixels) + Path(output_path).write_bytes(png_data) + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + generate_background(sys.argv[1]) + print(f"Background image written to {sys.argv[1]}") diff --git a/src/exo/utils/banner.py b/src/exo/utils/banner.py index ffdb54582..9fca88e4f 100644 --- a/src/exo/utils/banner.py +++ b/src/exo/utils/banner.py @@ -1,8 +1,26 @@ +import logging import sys +import webbrowser + +from exo.shared.constants import EXO_CONFIG_HOME + +logger = logging.getLogger(__name__) + +_FIRST_RUN_MARKER = EXO_CONFIG_HOME / ".dashboard_opened" + + +def _is_first_run() -> bool: + return not _FIRST_RUN_MARKER.exists() + + +def _mark_first_run_done() -> None: + _FIRST_RUN_MARKER.parent.mkdir(parents=True, exist_ok=True) + _FIRST_RUN_MARKER.touch() def print_startup_banner(port: int) -> None: dashboard_url = f"http://localhost:{port}" + first_run = _is_first_run() banner = f""" ╔═══════════════════════════════════════════════════════════════════════╗ ║ ║ @@ -30,3 +48,11 @@ def print_startup_banner(port: int) -> None: """ print(banner, file=sys.stderr) + + if first_run: + try: + webbrowser.open(dashboard_url) + logger.info("First run detected — opening dashboard in browser") + except Exception: + logger.debug("Could not auto-open browser", exc_info=True) + _mark_first_run_done()