← back to Exo
feat(dashboard): improve model picker feedback and HuggingFace search (#1661)
e8c3a873a66e4adf7c5b39f54282962b80c1d512 · 2026-03-06 18:23:08 +0800 · Alex Cheema
## Motivation
Fixes #1648. Users adding custom models from HuggingFace got no clear
feedback that the model was successfully added — the model didn't appear
prominently in the list. Additionally, searches were limited to
`mlx-community` models with no fallback.
## Changes
- **Success feedback**: After adding a custom model, a toast
notification appears, the view auto-switches to "All Models", and the
newly added model is scrolled into view with a green highlight that
fades over 4 seconds.
- **HuggingFace search fallback**: The `/models/search` endpoint now
searches `mlx-community` first; if no results are found, it falls back
to searching all of HuggingFace.
- **Inline HF results**: When the main search bar finds no local
matches, HuggingFace search results appear inline with "+ Add" buttons
and a "See all results on Hub" link.
- **Full repo ID for non-mlx models**: Non-mlx-community models now
display the full repo ID (e.g., `meta-llama/Llama-3.1-8B`) instead of
just the short name.
## Why It Works
The toast + scroll + highlight gives immediate, unambiguous feedback
that the model was added and where it lives in the list. The HF search
fallback broadens discoverability while still prioritising mlx-community
models. Inline results in the main search bar mean users don't need to
navigate to the HF tab to discover new models.
## Test Plan
### Manual Testing
- Open model picker → HuggingFace Hub tab → search for a model → click
"+ Add"
- Verify: toast appears, view switches to All Models, model highlighted
with green glow
- Search for a term with no mlx-community results → verify fallback to
full HF search
- Non-mlx-community results show full repo ID (e.g., `org/model-name`)
- On All Models tab, search for a model not in the local list → verify
"From HuggingFace" section appears
### Automated Testing
- Existing tests cover the model catalog and API; no new automated tests
needed for UI behaviour changes
---------
Co-authored-by: Evan <evanev7@gmail.com>
Files touched
M dashboard/src/lib/components/HuggingFaceResultItem.svelteM dashboard/src/lib/components/ModelPickerGroup.svelteM dashboard/src/lib/components/ModelPickerModal.svelteM src/exo/master/api.py
Diff
commit e8c3a873a66e4adf7c5b39f54282962b80c1d512
Author: Alex Cheema <41707476+AlexCheema@users.noreply.github.com>
Date: Fri Mar 6 18:23:08 2026 +0800
feat(dashboard): improve model picker feedback and HuggingFace search (#1661)
## Motivation
Fixes #1648. Users adding custom models from HuggingFace got no clear
feedback that the model was successfully added — the model didn't appear
prominently in the list. Additionally, searches were limited to
`mlx-community` models with no fallback.
## Changes
- **Success feedback**: After adding a custom model, a toast
notification appears, the view auto-switches to "All Models", and the
newly added model is scrolled into view with a green highlight that
fades over 4 seconds.
- **HuggingFace search fallback**: The `/models/search` endpoint now
searches `mlx-community` first; if no results are found, it falls back
to searching all of HuggingFace.
- **Inline HF results**: When the main search bar finds no local
matches, HuggingFace search results appear inline with "+ Add" buttons
and a "See all results on Hub" link.
- **Full repo ID for non-mlx models**: Non-mlx-community models now
display the full repo ID (e.g., `meta-llama/Llama-3.1-8B`) instead of
just the short name.
## Why It Works
The toast + scroll + highlight gives immediate, unambiguous feedback
that the model was added and where it lives in the list. The HF search
fallback broadens discoverability while still prioritising mlx-community
models. Inline results in the main search bar mean users don't need to
navigate to the HF tab to discover new models.
## Test Plan
### Manual Testing
- Open model picker → HuggingFace Hub tab → search for a model → click
"+ Add"
- Verify: toast appears, view switches to All Models, model highlighted
with green glow
- Search for a term with no mlx-community results → verify fallback to
full HF search
- Non-mlx-community results show full repo ID (e.g., `org/model-name`)
- On All Models tab, search for a model not in the local list → verify
"From HuggingFace" section appears
### Automated Testing
- Existing tests cover the model catalog and API; no new automated tests
needed for UI behaviour changes
---------
Co-authored-by: Evan <evanev7@gmail.com>
---
.../lib/components/HuggingFaceResultItem.svelte | 8 +-
.../src/lib/components/ModelPickerGroup.svelte | 23 +++-
.../src/lib/components/ModelPickerModal.svelte | 131 +++++++++++++++++++++
src/exo/master/api.py | 56 +++++----
4 files changed, 195 insertions(+), 23 deletions(-)
diff --git a/dashboard/src/lib/components/HuggingFaceResultItem.svelte b/dashboard/src/lib/components/HuggingFaceResultItem.svelte
index f09408da..dc96d29f 100644
--- a/dashboard/src/lib/components/HuggingFaceResultItem.svelte
+++ b/dashboard/src/lib/components/HuggingFaceResultItem.svelte
@@ -36,8 +36,12 @@
return num.toString();
}
- // Extract model name from full ID (e.g., "mlx-community/Llama-3.2-1B" -> "Llama-3.2-1B")
- const modelName = $derived(model.id.split("/").pop() || model.id);
+ // Show short name for mlx-community models, full ID for everything else
+ const modelName = $derived(
+ model.author === "mlx-community"
+ ? model.id.split("/").pop() || model.id
+ : model.id,
+ );
</script>
<div
diff --git a/dashboard/src/lib/components/ModelPickerGroup.svelte b/dashboard/src/lib/components/ModelPickerGroup.svelte
index b5270f5a..c09600b0 100644
--- a/dashboard/src/lib/components/ModelPickerGroup.svelte
+++ b/dashboard/src/lib/components/ModelPickerGroup.svelte
@@ -32,6 +32,7 @@
group: ModelGroup;
isExpanded: boolean;
isFavorite: boolean;
+ isHighlighted?: boolean;
selectedModelId: string | null;
canModelFit: (id: string) => boolean;
getModelFitStatus: (id: string) => ModelFitStatus;
@@ -48,6 +49,7 @@
group,
isExpanded,
isFavorite,
+ isHighlighted = false,
selectedModelId,
canModelFit,
getModelFitStatus,
@@ -150,10 +152,11 @@
</script>
<div
+ data-model-ids={group.variants.map((v) => v.id).join(" ")}
class="border-b border-white/5 last:border-b-0 {!anyVariantFits &&
!anyVariantHasInstance
? 'opacity-50'
- : ''}"
+ : ''} {isHighlighted ? 'model-just-added' : ''}"
>
<!-- Main row -->
<div
@@ -644,3 +647,21 @@
</div>
{/if}
</div>
+
+<style>
+ .model-just-added {
+ animation: highlightFade 4s ease-out forwards;
+ }
+
+ @keyframes highlightFade {
+ 0%,
+ 40% {
+ background-color: rgba(20, 83, 45, 0.25);
+ box-shadow: inset 0 0 0 1px rgba(74, 222, 128, 0.4);
+ }
+ 100% {
+ background-color: transparent;
+ box-shadow: none;
+ }
+ }
+</style>
diff --git a/dashboard/src/lib/components/ModelPickerModal.svelte b/dashboard/src/lib/components/ModelPickerModal.svelte
index 06ed4d24..0753c8be 100644
--- a/dashboard/src/lib/components/ModelPickerModal.svelte
+++ b/dashboard/src/lib/components/ModelPickerModal.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { tick } from "svelte";
import { fade, fly } from "svelte/transition";
import { cubicOut } from "svelte/easing";
import FamilySidebar from "./FamilySidebar.svelte";
@@ -7,6 +8,7 @@
import HuggingFaceResultItem from "./HuggingFaceResultItem.svelte";
import { getNodesWithModelDownloaded } from "$lib/utils/downloads";
import { getRecentEntries } from "$lib/stores/recents.svelte";
+ import { addToast } from "$lib/stores/toast.svelte";
interface ModelInfo {
id: string;
@@ -191,6 +193,13 @@
let hfSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let manualModelId = $state("");
let addModelError = $state<string | null>(null);
+ let justAddedModelId = $state<string | null>(null);
+ let justAddedTimer: ReturnType<typeof setTimeout> | null = null;
+
+ // Inline HuggingFace search in main search bar
+ let mainSearchHfResults = $state<HuggingFaceModel[]>([]);
+ let mainSearchHfLoading = $state(false);
+ let mainSearchDebounceTimer: ReturnType<typeof setTimeout> | null = null;
// Reset transient state when modal opens, but preserve tab selection
$effect(() => {
@@ -200,6 +209,11 @@
showFilters = false;
manualModelId = "";
addModelError = null;
+ justAddedModelId = null;
+ if (justAddedTimer) {
+ clearTimeout(justAddedTimer);
+ justAddedTimer = null;
+ }
}
});
@@ -214,6 +228,50 @@
}
});
+ // Inline HuggingFace search when local search returns no results
+ $effect(() => {
+ const query = searchQuery.trim();
+ const noLocalResults = filteredGroups.length === 0;
+
+ if (mainSearchDebounceTimer) {
+ clearTimeout(mainSearchDebounceTimer);
+ mainSearchDebounceTimer = null;
+ }
+
+ if (
+ selectedFamily === "huggingface" ||
+ selectedFamily === "recents" ||
+ selectedFamily === "favorites" ||
+ query.length < 2 ||
+ !noLocalResults
+ ) {
+ mainSearchHfResults = [];
+ mainSearchHfLoading = false;
+ return;
+ }
+
+ mainSearchHfLoading = true;
+ mainSearchDebounceTimer = setTimeout(async () => {
+ try {
+ const response = await fetch(
+ `/models/search?query=${encodeURIComponent(query)}&limit=10`,
+ );
+ if (response.ok) {
+ const results: HuggingFaceModel[] = await response.json();
+ mainSearchHfResults = results.filter(
+ (r) => !existingModelIds.has(r.id),
+ );
+ } else {
+ mainSearchHfResults = [];
+ }
+ } catch {
+ mainSearchHfResults = [];
+ } finally {
+ mainSearchHfLoading = false;
+ }
+ }, 500);
+ });
+
async function fetchTrendingModels() {
hfIsLoadingTrending = true;
try {
@@ -274,6 +332,24 @@
addModelError = null;
try {
await onAddModel(modelId);
+ // Success: show toast, switch to All Models, highlight the model
+ const shortName = modelId.split("/").pop() || modelId;
+ addToast({ type: "success", message: `Added ${shortName}` });
+ justAddedModelId = modelId;
+ selectedFamily = null;
+ searchQuery = "";
+ // Scroll to the newly added model after DOM update
+ await tick();
+ const el = document.querySelector(
+ `[data-model-ids~="${CSS.escape(modelId)}"]`,
+ );
+ el?.scrollIntoView({ behavior: "smooth", block: "center" });
+ // Clear highlight after 4 seconds
+ if (justAddedTimer) clearTimeout(justAddedTimer);
+ justAddedTimer = setTimeout(() => {
+ justAddedModelId = null;
+ justAddedTimer = null;
+ }, 4000);
} catch (error) {
addModelError =
error instanceof Error ? error.message : "Failed to add model";
@@ -841,6 +917,8 @@
{group}
isExpanded={expandedGroups.has(group.id)}
isFavorite={favorites.has(group.id)}
+ isHighlighted={justAddedModelId !== null &&
+ group.variants.some((v) => v.id === justAddedModelId)}
{selectedModelId}
{canModelFit}
{getModelFitStatus}
@@ -910,6 +988,8 @@
{group}
isExpanded={expandedGroups.has(group.id)}
isFavorite={favorites.has(group.id)}
+ isHighlighted={justAddedModelId !== null &&
+ group.variants.some((v) => v.id === justAddedModelId)}
{selectedModelId}
{canModelFit}
{getModelFitStatus}
@@ -937,6 +1017,8 @@
{group}
isExpanded={expandedGroups.has(group.id)}
isFavorite={favorites.has(group.id)}
+ isHighlighted={justAddedModelId !== null &&
+ group.variants.some((v) => v.id === justAddedModelId)}
{selectedModelId}
{canModelFit}
{getModelFitStatus}
@@ -948,6 +1030,55 @@
{instanceStatuses}
/>
{/each}
+ <!-- Inline HuggingFace search results (shown when no local results match) -->
+ {#if filteredGroups.length === 0 && searchQuery.trim().length >= 2 && selectedFamily !== "huggingface" && selectedFamily !== "recents" && selectedFamily !== "favorites"}
+ {#if mainSearchHfLoading}
+ <div
+ class="flex items-center gap-2 px-3 py-2 border-t border-orange-400/20 bg-orange-950/20"
+ >
+ <span
+ class="w-4 h-4 border-2 border-orange-400 border-t-transparent rounded-full animate-spin"
+ ></span>
+ <span class="text-xs font-mono text-orange-400/60"
+ >Searching HuggingFace...</span
+ >
+ </div>
+ {:else if mainSearchHfResults.length > 0}
+ <div
+ class="sticky top-0 z-10 flex items-center gap-2 px-3 py-2 bg-orange-950/30 border-y border-orange-400/20 backdrop-blur-sm"
+ >
+ <span
+ class="text-xs font-mono text-orange-400 tracking-wider uppercase"
+ >From HuggingFace</span
+ >
+ </div>
+ {#each mainSearchHfResults as model}
+ <HuggingFaceResultItem
+ {model}
+ isAdded={existingModelIds.has(model.id)}
+ isAdding={addingModelId === model.id}
+ onAdd={() => handleAddModel(model.id)}
+ onSelect={() => handleSelectHfModel(model.id)}
+ downloadedOnNodes={downloadsData
+ ? getNodesWithModelDownloaded(downloadsData, model.id).map(
+ getNodeName,
+ )
+ : []}
+ />
+ {/each}
+ <button
+ type="button"
+ class="w-full px-3 py-2 text-xs font-mono text-orange-400/60 hover:text-orange-400 hover:bg-orange-500/10 transition-colors text-center"
+ onclick={() => {
+ hfSearchQuery = searchQuery;
+ searchHuggingFace(searchQuery);
+ selectedFamily = "huggingface";
+ }}
+ >
+ See all results on Hub
+ </button>
+ {/if}
+ {/if}
{/if}
</div>
</div>
diff --git a/src/exo/master/api.py b/src/exo/master/api.py
index 5da82662..db38c877 100644
--- a/src/exo/master/api.py
+++ b/src/exo/master/api.py
@@ -3,7 +3,7 @@ import contextlib
import json
import random
import time
-from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator
+from collections.abc import AsyncGenerator, Awaitable, Callable, Iterable
from datetime import datetime, timezone
from http import HTTPStatus
from pathlib import Path
@@ -775,7 +775,7 @@ class API:
return resolved_model
def stream_events(self) -> StreamingResponse:
- def _generate_json_array(events: Iterator[Event]) -> Iterator[str]:
+ def _generate_json_array(events: Iterable[Event]) -> Iterable[str]:
yield "["
first = True
for event in events:
@@ -1586,26 +1586,42 @@ class API:
async def search_models(
self, query: str = "", limit: int = 20
) -> list[HuggingFaceSearchResult]:
- """Search HuggingFace Hub for mlx-community models."""
- from huggingface_hub import list_models
-
- results = list_models(
- search=query or None,
- author="mlx-community",
- sort="downloads",
- limit=limit,
+ """Search HuggingFace Hub — tries mlx-community first, falls back to all of HuggingFace."""
+ from huggingface_hub import ModelInfo, list_models
+
+ def _to_results(models: Iterable[ModelInfo]) -> list[HuggingFaceSearchResult]:
+ return [
+ HuggingFaceSearchResult(
+ id=m.id,
+ author=m.author or "",
+ downloads=m.downloads or 0,
+ likes=m.likes or 0,
+ last_modified=str(m.last_modified or ""),
+ tags=list(m.tags or []),
+ )
+ for m in models
+ ]
+
+ # Search mlx-community first
+ mlx_results = _to_results(
+ list_models(
+ search=query or None,
+ author="mlx-community",
+ sort="downloads",
+ limit=limit,
+ )
)
- return [
- HuggingFaceSearchResult(
- id=m.id,
- author=m.author or "",
- downloads=m.downloads or 0,
- likes=m.likes or 0,
- last_modified=str(m.last_modified or ""),
- tags=list(m.tags or []),
+ if mlx_results:
+ return mlx_results
+
+ # Fall back to searching all of HuggingFace
+ return _to_results(
+ list_models(
+ search=query or None,
+ sort="downloads",
+ limit=limit,
)
- for m in results
- ]
+ )
async def run(self):
shutdown_ev = anyio.Event()
← afab3095 fix: `KVPrefixCache` Regression (#1668)
·
back to Exo
·
feat(mlx): add repetition_penalty and repetition_context_siz eee34327 →