Merge branch 'pr-696' into visual-pr-playground

This commit is contained in:
pewdiepie-archdaemon
2026-06-02 06:26:31 +09:00
2 changed files with 225 additions and 28 deletions

View File

@@ -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 {}

View File

@@ -2711,6 +2711,92 @@ body.bg-pattern-sparkles {
opacity: 0.4;
padding: 6px 8px 2px;
}
.model-picker-list .mp-section-label:first-child {
padding-top: 2px;
}
/* Model name takes the slack so the endpoint label + star sit on the right. */
.model-picker-list .model-switch-item .mp-model-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-picker-list .model-switch-item .model-switch-ep {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.9em;
opacity: 0.45;
}
/* Keyboard navigation highlight (Arrow keys in the search box). */
.model-picker-list .model-switch-item.kb-active {
background: color-mix(in srgb, var(--red) 14%, transparent);
}
/* Inline favorite star — always visible (works on touch), filled when on. */
.model-picker-list .mp-fav-star {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin: -5px -4px -5px 2px;
padding: 0;
border: none;
background: none;
cursor: pointer;
color: color-mix(in srgb, var(--fg) 26%, transparent);
transition: color 0.15s ease, transform 0.12s ease;
-webkit-tap-highlight-color: transparent;
}
.model-picker-list .mp-fav-star:hover {
color: var(--fg);
transform: scale(1.18);
}
.model-picker-list .mp-fav-star:focus-visible {
outline: none;
color: var(--fg);
}
.model-picker-list .mp-fav-star.active {
color: var(--red);
}
.model-picker-list .mp-fav-star.active:hover {
color: var(--red);
opacity: 0.7;
}
.model-picker-list .mp-fav-star .mp-star-filled { display: none; }
.model-picker-list .mp-fav-star.active .mp-star-filled { display: inline-flex; }
.model-picker-list .mp-fav-star.active .mp-star-outline { display: none; }
/* First-run hint when a large catalog has no Recent/Favorites yet. */
.model-picker-list .mp-empty-hint {
flex-direction: column;
gap: 2px;
padding: 14px 8px;
text-align: center;
}
.model-picker-list .mp-empty-hint .mp-empty-title {
font-size: 1.05em;
color: color-mix(in srgb, var(--fg) 70%, transparent);
}
.model-picker-list .mp-empty-hint .mp-empty-sub {
font-size: 0.92em;
opacity: 0.7;
}
/* Comfortable touch targets on phones / narrow screens. */
@media (hover: none) and (pointer: coarse), (max-width: 768px) {
.model-picker-list .model-switch-item {
padding-top: 8px;
padding-bottom: 8px;
}
.model-picker-list .mp-fav-star {
width: 30px;
height: 30px;
margin: -7px -4px -7px 2px;
}
}
/* Overflow "+" menu */
.overflow-wrapper {
position: relative;