From 01400485bfd036ee13620b11df905657a70e8d52 Mon Sep 17 00:00:00 2001 From: Alex Cheema Date: Sun, 15 Feb 2026 15:25:20 -0800 Subject: [PATCH] feat: complete onboarding rewrite, native settings, DMG polish, menu bar UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Full-screen Apple-style onboarding wizard (Welcome → Devices → Pick Model → Downloading → Loading → Ready) replacing the broken layered approach - Native macOS Settings window (General/Model/About tabs) replacing the cramped Advanced dropdown section - First-launch floating popout with 5s countdown that auto-opens dashboard - Clean DMG installer: dark gradient, white anti-aliased arrow, no grid - Menu bar: "Web Dashboard" with link icon, Base URL copy (localhost:52415/v1) - UI renames: Sharding Strategy, Interconnect, Load Model - Helpful "no model loaded" message instead of raw error on chat submit - Reduce launch delay from 15s to 5s Co-Authored-By: Claude Opus 4.6 --- app/EXO/EXO/ContentView.swift | 166 +- app/EXO/EXO/EXOApp.swift | 11 +- app/EXO/EXO/ExoProcessController.swift | 10 + app/EXO/EXO/Views/FirstLaunchPopout.swift | 145 + app/EXO/EXO/Views/SettingsView.swift | 163 + .../EXO/Views/SettingsWindowController.swift | 38 + dashboard/src/lib/stores/app.svelte.ts | 4 +- dashboard/src/routes/+page.svelte | 2662 ++++++++++------- packaging/dmg/generate-background.py | 133 +- 9 files changed, 2098 insertions(+), 1234 deletions(-) create mode 100644 app/EXO/EXO/Views/FirstLaunchPopout.swift create mode 100644 app/EXO/EXO/Views/SettingsView.swift create mode 100644 app/EXO/EXO/Views/SettingsWindowController.swift diff --git a/app/EXO/EXO/ContentView.swift b/app/EXO/EXO/ContentView.swift index 688fba5b..0c91f085 100644 --- a/app/EXO/EXO/ContentView.swift +++ b/app/EXO/EXO/ContentView.swift @@ -15,18 +15,16 @@ struct ContentView: View { @EnvironmentObject private var localNetworkChecker: LocalNetworkChecker @EnvironmentObject private var updater: SparkleUpdater @EnvironmentObject private var thunderboltBridgeService: ThunderboltBridgeService + @EnvironmentObject private var settingsWindowController: SettingsWindowController @State private var focusedNode: NodeViewModel? @State private var deletingInstanceIDs: Set = [] @State private var showAllNodes = false @State private var showAllInstances = false - @State private var showAdvanced = false @State private var showDebugInfo = false @State private var bugReportInFlight = false @State private var bugReportMessage: String? @State private var uninstallInProgress = false - @State private var pendingNamespace: String = "" - @State private var pendingHFToken: String = "" - @State private var pendingEnableImageModels = false + @State private var baseURLCopied = false var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -275,93 +273,23 @@ struct ContentView: View { private var advancedSection: some View { VStack(alignment: .leading, spacing: 6) { - HStack { - Text("Advanced") - .font(.caption) - .foregroundColor(.secondary) - Spacer() - collapseButton(isExpanded: $showAdvanced) + HoverButton( + title: "Settings", + tint: .primary, + trailingSystemImage: "gear", + small: false + ) { + settingsWindowController.open(controller: controller, updater: updater) } - .animation(nil, value: showAdvanced) - if showAdvanced { - VStack(alignment: .leading, spacing: 8) { - VStack(alignment: .leading, spacing: 4) { - Text("Cluster Namespace") - .font(.caption2) - .foregroundColor(.secondary) - HStack { - TextField("optional", text: $pendingNamespace) - .textFieldStyle(.roundedBorder) - .font(.caption2) - .onAppear { - pendingNamespace = controller.customNamespace - } - Button("Save & Restart") { - controller.customNamespace = pendingNamespace - if controller.status == .running || controller.status == .starting { - controller.restart() - } - } - .font(.caption2) - .disabled(pendingNamespace == controller.customNamespace) - } - } - VStack(alignment: .leading, spacing: 4) { - Text("HuggingFace Token") - .font(.caption2) - .foregroundColor(.secondary) - HStack { - SecureField("optional", text: $pendingHFToken) - .textFieldStyle(.roundedBorder) - .font(.caption2) - .onAppear { - pendingHFToken = controller.hfToken - } - Button("Save & Restart") { - controller.hfToken = pendingHFToken - if controller.status == .running || controller.status == .starting { - controller.restart() - } - } - .font(.caption2) - .disabled(pendingHFToken == controller.hfToken) - } - } - Divider() - HStack { - Toggle( - "Enable Image Models (experimental)", isOn: $pendingEnableImageModels - ) - .toggleStyle(.switch) - .font(.caption2) - .onAppear { - pendingEnableImageModels = controller.enableImageModels - } - - Spacer() - - Button("Save & Restart") { - controller.enableImageModels = pendingEnableImageModels - if controller.status == .running || controller.status == .starting { - controller.restart() - } - } - .font(.caption2) - .disabled(pendingEnableImageModels == controller.enableImageModels) - } - HoverButton(title: "Check for Updates", small: true) { - updater.checkForUpdates() - } - debugSection - HoverButton(title: "Uninstall", tint: .red, small: true) { - showUninstallConfirmationAlert() - } - .disabled(uninstallInProgress) - } - .transition(.opacity) + HoverButton(title: "Check for Updates", small: true) { + updater.checkForUpdates() } + debugSection + HoverButton(title: "Uninstall", tint: .red, small: true) { + showUninstallConfirmationAlert() + } + .disabled(uninstallInProgress) } - .animation(.easeInOut(duration: 0.25), value: showAdvanced) } private func controlButton(title: String, tint: Color = .primary, action: @escaping () -> Void) @@ -371,25 +299,57 @@ struct ContentView: View { } private var dashboardButton: some View { - Button { - guard let url = URL(string: "http://localhost:52415/") else { return } - NSWorkspace.shared.open(url) - } label: { - HStack { - Image(systemName: "arrow.up.right.square") - .imageScale(.small) - Text("Dashboard") - .fontWeight(.medium) + VStack(spacing: 6) { + Button { + guard let url = URL(string: "http://localhost:52415/") else { return } + NSWorkspace.shared.open(url) + } label: { + HStack { + Image(systemName: "globe") + .imageScale(.small) + Text("Web Dashboard") + .fontWeight(.medium) + Spacer() + Image(systemName: "arrow.up.right") + .imageScale(.small) + .foregroundColor(.secondary) + } + .padding(.vertical, 8) + .padding(.horizontal, 10) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color(red: 1.0, green: 0.87, blue: 0.0).opacity(0.2)) + ) + } + .buttonStyle(.plain) + + // Base URL for API integrations (SillyTavern, etc.) + HStack(spacing: 6) { + Text("Base URL:") + .font(.caption2) + .foregroundColor(.secondary) + Text("localhost:52415/v1") + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.primary) Spacer() + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString("http://localhost:52415/v1", forType: .string) + baseURLCopied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + baseURLCopied = false + } + } label: { + Image(systemName: baseURLCopied ? "checkmark" : "doc.on.doc") + .imageScale(.small) + .foregroundColor(baseURLCopied ? .green : .secondary) + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.plain) + .help("Copy API base URL") } - .padding(.vertical, 8) .padding(.horizontal, 10) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color(red: 1.0, green: 0.87, blue: 0.0).opacity(0.2)) - ) } - .buttonStyle(.plain) .padding(.bottom, 4) } diff --git a/app/EXO/EXO/EXOApp.swift b/app/EXO/EXO/EXOApp.swift index 3aff58c3..9eeed041 100644 --- a/app/EXO/EXO/EXOApp.swift +++ b/app/EXO/EXO/EXOApp.swift @@ -21,7 +21,9 @@ struct EXOApp: App { @StateObject private var localNetworkChecker: LocalNetworkChecker @StateObject private var updater: SparkleUpdater @StateObject private var thunderboltBridgeService: ThunderboltBridgeService + @StateObject private var settingsWindowController: SettingsWindowController private let terminationObserver: TerminationObserver + private let firstLaunchPopout = FirstLaunchPopout() private let ciContext = CIContext(options: nil) init() { @@ -43,12 +45,13 @@ struct EXOApp: App { _updater = StateObject(wrappedValue: updater) let thunderboltBridge = ThunderboltBridgeService(clusterStateService: service) _thunderboltBridgeService = StateObject(wrappedValue: thunderboltBridge) + _settingsWindowController = StateObject(wrappedValue: SettingsWindowController()) enableLaunchAtLoginIfNeeded() // Install LaunchDaemon to disable Thunderbolt Bridge on startup (prevents network loops) NetworkSetupHelper.promptAndInstallIfNeeded() // Check local network access periodically (warning disappears when user grants permission) localNetwork.startPeriodicChecking(interval: 10) - controller.scheduleLaunch(after: 15) + controller.scheduleLaunch(after: 5) service.startPolling() networkStatus.startPolling() } @@ -62,6 +65,12 @@ struct EXOApp: App { .environmentObject(localNetworkChecker) .environmentObject(updater) .environmentObject(thunderboltBridgeService) + .environmentObject(settingsWindowController) + .onReceive(controller.$isFirstLaunchReady) { ready in + if ready { + firstLaunchPopout.show() + } + } } label: { menuBarIcon } diff --git a/app/EXO/EXO/ExoProcessController.swift b/app/EXO/EXO/ExoProcessController.swift index 704b9d4f..bb84f06e 100644 --- a/app/EXO/EXO/ExoProcessController.swift +++ b/app/EXO/EXO/ExoProcessController.swift @@ -5,6 +5,7 @@ import Foundation private let customNamespaceKey = "EXOCustomNamespace" private let hfTokenKey = "EXOHFToken" private let enableImageModelsKey = "EXOEnableImageModels" +private let hasLaunchedBeforeKey = "EXOHasLaunchedBefore" @MainActor final class ExoProcessController: ObservableObject { @@ -60,6 +61,9 @@ final class ExoProcessController: ObservableObject { } } + /// Fires once when EXO transitions to `.running` for the very first time (fresh install). + @Published private(set) var isFirstLaunchReady = false + private var process: Process? private var runtimeDirectoryURL: URL? private var pendingLaunchTask: Task? @@ -113,6 +117,12 @@ final class ExoProcessController: ObservableObject { try child.run() process = child status = .running + + // Detect first-ever launch to trigger welcome popout + if !UserDefaults.standard.bool(forKey: hasLaunchedBeforeKey) { + UserDefaults.standard.set(true, forKey: hasLaunchedBeforeKey) + isFirstLaunchReady = true + } } catch { process = nil status = .failed(message: "Launch error") diff --git a/app/EXO/EXO/Views/FirstLaunchPopout.swift b/app/EXO/EXO/Views/FirstLaunchPopout.swift new file mode 100644 index 00000000..ef1dcdd0 --- /dev/null +++ b/app/EXO/EXO/Views/FirstLaunchPopout.swift @@ -0,0 +1,145 @@ +import AppKit +import SwiftUI + +/// A small floating panel that appears near the menu bar on first launch, +/// showing a countdown before auto-opening the dashboard. +/// Inspired by LlamaBarn's menu bar popout pattern. +@MainActor +final class FirstLaunchPopout { + private var panel: NSPanel? + private var countdownTask: Task? + private static let dashboardURL = "http://localhost:52415/" + + func show() { + guard panel == nil else { return } + + let hostingView = NSHostingView( + rootView: PopoutContentView( + onDismiss: { [weak self] in + self?.dismiss() + }, + onOpen: { [weak self] in + self?.openDashboard() + })) + hostingView.frame = NSRect(x: 0, y: 0, width: 300, height: 120) + + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 300, height: 120), + styleMask: [.nonactivatingPanel, .hudWindow, .utilityWindow], + backing: .buffered, + defer: false + ) + window.contentView = hostingView + window.isFloatingPanel = true + window.level = .floating + window.hasShadow = true + window.isOpaque = false + window.backgroundColor = .clear + window.isMovableByWindowBackground = false + window.hidesOnDeactivate = false + window.collectionBehavior = [.canJoinAllSpaces, .stationary] + + // Position near top-right of screen (near menu bar area) + if let screen = NSScreen.main { + let screenFrame = screen.visibleFrame + let x = screenFrame.maxX - window.frame.width - 16 + let y = screenFrame.maxY - 8 + window.setFrameOrigin(NSPoint(x: x, y: y)) + } + + window.orderFrontRegardless() + panel = window + + // Start countdown: auto-open dashboard after 5 seconds, then dismiss + countdownTask = Task { + try? await Task.sleep(nanoseconds: 5_000_000_000) + if !Task.isCancelled { + openDashboard() + // Give the browser a moment, then dismiss + try? await Task.sleep(nanoseconds: 1_000_000_000) + if !Task.isCancelled { + dismiss() + } + } + } + } + + func dismiss() { + countdownTask?.cancel() + countdownTask = nil + panel?.close() + panel = nil + } + + private func openDashboard() { + guard let url = URL(string: Self.dashboardURL) else { return } + NSWorkspace.shared.open(url) + } +} + +/// SwiftUI content for the first-launch popout +private struct PopoutContentView: View { + let onDismiss: () -> Void + let onOpen: () -> Void + @State private var countdown = 5 + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .imageScale(.large) + Text("EXO is ready!") + .font(.system(.headline, design: .default)) + .fontWeight(.semibold) + Spacer() + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + .imageScale(.small) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + + Text("http://localhost:52415") + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.secondary) + + HStack { + Button { + onOpen() + onDismiss() + } label: { + Text("Open Dashboard") + .font(.caption) + .fontWeight(.medium) + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + + Spacer() + + Text("Opening in \(countdown)s") + .font(.caption2) + .foregroundColor(.secondary) + } + } + .padding(12) + .onAppear { + startCountdown() + } + } + + private func startCountdown() { + Task { + while countdown > 0 { + try? await Task.sleep(nanoseconds: 1_000_000_000) + if !Task.isCancelled { + countdown -= 1 + } + } + } + } +} diff --git a/app/EXO/EXO/Views/SettingsView.swift b/app/EXO/EXO/Views/SettingsView.swift new file mode 100644 index 00000000..4d364866 --- /dev/null +++ b/app/EXO/EXO/Views/SettingsView.swift @@ -0,0 +1,163 @@ +import SwiftUI + +/// Native macOS Settings window following Apple HIG. +/// Organized into General, Model, and Advanced sections. +struct SettingsView: View { + @EnvironmentObject private var controller: ExoProcessController + @EnvironmentObject private var updater: SparkleUpdater + + @State private var pendingNamespace: String = "" + @State private var pendingHFToken: String = "" + @State private var pendingEnableImageModels = false + @State private var needsRestart = false + + var body: some View { + TabView { + generalTab + .tabItem { + Label("General", systemImage: "gear") + } + modelTab + .tabItem { + Label("Model", systemImage: "cube") + } + aboutTab + .tabItem { + Label("About", systemImage: "info.circle") + } + } + .frame(width: 450, height: 320) + .onAppear { + pendingNamespace = controller.customNamespace + pendingHFToken = controller.hfToken + pendingEnableImageModels = controller.enableImageModels + needsRestart = false + } + } + + // MARK: - General Tab + + private var generalTab: some View { + Form { + Section { + LabeledContent("Cluster Namespace") { + TextField("default", text: $pendingNamespace) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + } + Text("Nodes with the same namespace form a cluster. Leave empty for default.") + .font(.caption) + .foregroundColor(.secondary) + } + + Section { + LabeledContent("HuggingFace Token") { + SecureField("optional", text: $pendingHFToken) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + } + Text("Required for gated models. Get yours at huggingface.co/settings/tokens") + .font(.caption) + .foregroundColor(.secondary) + } + + Section { + HStack { + Spacer() + Button("Save & Restart") { + applyGeneralSettings() + } + .disabled(!hasGeneralChanges) + } + } + } + .formStyle(.grouped) + .padding() + } + + // MARK: - Model Tab + + private var modelTab: some View { + Form { + Section { + Toggle("Enable Image Models (experimental)", isOn: $pendingEnableImageModels) + Text("Allow text-to-image and image-to-image models in the model picker.") + .font(.caption) + .foregroundColor(.secondary) + } + + Section { + HStack { + Spacer() + Button("Save & Restart") { + applyModelSettings() + } + .disabled(!hasModelChanges) + } + } + } + .formStyle(.grouped) + .padding() + } + + // MARK: - About Tab + + private var aboutTab: some View { + Form { + Section { + LabeledContent("Version") { + Text(buildTag) + .textSelection(.enabled) + } + LabeledContent("Commit") { + Text(buildCommit) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } + } + + Section { + Button("Check for Updates") { + updater.checkForUpdates() + } + } + } + .formStyle(.grouped) + .padding() + } + + // MARK: - Helpers + + private var hasGeneralChanges: Bool { + pendingNamespace != controller.customNamespace || pendingHFToken != controller.hfToken + } + + private var hasModelChanges: Bool { + pendingEnableImageModels != controller.enableImageModels + } + + private func applyGeneralSettings() { + controller.customNamespace = pendingNamespace + controller.hfToken = pendingHFToken + restartIfRunning() + } + + private func applyModelSettings() { + controller.enableImageModels = pendingEnableImageModels + restartIfRunning() + } + + private func restartIfRunning() { + if controller.status == .running || controller.status == .starting { + controller.restart() + } + } + + private var buildTag: String { + Bundle.main.infoDictionary?["EXOBuildTag"] as? String ?? "unknown" + } + + private var buildCommit: String { + Bundle.main.infoDictionary?["EXOBuildCommit"] as? String ?? "unknown" + } +} diff --git a/app/EXO/EXO/Views/SettingsWindowController.swift b/app/EXO/EXO/Views/SettingsWindowController.swift new file mode 100644 index 00000000..7ac6b1bc --- /dev/null +++ b/app/EXO/EXO/Views/SettingsWindowController.swift @@ -0,0 +1,38 @@ +import AppKit +import SwiftUI + +/// Manages a standalone native macOS Settings window. +/// Ensures only one instance exists and brings it to front on repeated opens. +@MainActor +final class SettingsWindowController: ObservableObject { + private var window: NSWindow? + + func open(controller: ExoProcessController, updater: SparkleUpdater) { + if let existing = window, existing.isVisible { + existing.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let settingsView = SettingsView() + .environmentObject(controller) + .environmentObject(updater) + + let hostingView = NSHostingView(rootView: settingsView) + + let newWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 450, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + newWindow.title = "EXO Settings" + newWindow.contentView = hostingView + newWindow.center() + newWindow.isReleasedWhenClosed = false + newWindow.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + + window = newWindow + } +} diff --git a/dashboard/src/lib/stores/app.svelte.ts b/dashboard/src/lib/stores/app.svelte.ts index e5dbf902..5182d130 100644 --- a/dashboard/src/lib/stores/app.svelte.ts +++ b/dashboard/src/lib/stores/app.svelte.ts @@ -1805,7 +1805,7 @@ class AppStore { assistantMessage.id, (msg) => { msg.content = - "Error: No model available. Please launch an instance first."; + "No model is loaded yet. Select a model from the sidebar to get started — it will download and load automatically."; }, ); this.syncActiveMessagesIfNeeded(targetConversationId); @@ -2243,7 +2243,7 @@ class AppStore { const modelToUse = this.getModelForRequest(); if (!modelToUse) { throw new Error( - "No model selected and no running instances available. Please launch an instance first.", + "No model is loaded yet. Select a model from the sidebar to get started — it will download and load automatically.", ); } diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte index 7b2f027b..f68e3ddb 100644 --- a/dashboard/src/routes/+page.svelte +++ b/dashboard/src/routes/+page.svelte @@ -154,6 +154,117 @@ let mounted = $state(false); + // ── Onboarding wizard state ── + const ONBOARDING_COMPLETE_KEY = "exo-onboarding-complete"; + let onboardingStep = $state(0); // 0 = not in onboarding, 1-6 = wizard steps + let onboardingModelId = $state(null); // model selected during onboarding + const showOnboarding = $derived(onboardingStep > 0); + + // Recommended models for onboarding (sorted by fit, then size desc, limited to 6) + const onboardingModels = $derived(() => { + if (models.length === 0) return []; + return [...models] + .filter((m) => getModelMemoryFitStatus(m) !== "too_large") + .sort((a, b) => { + const aFit = hasEnoughMemory(a) ? 0 : 1; + const bFit = hasEnoughMemory(b) ? 0 : 1; + if (aFit !== bFit) return aFit - bFit; + return getModelSizeGB(b) - getModelSizeGB(a); + }) + .slice(0, 6); + }); + + // Track onboarding instance status for auto-advancing steps + $effect(() => { + if (onboardingStep === 4 && instanceCount > 0) { + // Check if any instance is past downloading + let anyDownloading = false; + for (const [id, inst] of Object.entries(instanceData)) { + const status = getInstanceDownloadStatus(id, inst); + if (status.isDownloading) { + anyDownloading = true; + break; + } + } + if (!anyDownloading) { + onboardingStep = 5; + } + } + }); + + $effect(() => { + if (onboardingStep === 5 && instanceCount > 0) { + for (const [id, inst] of Object.entries(instanceData)) { + const status = getInstanceDownloadStatus(id, inst); + if ( + status.statusText === "READY" || + status.statusText === "LOADED" || + status.statusText === "RUNNING" + ) { + onboardingStep = 6; + break; + } + } + } + }); + + function completeOnboarding() { + onboardingStep = 0; + try { + localStorage.setItem(ONBOARDING_COMPLETE_KEY, "true"); + } catch { + // ignore + } + } + + async function onboardingLaunchModel(modelId: string) { + onboardingModelId = modelId; + selectPreviewModel(modelId); + onboardingStep = 4; + // Launch via API + try { + const placementResponse = await fetch( + `/instance/placement?model_id=${encodeURIComponent(modelId)}&sharding=${selectedSharding}&instance_meta=${selectedInstanceType}&min_nodes=1`, + ); + if (!placementResponse.ok) { + console.error( + "Onboarding placement failed:", + await placementResponse.text(), + ); + onboardingStep = 3; + return; + } + const placementData = await placementResponse.json(); + const response = await fetch("/instance", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ instance: placementData }), + }); + if (!response.ok) { + console.error("Onboarding launch failed:", await response.text()); + onboardingStep = 3; + return; + } + setSelectedChatModel(modelId); + recordRecentLaunch(modelId); + } catch (error) { + console.error("Onboarding launch error:", error); + onboardingStep = 3; + } + } + + // Helper to get onboarding download progress + const onboardingDownloadProgress = $derived(() => { + if (instanceCount === 0) return null; + for (const [id, inst] of Object.entries(instanceData)) { + const status = getInstanceDownloadStatus(id, inst); + if (status.isDownloading && status.progress) { + return status.progress; + } + } + return null; + }); + // Instance launch state let models = $state< Array<{ @@ -630,6 +741,10 @@ onMount(() => { mounted = true; fetchModels(); + // Show onboarding wizard for first-time users + if (!localStorage.getItem(ONBOARDING_COMPLETE_KEY)) { + onboardingStep = 1; + } }); async function fetchModels() { @@ -2042,157 +2157,159 @@ > - {#if !topologyOnlyEnabled} - - {/if} - - -
- - {#if !topologyOnlyEnabled && sidebarVisible} -
- -
- {/if} - - {#if topologyOnlyEnabled} - -
+ {#if showOnboarding} + + + +
+ {#if onboardingStep === 1} +
- - - {@render clusterWarnings()} - - - {#if tb5WithoutRdma && !tb5InfoDismissed} +
0} - class:top-4={tbBridgeCycles.length === 0} - role="status" + class="text-5xl font-mono font-bold text-exo-yellow tracking-wider mb-4" > - - - - - RDMA AVAILABLE - - + exo
- {/if} - - +

+ Welcome to exo +

+

+ Run AI models locally, across all your devices. Let's get you set + up. +

+
-
- {:else if !chatStarted} - -
- -
- + {:else if onboardingStep === 2} + +
+
+

+ Here are your devices +

+

+ {nodeCount} device{nodeCount !== 1 ? "s" : ""} connected + {#if clusterTotalMemoryGB() > 0} + · {clusterTotalMemoryGB().toFixed(0)} GB total memory + {/if} +

+
- +
+ +
+ {:else if onboardingStep === 3} + +
+
+

+ Choose a model +

+

+ Pick a model to download and run on your cluster. +

+
- - {#if instanceCount === 0} -
-
-
-
- Welcome to exo + {#if onboardingModels().length === 0} +
+
+ Loading models... +
+
+ {:else} +
+ {#each onboardingModels() as model} + {@const sizeGB = getModelSizeGB(model)} + {@const fitsNow = hasEnoughMemory(model)} + {@const tags = modelTags()[model.id] || []} + -
+
+ + {/each} +
+ {/if} + + +
+ {:else if onboardingStep === 4} + +
+
+

Downloading

+

+ {#if onboardingModelId} + {onboardingModelId} + {/if} +

+
+ + {#if onboardingDownloadProgress()} +
+
+
- {/if} +
+ {onboardingDownloadProgress()!.percentage.toFixed(1)}% + {formatBytes(onboardingDownloadProgress()!.downloadedBytes)} / + {formatBytes(onboardingDownloadProgress()!.totalBytes)} +
+
+ {formatSpeed(onboardingDownloadProgress()!.speed)} + ETA: {formatEta(onboardingDownloadProgress()!.etaMs)} +
+
+ {:else} +
+
+
+
+

+ Preparing download... +

+
+ {/if} + +

+ This may take a few minutes depending on your connection. +

+
+ {:else if onboardingStep === 5} + +
+
+

+ Loading into memory +

+

+ {#if onboardingModelId} + {onboardingModelId} + {/if} +

+
+ +
+
+
+ +

Almost ready...

+
+ {:else if onboardingStep === 6} + +
+
+
+
+ + + +
+
+

+ You're all set! +

+

+ {#if onboardingModelId} + {onboardingModelId} is ready. + {:else} + Your model is ready. + {/if} +

+
+ + +
+ {/if} +
+ + + {#if onboardingStep === 3} + m.id))} + canModelFit={(modelId) => { + const model = models.find((m) => m.id === modelId); + return model ? hasEnoughMemory(model) : false; + }} + getModelFitStatus={(modelId): ModelMemoryFitStatus => { + const model = models.find((m) => m.id === modelId); + return model ? getModelMemoryFitStatus(model) : "too_large"; + }} + onSelect={(modelId) => { + isModelPickerOpen = false; + onboardingLaunchModel(modelId); + }} + onClose={() => (isModelPickerOpen = false)} + onToggleFavorite={toggleFavorite} + onAddModel={addModelFromPicker} + onDeleteModel={deleteCustomModel} + totalMemoryGB={clusterMemory().total / (1024 * 1024 * 1024)} + usedMemoryGB={clusterMemory().used / (1024 * 1024 * 1024)} + {downloadsData} + topologyNodes={data?.nodes} + /> + {/if} + {:else} + + + + {#if !topologyOnlyEnabled} + + {/if} + + +
+ + {#if !topologyOnlyEnabled && sidebarVisible} +
+ +
+ {/if} + + {#if topologyOnlyEnabled} + +
+
+ {@render clusterWarnings()} {#if tb5WithoutRdma && !tb5InfoDismissed}
0} class:top-4={tbBridgeCycles.length === 0} role="status" > -
+ + + + RDMA AVAILABLE + + +
+ {/if} + + + +
+
+ {:else if !chatStarted} + +
+ +
+ +
+ + + + + {#if instanceCount === 0} +
+
+
+
+ Welcome to exo +
+

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

+
+ +
+
+ {/if} + + {@render clusterWarnings()} + + + {#if tb5WithoutRdma && !tb5InfoDismissed} +
0} + class:top-4={tbBridgeCycles.length === 0} + role="status" + > +
- -
- - - -
- {/if} + + + +
- - {#if isFilterActive()} - - {/if} -
- - -
-
- {#if instanceCount === 0} - -
-

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

+ +
{/if} - -
-
-
- - -
- {:else} - -
- -
-
-
- -
-
- -
-
- -
-
-
- - - {#if minimized} -
+
- + + - {/if} -
- {/if} -
+ + {:else} + +
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ + + {#if minimized} + + {/if} +
+ {/if} + + {/if} int: return max(0, min(255, int(a + (b - a) * t))) -def _draw_arrow( +def _blend( + bg: tuple[int, int, int, int], fg: tuple[int, int, int, int] +) -> tuple[int, int, int, int]: + """Alpha-blend fg over bg.""" + fa = fg[3] / 255.0 + ba = bg[3] / 255.0 + oa = fa + ba * (1 - fa) + if oa == 0: + return (0, 0, 0, 0) + r = int((fg[0] * fa + bg[0] * ba * (1 - fa)) / oa) + g = int((fg[1] * fa + bg[1] * ba * (1 - fa)) / oa) + b = int((fg[2] * fa + bg[2] * ba * (1 - fa)) / oa) + return (r, g, b, int(oa * 255)) + + +def _draw_smooth_arrow( pixels: list[list[tuple[int, int, int, int]]], cx: int, cy: int, - color: tuple[int, int, int, int], + color: tuple[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 + """Draw a clean, minimal right-pointing arrow with anti-aliased edges.""" + height = len(pixels) + width = len(pixels[0]) if height > 0 else 0 - # 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 + # Slim shaft + shaft_half_len = 32 + shaft_half_thickness = 1.5 + + for x in range(cx - shaft_half_len, cx + shaft_half_len + 1): + for y_offset_10 in range(-30, 31): # sub-pixel sampling + y_f = cy + y_offset_10 / 10.0 + yi = int(y_f) + dist = abs(y_f - cy) + if dist <= shaft_half_thickness and 0 <= yi < height and 0 <= x < width: + # Smooth edge falloff + edge_dist = shaft_half_thickness - dist + alpha = min(1.0, edge_dist * 2.0) + a = int(alpha * 200) + fg = (color[0], color[1], color[2], a) + pixels[yi][x] = _blend(pixels[yi][x], fg) + + # Chevron arrowhead (> shape) — clean and modern + head_x = cx + shaft_half_len - 2 + head_size = 14 + stroke_width = 2.0 + + for i_10 in range(head_size * 10): + t = i_10 / 10.0 + # Top arm of chevron + px_f = head_x + t + py_top_f = cy - t + # Bottom arm of chevron + py_bot_f = cy + t + + for dy_10 in range(int(-stroke_width * 10), int(stroke_width * 10) + 1): + for arm_py in [py_top_f, py_bot_f]: + py = int(arm_py + dy_10 / 10.0) + px = int(px_f) + if 0 <= py < height and 0 <= px < width: + dist = abs(dy_10 / 10.0) + alpha = max(0.0, min(1.0, (stroke_width - dist) * 1.5)) + a = int(alpha * 200) + fg = (color[0], color[1], color[2], a) + pixels[py][px] = _blend(pixels[py][px], fg) def _draw_text_pixel( @@ -93,7 +134,7 @@ def _draw_text_pixel( scale: int = 1, ) -> None: """Draw simple pixel text. Limited to the phrase 'Drag to install'.""" - # 5x7 pixel font for uppercase + lowercase letters we need + # 5x7 pixel font for the 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 "], @@ -122,7 +163,7 @@ def _draw_text_pixel( 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 + pixels[py][px] = _blend(pixels[py][px], color) cursor_x += (len(glyph[0]) + 1) * scale @@ -130,33 +171,33 @@ 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 + # Clean dark gradient — no grid, no noise + top_color = (28, 28, 30) # macOS dark mode surface + bottom_color = (16, 16, 18) # slightly darker at bottom 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): + row: list[tuple[int, int, int, int]] = [] 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 + # Radial vignette: slightly brighter in center for depth + dx = (x - width / 2) / (width / 2) + dy = (y - height * 0.45) / (height / 2) + dist = math.sqrt(dx * dx + dy * dy) + vignette = max(0.0, 1.0 - dist * 0.3) - # 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) + r = _lerp(top_color[0], bottom_color[0], t) + int(vignette * 6) + g = _lerp(top_color[1], bottom_color[1], t) + int(vignette * 6) + b = _lerp(top_color[2], bottom_color[2], t) + int(vignette * 6) + row.append((min(255, r), min(255, g), min(255, b), 255)) + pixels.append(row) - # Draw instruction text below the arrow - text_color = (150, 150, 150, 200) + # Draw a clean, minimal arrow between app (x=155) and Applications (x=505) + arrow_color = (255, 255, 255) # white — clean and visible + _draw_smooth_arrow(pixels, width // 2, 200, arrow_color) + + # Draw instruction text below the arrow — white for readability + text_color = (255, 255, 255, 140) # white, semi-transparent _draw_text_pixel(pixels, 268, 310, "Drag to install", text_color, scale=2) # Write PNG