mirror of
https://github.com/exo-explore/exo.git
synced 2026-02-15 16:42:28 -05:00
Compare commits
2 Commits
main
...
alexcheema
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b72e0ebe09 | ||
|
|
a215b7d57f |
5
.github/workflows/build-app.yml
vendored
5
.github/workflows/build-app.yml
vendored
@@ -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
|
||||
|
||||
@@ -307,7 +307,7 @@
|
||||
<div class="py-2">
|
||||
<div class="px-4 py-2">
|
||||
<span
|
||||
class="text-sm text-white/70 font-mono tracking-wider uppercase"
|
||||
class="text-xs text-exo-light-gray font-mono tracking-wider uppercase"
|
||||
>
|
||||
{searchQuery ? "SEARCH RESULTS" : "CONVERSATIONS"}
|
||||
</span>
|
||||
@@ -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'}"
|
||||
>
|
||||
<div class="flex-1 min-w-0 pr-2">
|
||||
<div
|
||||
class="text-sm truncate {activeId === conversation.id
|
||||
class="text-sm font-medium truncate {activeId ===
|
||||
conversation.id
|
||||
? 'text-exo-yellow'
|
||||
: 'text-white/90'}"
|
||||
: 'text-white'}"
|
||||
>
|
||||
{conversation.name}
|
||||
</div>
|
||||
<div class="text-sm text-white/50 mt-0.5">
|
||||
<div class="text-xs text-white/60 mt-0.5">
|
||||
{formatDate(conversation.updatedAt)}
|
||||
</div>
|
||||
<div class="text-sm text-white/70 truncate">
|
||||
<div class="text-xs text-exo-light-gray 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 class="text-xs text-white/70 font-mono mt-1">
|
||||
{#if stats.ttftMs}<span class="text-white/50">TTFT</span>
|
||||
<span class="text-exo-yellow/80"
|
||||
>{stats.ttftMs.toFixed(0)}ms</span
|
||||
>{/if}{#if stats.ttftMs && stats.tps}<span
|
||||
class="text-white/30 mx-1.5">·</span
|
||||
>{/if}{#if stats.tps}<span class="text-exo-yellow/80"
|
||||
>{stats.tps.toFixed(1)}</span
|
||||
>
|
||||
<span class="text-white/50">tok/s</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -298,6 +298,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());
|
||||
|
||||
@@ -2166,6 +2169,47 @@
|
||||
onNodeClick={togglePreviewNodeFilter}
|
||||
/>
|
||||
|
||||
<!-- Welcome overlay - shown when no instances are running -->
|
||||
{#if instanceCount === 0}
|
||||
<div
|
||||
class="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<div class="text-center pointer-events-auto max-w-md px-6">
|
||||
<div class="mb-4">
|
||||
<div
|
||||
class="text-2xl font-mono text-exo-yellow font-bold tracking-wide mb-2"
|
||||
>
|
||||
Welcome to exo
|
||||
</div>
|
||||
<p class="text-sm text-white/70 leading-relaxed">
|
||||
Your devices are connected. Choose a model to start
|
||||
running AI locally.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (isModelPickerOpen = true)}
|
||||
class="inline-flex items-center gap-2 px-6 py-3 bg-exo-yellow text-exo-black font-mono text-sm font-bold tracking-wider uppercase rounded-lg hover:bg-exo-yellow-darker hover:shadow-[0_0_30px_rgba(255,215,0,0.3)] transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Choose a Model
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{@render clusterWarnings()}
|
||||
|
||||
<!-- TB5 RDMA Available Info -->
|
||||
@@ -2278,8 +2322,18 @@
|
||||
<!-- Chat Input - Below topology -->
|
||||
<div class="px-4 pt-6 pb-8">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
{#if instanceCount === 0}
|
||||
<!-- No model loaded prompt -->
|
||||
<div class="text-center mb-6">
|
||||
<p class="text-sm text-white/60 font-mono">
|
||||
No model loaded yet. Select a model to get started.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<ChatForm
|
||||
placeholder="Ask anything"
|
||||
placeholder={instanceCount === 0
|
||||
? "Choose a model above to start chatting"
|
||||
: "Ask anything"}
|
||||
showHelperText={false}
|
||||
showModelSelector={true}
|
||||
modelTasks={modelTasks()}
|
||||
@@ -2450,11 +2504,11 @@
|
||||
>
|
||||
{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 debugEnabled}
|
||||
<div class="text-white/60 text-xs font-mono">
|
||||
{instanceInfo.sharding} · {instanceInfo.instanceType}
|
||||
</div>
|
||||
{/if}
|
||||
{#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"
|
||||
@@ -2697,18 +2751,41 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="text-xs text-blue-400 font-mono tracking-wider mt-1"
|
||||
>
|
||||
DOWNLOADING
|
||||
<div class="mt-2 space-y-1">
|
||||
<div
|
||||
class="text-xs text-blue-400 font-mono tracking-wider"
|
||||
>
|
||||
DOWNLOADING
|
||||
</div>
|
||||
<p
|
||||
class="text-[11px] text-white/50 leading-relaxed"
|
||||
>
|
||||
Downloading model files. This runs locally on your
|
||||
device and needs to finish before you can chat.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="text-xs {getStatusColor(
|
||||
downloadInfo.statusText,
|
||||
)} font-mono tracking-wider mt-1"
|
||||
>
|
||||
{downloadInfo.statusText}
|
||||
<div class="mt-1 space-y-1">
|
||||
<div
|
||||
class="text-xs {getStatusColor(
|
||||
downloadInfo.statusText,
|
||||
)} font-mono tracking-wider"
|
||||
>
|
||||
{downloadInfo.statusText}
|
||||
</div>
|
||||
{#if isLoading}
|
||||
<p
|
||||
class="text-[11px] text-white/50 leading-relaxed"
|
||||
>
|
||||
Loading model into memory for fast inference...
|
||||
</p>
|
||||
{:else if isReady || isRunning}
|
||||
<p
|
||||
class="text-[11px] text-green-400/70 leading-relaxed"
|
||||
>
|
||||
Ready to chat! Type a message below.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if downloadInfo.isFailed && downloadInfo.errorMessage}
|
||||
<div
|
||||
@@ -2799,172 +2876,201 @@
|
||||
</button>
|
||||
</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}
|
||||
<!-- Advanced Options Toggle -->
|
||||
<div class="flex-shrink-0 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showAdvancedOptions = !showAdvancedOptions)}
|
||||
class="flex items-center gap-2 text-xs text-white/50 hover:text-white/70 font-mono tracking-wider uppercase transition-colors cursor-pointer py-1"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3 transition-transform duration-200 {showAdvancedOptions
|
||||
? 'rotate-90'
|
||||
: ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<!-- 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
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
Advanced Options
|
||||
</button>
|
||||
|
||||
{#if showAdvancedOptions}
|
||||
<div class="mt-3 space-y-3 pl-1" in:fade={{ duration: 150 }}>
|
||||
<!-- Sharding Strategy -->
|
||||
<div>
|
||||
<div class="text-xs text-white/50 font-mono mb-2">
|
||||
Splitting Strategy:
|
||||
</div>
|
||||
{/each}
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => {
|
||||
selectedSharding = "Pipeline";
|
||||
saveLaunchDefaults();
|
||||
}}
|
||||
class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 cursor-pointer {selectedSharding ===
|
||||
'Pipeline'
|
||||
? 'bg-transparent text-exo-yellow border-exo-yellow'
|
||||
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
|
||||
>
|
||||
<span
|
||||
class="w-3 h-3 rounded-full border-2 flex items-center justify-center {selectedSharding ===
|
||||
'Pipeline'
|
||||
? 'border-exo-yellow'
|
||||
: 'border-exo-medium-gray'}"
|
||||
>
|
||||
{#if selectedSharding === "Pipeline"}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
|
||||
></span>
|
||||
{/if}
|
||||
</span>
|
||||
Pipeline
|
||||
</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
selectedSharding = "Tensor";
|
||||
saveLaunchDefaults();
|
||||
}}
|
||||
class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 cursor-pointer {selectedSharding ===
|
||||
'Tensor'
|
||||
? 'bg-transparent text-exo-yellow border-exo-yellow'
|
||||
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
|
||||
>
|
||||
<span
|
||||
class="w-3 h-3 rounded-full border-2 flex items-center justify-center {selectedSharding ===
|
||||
'Tensor'
|
||||
? 'border-exo-yellow'
|
||||
: 'border-exo-medium-gray'}"
|
||||
>
|
||||
{#if selectedSharding === "Tensor"}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
|
||||
></span>
|
||||
{/if}
|
||||
</span>
|
||||
Tensor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Runtime -->
|
||||
<div>
|
||||
<div class="text-xs text-white/50 font-mono mb-2">
|
||||
Runtime:
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => {
|
||||
selectedInstanceType = "MlxRing";
|
||||
saveLaunchDefaults();
|
||||
}}
|
||||
class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType ===
|
||||
'MlxRing'
|
||||
? 'bg-transparent text-exo-yellow border-exo-yellow'
|
||||
: 'bg-transparent text-white/70 border-exo-medium-gray/50 hover:border-exo-yellow/50'}"
|
||||
>
|
||||
<span
|
||||
class="w-3 h-3 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
|
||||
'MlxRing'
|
||||
? 'border-exo-yellow'
|
||||
: 'border-exo-medium-gray'}"
|
||||
>
|
||||
{#if selectedInstanceType === "MlxRing"}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
|
||||
></span>
|
||||
{/if}
|
||||
</span>
|
||||
Standard
|
||||
</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
selectedInstanceType = "MlxIbv";
|
||||
saveLaunchDefaults();
|
||||
}}
|
||||
class="flex items-center gap-2 py-1.5 px-3 text-xs font-mono border rounded transition-all duration-200 cursor-pointer {selectedInstanceType ===
|
||||
'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-3 h-3 rounded-full border-2 flex items-center justify-center {selectedInstanceType ===
|
||||
'MlxIbv'
|
||||
? 'border-exo-yellow'
|
||||
: 'border-exo-medium-gray'}"
|
||||
>
|
||||
{#if selectedInstanceType === "MlxIbv"}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-exo-yellow"
|
||||
></span>
|
||||
{/if}
|
||||
</span>
|
||||
RDMA (Fast)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Minimum Devices -->
|
||||
<div>
|
||||
<div class="text-xs text-white/50 font-mono mb-2">
|
||||
Minimum Devices:
|
||||
</div>
|
||||
<!-- Discrete slider track with drag support -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={sliderTrackElement}
|
||||
class="relative h-16 cursor-pointer select-none px-2 pr-6"
|
||||
onmousedown={handleSliderMouseDown}
|
||||
ontouchstart={handleSliderTouchStart}
|
||||
>
|
||||
<!-- Track background -->
|
||||
<div
|
||||
class="absolute top-6 left-0 right-0 h-2 bg-exo-medium-gray/50 rounded-full"
|
||||
></div>
|
||||
<!-- Active track (fills up to selected) -->
|
||||
{#if availableMinNodes > 1}
|
||||
<div
|
||||
class="absolute top-6 left-0 h-2 bg-white/30 rounded-full transition-all pointer-events-none"
|
||||
style="width: {((selectedMinNodes - 1) /
|
||||
(availableMinNodes - 1)) *
|
||||
100}%"
|
||||
></div>
|
||||
{/if}
|
||||
<!-- Dots and labels for each device count -->
|
||||
{#each Array.from({ length: availableMinNodes }, (_, i) => i + 1) as n}
|
||||
{@const isValid = validMinNodeCounts().has(n)}
|
||||
{@const isSelected = selectedMinNodes === n}
|
||||
{@const position =
|
||||
availableMinNodes > 1
|
||||
? ((n - 1) / (availableMinNodes - 1)) * 100
|
||||
: 50}
|
||||
<div
|
||||
class="absolute flex flex-col items-center pointer-events-none"
|
||||
style="left: {position}%; top: 0; transform: translateX(-50%);"
|
||||
>
|
||||
<span
|
||||
class="rounded-full transition-all {isSelected
|
||||
? 'w-6 h-6 bg-exo-yellow shadow-[0_0_10px_rgba(255,215,0,0.6)]'
|
||||
: isValid
|
||||
? 'w-4 h-4 bg-exo-light-gray/70 mt-1'
|
||||
: 'w-3 h-3 bg-exo-medium-gray/50 mt-1.5'}"
|
||||
></span>
|
||||
<span
|
||||
class="text-sm font-mono mt-1.5 tabular-nums transition-colors {isSelected
|
||||
? 'text-exo-yellow font-bold'
|
||||
: isValid
|
||||
? 'text-white/70'
|
||||
: 'text-white/30'}">{n}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Selected Model Preview -->
|
||||
@@ -3263,11 +3369,11 @@
|
||||
>
|
||||
{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 debugEnabled}
|
||||
<div class="text-white/60 text-xs font-mono">
|
||||
{instanceInfo.sharding} · {instanceInfo.instanceType}
|
||||
</div>
|
||||
{/if}
|
||||
{#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"
|
||||
@@ -3520,18 +3626,43 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="text-xs text-blue-400 font-mono tracking-wider mt-1"
|
||||
>
|
||||
DOWNLOADING
|
||||
<div class="mt-2 space-y-1">
|
||||
<div
|
||||
class="text-xs text-blue-400 font-mono tracking-wider"
|
||||
>
|
||||
DOWNLOADING
|
||||
</div>
|
||||
<p
|
||||
class="text-[11px] text-white/50 leading-relaxed"
|
||||
>
|
||||
Downloading model files. This runs locally on
|
||||
your device and needs to finish before you can
|
||||
chat.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="text-xs {getStatusColor(
|
||||
downloadInfo.statusText,
|
||||
)} font-mono tracking-wider mt-1"
|
||||
>
|
||||
{downloadInfo.statusText}
|
||||
<div class="mt-1 space-y-1">
|
||||
<div
|
||||
class="text-xs {getStatusColor(
|
||||
downloadInfo.statusText,
|
||||
)} font-mono tracking-wider"
|
||||
>
|
||||
{downloadInfo.statusText}
|
||||
</div>
|
||||
{#if isLoading}
|
||||
<p
|
||||
class="text-[11px] text-white/50 leading-relaxed"
|
||||
>
|
||||
Loading model into memory for fast
|
||||
inference...
|
||||
</p>
|
||||
{:else if isReady || isRunning}
|
||||
<p
|
||||
class="text-[11px] text-green-400/70 leading-relaxed"
|
||||
>
|
||||
Ready to chat! Type a message below.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if downloadInfo.isFailed && downloadInfo.errorMessage}
|
||||
<div
|
||||
|
||||
110
packaging/dmg/create-dmg.sh
Executable file
110
packaging/dmg/create-dmg.sh
Executable file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
# create-dmg.sh — Build a polished macOS DMG installer for EXO
|
||||
#
|
||||
# Usage:
|
||||
# ./packaging/dmg/create-dmg.sh <app-path> <output-dmg> [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 <app-path> <output-dmg> [volume-name]}"
|
||||
OUTPUT_DMG="${2:?Usage: create-dmg.sh <app-path> <output-dmg> [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 -F'\t' '/Apple_HFS/ {gsub(/^[[:space:]]+|[[:space:]]+$/, "", $NF); print $NF}')
|
||||
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 <<APPLESCRIPT
|
||||
tell application "Finder"
|
||||
tell disk "$VOLUME_NAME"
|
||||
open
|
||||
set current view of container window to icon view
|
||||
set toolbar visible of container window to false
|
||||
set statusbar visible of container window to false
|
||||
set bounds of container window to {200, 200, 860, 600}
|
||||
set opts to icon view options of container window
|
||||
set icon size of opts to 128
|
||||
set arrangement of opts to not arranged
|
||||
if exists file ".background:background.png" then
|
||||
set background picture of opts to file ".background:background.png"
|
||||
end if
|
||||
set position of item "$APP_NAME" of container window to {155, 200}
|
||||
set position of item "Applications" of container window to {505, 200}
|
||||
close
|
||||
open
|
||||
update without registering applications
|
||||
delay 1
|
||||
close
|
||||
end tell
|
||||
end tell
|
||||
APPLESCRIPT
|
||||
|
||||
echo " Window layout configured"
|
||||
|
||||
# Ensure Finder updates are flushed
|
||||
sync
|
||||
|
||||
# ── Step 6: Finalise ─────────────────────────────────────────────────────────
|
||||
hdiutil detach "$MOUNT_DIR" -quiet
|
||||
hdiutil convert "$TEMP_DMG" -format UDZO -imagekey zlib-level=9 -o "$OUTPUT_DMG"
|
||||
|
||||
echo "==> DMG created: $OUTPUT_DMG"
|
||||
echo " Size: $(du -h "$OUTPUT_DMG" | cut -f1)"
|
||||
172
packaging/dmg/generate-background.py
Normal file
172
packaging/dmg/generate-background.py
Normal file
@@ -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]} <output.png>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
generate_background(sys.argv[1])
|
||||
print(f"Background image written to {sys.argv[1]}")
|
||||
@@ -1,6 +1,26 @@
|
||||
import logging
|
||||
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:
|
||||
"""Print a prominent startup banner with API endpoint information."""
|
||||
dashboard_url = f"http://localhost:{port}"
|
||||
first_run = _is_first_run()
|
||||
banner = f"""
|
||||
╔═══════════════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
@@ -28,3 +48,11 @@ def print_startup_banner(port: int) -> None:
|
||||
"""
|
||||
|
||||
print(banner)
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user