mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-16 09:45:24 -04:00
Model picker: search + recent + favorites for large catalogs
Replace the flat dump of every model in the chat-input picker with a quick-switch. Opening the picker now shows a search box, an auto-tracked Recent list (last 5 picks), and a manual Favorites list instead of every available model crammed into a 280px dropdown. With large catalogs (e.g. OpenRouter's 350+ models) this was unusable as both a quick-switch and a browser. - Recent: each pick is recorded most-recent-first (capped at 5) under a new odysseus-model-recent key, so the next open has it one click away. - Favorites: an inline star on every row toggles favorite state and writes the existing odysseus-model-favorites key, so the sidebar Models section stays in sync. The star toggles only — it never picks the model. - Search filters a flat list across the whole catalog; favorited rows keep their filled star while filtered. - Small catalogs (<=12 models) still list everything in browse mode so tiny installs aren't forced to search for a model. - Touch friendly: stars are always visible (no hover-reveal) and tap targets grow on narrow screens. No changes to sidebar visibility defaults. Closes #399
This commit is contained in:
+139
-28
@@ -8,6 +8,54 @@ import { sortModelObjects } from './modelSort.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
// ── Recent + Favorites persistence ──
|
||||
// Recent is auto-tracked (last 5 picks, most-recent-first) and lives in its
|
||||
// own key. Favorites is the SAME key the sidebar Models section uses, so a
|
||||
// star toggled here shows up there and vice-versa.
|
||||
const RECENT_KEY = 'odysseus-model-recent';
|
||||
const FAVORITES_KEY = 'odysseus-model-favorites';
|
||||
const RECENT_MAX = 5;
|
||||
// Catalogs at or below this size are small enough that hiding everything
|
||||
// behind search would be a regression — keep listing them in browse mode.
|
||||
const BROWSE_ALL_LIMIT = 12;
|
||||
|
||||
function _loadList(key) {
|
||||
try {
|
||||
const a = JSON.parse(localStorage.getItem(key) || '[]');
|
||||
return Array.isArray(a) ? a : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
function _saveList(key, list) {
|
||||
try { localStorage.setItem(key, JSON.stringify(list)); } catch { /* quota / private mode */ }
|
||||
}
|
||||
function _loadRecent() { return _loadList(RECENT_KEY); }
|
||||
function _pushRecent(mid) {
|
||||
if (!mid) return;
|
||||
const next = _loadRecent().filter(x => x !== mid);
|
||||
next.unshift(mid);
|
||||
_saveList(RECENT_KEY, next.slice(0, RECENT_MAX));
|
||||
}
|
||||
function _loadFavorites() { return _loadList(FAVORITES_KEY); }
|
||||
function _toggleFavorite(mid) {
|
||||
const favs = _loadFavorites();
|
||||
const i = favs.indexOf(mid);
|
||||
if (i >= 0) favs.splice(i, 1);
|
||||
else favs.push(mid);
|
||||
_saveList(FAVORITES_KEY, favs);
|
||||
// Keep the sidebar Models section (same key) in sync if it's mounted.
|
||||
try {
|
||||
if (window.modelsModule && typeof window.modelsModule.refreshModels === 'function') {
|
||||
window.modelsModule.refreshModels();
|
||||
}
|
||||
} catch { /* sidebar not present */ }
|
||||
return i < 0; // true when now favorited
|
||||
}
|
||||
|
||||
// Filled star (favorited) + outline star (not) — CSS toggles which shows.
|
||||
const _STAR_SVG =
|
||||
'<svg class="mp-star-outline" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>'
|
||||
+ '<svg class="mp-star-filled" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>';
|
||||
|
||||
// ── Shared keyboard nav for model pickers ──
|
||||
function _handlePickerKeydown(e, listEl, itemSelector, closeFn) {
|
||||
if (e.key === 'Escape') { closeFn(); return; }
|
||||
@@ -163,7 +211,7 @@ function _initModelPickerDropdown() {
|
||||
function _populate(filter) {
|
||||
listEl.innerHTML = '';
|
||||
const all = _getAllModels();
|
||||
const q = (filter || '').toLowerCase();
|
||||
const q = (filter || '').trim().toLowerCase();
|
||||
const hasAnyModel = all.length > 0;
|
||||
listEl.classList.toggle('is-empty', !hasAnyModel);
|
||||
menu.classList.toggle('no-models', !hasAnyModel);
|
||||
@@ -171,22 +219,17 @@ function _initModelPickerDropdown() {
|
||||
search.placeholder = hasAnyModel ? 'Search models…' : 'No models connected';
|
||||
}
|
||||
if (searchRow) {
|
||||
searchRow.classList.toggle('searching', !!filter);
|
||||
searchRow.classList.toggle('searching', !!q);
|
||||
}
|
||||
|
||||
// Load favorites
|
||||
const favs = (function() { try { return JSON.parse(localStorage.getItem('odysseus-model-favorites') || '[]'); } catch { return []; } })();
|
||||
if (!hasAnyModel) return; // collapsed empty list — nothing to render
|
||||
|
||||
// Partition: favorites first, then rest
|
||||
const favModels = [];
|
||||
const restModels = [];
|
||||
all.forEach(m => {
|
||||
if (q && !m.mid.toLowerCase().includes(q) && !m.display.toLowerCase().includes(q)) return;
|
||||
if (favs.includes(m.mid)) favModels.push(m);
|
||||
else restModels.push(m);
|
||||
});
|
||||
sortModelObjects(favModels).forEach(function(m, i) { favModels[i] = m; });
|
||||
sortModelObjects(restModels).forEach(function(m, i) { restModels[i] = m; });
|
||||
// Unique lookup so Recent/Favorites (stored as bare model IDs) can be
|
||||
// resolved back to full model objects; drops anything no longer offered.
|
||||
const byId = new Map();
|
||||
all.forEach(m => { if (!byId.has(m.mid)) byId.set(m.mid, m); });
|
||||
|
||||
const favs = _loadFavorites();
|
||||
|
||||
function _addSection(label) {
|
||||
const el = document.createElement('div');
|
||||
@@ -194,6 +237,12 @@ function _initModelPickerDropdown() {
|
||||
el.textContent = label;
|
||||
listEl.appendChild(el);
|
||||
}
|
||||
function _addEmpty(text) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'model-switch-empty';
|
||||
empty.textContent = text;
|
||||
listEl.appendChild(empty);
|
||||
}
|
||||
function _addRow(m) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'model-switch-item';
|
||||
@@ -211,6 +260,7 @@ function _initModelPickerDropdown() {
|
||||
row.appendChild(logoSpan);
|
||||
}
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'mp-model-name';
|
||||
nameSpan.textContent = m.display;
|
||||
row.appendChild(nameSpan);
|
||||
if (m.stale) {
|
||||
@@ -226,27 +276,84 @@ function _initModelPickerDropdown() {
|
||||
const _epDisplay = m.epName && !m.display.toLowerCase().includes(m.epName.toLowerCase().split('/').pop()) ? m.epName : '';
|
||||
epSpan.textContent = _epDisplay;
|
||||
row.appendChild(epSpan);
|
||||
|
||||
// Inline favorite star — toggles favorite, never picks the model.
|
||||
const star = document.createElement('button');
|
||||
star.type = 'button';
|
||||
star.className = 'mp-fav-star' + (favs.includes(m.mid) ? ' active' : '');
|
||||
const _setStarState = (on) => {
|
||||
star.classList.toggle('active', on);
|
||||
star.title = on ? 'Remove from favorites' : 'Add to favorites';
|
||||
star.setAttribute('aria-label', on ? 'Remove from favorites' : 'Add to favorites');
|
||||
star.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
};
|
||||
star.innerHTML = _STAR_SVG;
|
||||
_setStarState(favs.includes(m.mid));
|
||||
star.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const nowFav = _toggleFavorite(m.mid);
|
||||
_setStarState(nowFav);
|
||||
// Keep our in-memory copy aligned so a follow-up re-render is correct.
|
||||
const idx = favs.indexOf(m.mid);
|
||||
if (nowFav && idx < 0) favs.push(m.mid);
|
||||
else if (!nowFav && idx >= 0) favs.splice(idx, 1);
|
||||
if (uiModule && uiModule.showToast) uiModule.showToast(nowFav ? 'Favorited' : 'Unfavorited');
|
||||
// In browse mode the Favorites section membership changed — rebuild
|
||||
// (cheap: Recent + Favorites). In search mode the row stays put, so
|
||||
// the in-place star update above is enough.
|
||||
if (!q) {
|
||||
const st = listEl.scrollTop;
|
||||
_populate('');
|
||||
listEl.scrollTop = st;
|
||||
}
|
||||
});
|
||||
row.appendChild(star);
|
||||
|
||||
row.addEventListener('click', () => _pick(m));
|
||||
listEl.appendChild(row);
|
||||
}
|
||||
|
||||
if (favModels.length > 0) {
|
||||
// ── Search mode: flat, filtered results across the whole catalog ──
|
||||
if (q) {
|
||||
const matches = all.filter(m =>
|
||||
m.mid.toLowerCase().includes(q) || m.display.toLowerCase().includes(q));
|
||||
if (matches.length === 0) _addEmpty('No matching models');
|
||||
else matches.forEach(_addRow);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Browse mode: Recent (auto) + Favorites (manual). No flat "All" dump. ──
|
||||
const shown = new Set();
|
||||
const recentModels = _loadRecent()
|
||||
.map(id => byId.get(id))
|
||||
.filter(Boolean)
|
||||
.slice(0, RECENT_MAX);
|
||||
const favModels = favs.map(id => byId.get(id)).filter(Boolean);
|
||||
|
||||
if (recentModels.length) {
|
||||
_addSection('Recent');
|
||||
recentModels.forEach(m => { shown.add(m.mid); _addRow(m); });
|
||||
}
|
||||
if (favModels.length) {
|
||||
_addSection('Favorites');
|
||||
favModels.forEach(_addRow);
|
||||
favModels.forEach(m => { shown.add(m.mid); _addRow(m); });
|
||||
}
|
||||
if (restModels.length > 0) {
|
||||
if (favModels.length > 0) _addSection('All models');
|
||||
restModels.forEach(_addRow);
|
||||
}
|
||||
if (listEl.children.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'model-switch-empty';
|
||||
if (hasAnyModel) {
|
||||
empty.textContent = 'No matching models';
|
||||
} else {
|
||||
return;
|
||||
|
||||
// Small catalogs: still list everything so users aren't forced to search.
|
||||
if (all.length <= BROWSE_ALL_LIMIT) {
|
||||
const rest = all.filter(m => !shown.has(m.mid));
|
||||
if (rest.length) {
|
||||
if (shown.size) _addSection('All models');
|
||||
rest.forEach(_addRow);
|
||||
}
|
||||
listEl.appendChild(empty);
|
||||
} else if (!recentModels.length && !favModels.length) {
|
||||
// Large catalog, nothing pinned yet — point them at the search box.
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'model-switch-empty mp-empty-hint';
|
||||
hint.innerHTML =
|
||||
'<span class="mp-empty-title">Search ' + all.length + ' models</span>'
|
||||
+ '<span class="mp-empty-sub">Picks land in Recent · tap ☆ to favorite</span>';
|
||||
listEl.appendChild(hint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +361,10 @@ function _initModelPickerDropdown() {
|
||||
const currentSessionId = _deps.getCurrentSessionId();
|
||||
const _pendingChat = _deps.getPendingChat();
|
||||
|
||||
// Remember this pick so it surfaces under "Recent" next time the picker
|
||||
// opens — the whole point of quick-switch.
|
||||
if (m && m.mid) _pushRecent(m.mid);
|
||||
|
||||
// Broadcast immediately so listeners (e.g. the tour) can advance without
|
||||
// waiting for the async session-create/PATCH that follows.
|
||||
try { document.dispatchEvent(new CustomEvent('odysseus:model-picked', { detail: m })); } catch {}
|
||||
|
||||
Reference in New Issue
Block a user