mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-05 12:42:20 +01:00
Squash Odysseus development history
This commit is contained in:
+310
-63
@@ -4,11 +4,16 @@
|
||||
// command building, preset slots, launch logic
|
||||
// ============================================
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import uiModule from './ui.js?v=20260908weekhoverfix1';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { modelColor } from './chatRenderer.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
import { modelColor } from './chatRenderer.js?v=20260910streamlinks2';
|
||||
import {
|
||||
bindMenuDismiss,
|
||||
dismissOrRemove,
|
||||
bindExpandedCardDismiss,
|
||||
unbindExpandedCardDismiss,
|
||||
} from './escMenuStack.js';
|
||||
import { openCookbookDependencies } from './cookbook-diagnosis.js';
|
||||
import { _hwfitCache } from './cookbook-hwfit.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
@@ -41,6 +46,21 @@ let _launchServeTask;
|
||||
let _retryDownload;
|
||||
let _nextAvailablePort;
|
||||
|
||||
function _fetchCookbookWithTimeout(input, init = {}, timeoutMs = 20000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const parentSignal = init.signal;
|
||||
const abortFromParent = () => controller.abort();
|
||||
if (parentSignal) {
|
||||
if (parentSignal.aborted) controller.abort();
|
||||
else parentSignal.addEventListener('abort', abortFromParent, { once: true });
|
||||
}
|
||||
return fetch(input, { ...init, signal: controller.signal }).finally(() => {
|
||||
clearTimeout(timer);
|
||||
parentSignal?.removeEventListener('abort', abortFromParent);
|
||||
});
|
||||
}
|
||||
|
||||
// Storage keys
|
||||
const SERVE_STATE_KEY = 'cookbook-serve-state';
|
||||
const SERVE_FAVORITES_KEY = 'cookbook-serve-favorite-models';
|
||||
@@ -48,6 +68,15 @@ const SERVE_FAVORITES_KEY = 'cookbook-serve-favorite-models';
|
||||
let _cachedAllModels = [];
|
||||
const _CACHED_MODELS_SCAN_KEY = 'cookbook_cached_models_scan_v3_ltx_video';
|
||||
const _CACHED_MODELS_SCAN_TTL = 6 * 3600 * 1000;
|
||||
let _cachedModelsFetchId = 0;
|
||||
let _cachedModelsRequestController = null;
|
||||
|
||||
function _syncServeStats() {
|
||||
const stats = document.getElementById('serve-stats');
|
||||
if (!stats) return;
|
||||
const count = _cachedAllModels.length;
|
||||
stats.textContent = `${count} model${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
function _normalizeCookbookModelDir(dir) {
|
||||
const d = String(dir || '').replaceAll('✕', '').replaceAll('✖', '').trim();
|
||||
@@ -833,7 +862,7 @@ async function _fetchServeRuntimePackage(panel, backend) {
|
||||
if (target.venv) params.set('venv', target.venv);
|
||||
}
|
||||
if (repo) params.set('model_hint', repo);
|
||||
const res = await fetch('/api/cookbook/packages' + (params.toString() ? '?' + params.toString() : ''), { credentials: 'same-origin' });
|
||||
const res = await _fetchCookbookWithTimeout('/api/cookbook/packages' + (params.toString() ? '?' + params.toString() : ''), { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const pkg = (data.packages || []).find(p => p.name === packageName);
|
||||
@@ -860,7 +889,7 @@ function _filterCachedList() {
|
||||
const list = document.getElementById('hwfit-cached-list');
|
||||
const tagContainer = document.getElementById('serve-tags');
|
||||
if (!list) return;
|
||||
const activeTag = tagContainer?.querySelector('.memory-cat-chip.active')?.dataset.serveTag || '';
|
||||
const activeTag = tagContainer?.querySelector('[data-serve-tag].active')?.dataset.serveTag || '';
|
||||
const searchVal = (document.getElementById('serve-search')?.value || '').toLowerCase().trim();
|
||||
const isFamily = activeTag.startsWith('fam:');
|
||||
const familyVal = isFamily ? activeTag.slice(4) : '';
|
||||
@@ -1105,7 +1134,7 @@ function _rerenderCachedModels() {
|
||||
const allModels = _cachedAllModels;
|
||||
const _h = (text) => `<span class="hwfit-hint" title="${text}">?</span>`;
|
||||
|
||||
const activeTag = tagContainer?.querySelector('.memory-cat-chip.active')?.dataset.serveTag || '';
|
||||
const activeTag = tagContainer?.querySelector('[data-serve-tag].active')?.dataset.serveTag || '';
|
||||
const searchVal = (document.getElementById('serve-search')?.value || '').toLowerCase().trim();
|
||||
|
||||
const sortVal = document.getElementById('serve-sort')?.value || 'name';
|
||||
@@ -1148,21 +1177,24 @@ function _rerenderCachedModels() {
|
||||
html += `<div class="doclib-card memory-item${_isFavorite ? ' memory-pinned cookbook-serve-favorite-model' : ''}" data-repo="${esc(m.repo_id)}" data-tag="${m._tag || ''}" data-family="${m._family || ''}" style="cursor:pointer;">`;
|
||||
html += `<span class="serve-select-cb memory-select-dot" style="display:${isSelectMode ? 'inline-block' : 'none'};cursor:pointer;"></span>`;
|
||||
html += `<div style="flex:1;min-width:0;">`;
|
||||
const _mc = modelColor(m.repo_id) || '';
|
||||
// Keep a provider family visually consistent across its variants. Unknown
|
||||
// local models retain their own stable color so they remain distinguishable.
|
||||
const _mc = modelColor(m._family || m.repo_id) || '';
|
||||
const _runningPill = _isActivelyServing(m.repo_id)
|
||||
? ` <span class="cookbook-serve-running-pill is-clickable" title="This model is currently being served — click to open in Running" data-repo="${esc(m.repo_id)}" role="button" tabindex="0">running</span>`
|
||||
: '';
|
||||
const _downloadingPill = _isDownloading
|
||||
? ` <span class="cookbook-serve-downloading-pill${_isDlActive ? '' : ' is-stalled'}" title="${_isDlActive ? 'Download in progress' : 'Download stalled — retry to resume'}">${_isDlActive ? 'downloading' : 'stalled'}</span>`
|
||||
: '';
|
||||
const _favoritePill = _isFavorite ? ' <span class="memory-cat-badge memory-cat-pinned cookbook-serve-fav-badge">pinned</span>' : '';
|
||||
html += `<div class="memory-item-title cookbook-serve-title"${_mc ? ` style="color:${_mc}"` : ''}><span class="cookbook-serve-title-name">${modelLogo(m.repo_id)}${esc(shortName)}</span>${_favoritePill}${hfLink ? ` <a href="${esc(hfLink)}" target="_blank" rel="noopener" class="cookbook-hf-link">HF ↗</a>` : ''}${_runningPill}${_downloadingPill}</div>`;
|
||||
const _favoritePill = _isFavorite ? ' <span class="memory-cat-badge memory-cat-pinned cookbook-serve-fav-badge"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg></span>' : '';
|
||||
html += `<div class="memory-item-title cookbook-serve-title"${_mc ? ` style="color:${_mc}"` : ''}><span class="cookbook-serve-title-name">${modelLogo(m.repo_id)}${esc(shortName)}</span>${hfLink ? ` <a href="${esc(hfLink)}" target="_blank" rel="noopener" class="cookbook-hf-link">HF ↗</a>` : ''}${_favoritePill}${_runningPill}${_downloadingPill}</div>`;
|
||||
html += `<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">${metaParts.join(' \u00b7 ')}</div>`;
|
||||
html += `</div>`;
|
||||
const _bk = _detectBackend(m).backend;
|
||||
const _bkIco = _bk === 'llamacpp' ? '<svg viewBox="0 0 24 24" width="18" height="18"><path d="M7 3C5.5 5 5 8 5 11v7c0 1.5 1 3 3 3h1v-4h6v4h1c2 0 3-1.5 3-3v-7c0-3-.5-6-2-8l-1 3c-.5-2-1.5-4-3-5-.5 2-1 3-1.5 3S11 3.5 10.5 2L7 3z" fill="currentColor"/><circle cx="9" cy="11" r="1.5" fill="var(--bg,#1a1a2e)"/><circle cx="15" cy="11" r="1.5" fill="var(--bg,#1a1a2e)"/></svg>'
|
||||
: _bk === 'diffusers' ? '<svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm0 3c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2zM6 9c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2zm0 6c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2zm6 4c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm4-8c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z" fill="currentColor"/></svg>'
|
||||
: '<svg viewBox="0 0 24 24" width="18" height="18"><path d="M4 4l8 16 8-16h-4l-4 8-4-8z" fill="currentColor"/></svg>';
|
||||
html += `<span class="doclib-card-chevron" aria-hidden="true"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></span>`;
|
||||
html += `<span class="cookbook-card-backend" data-detected="${_bk}">${_bkIco}</span>`;
|
||||
html += `<div class="memory-item-actions"><button type="button" class="memory-item-btn hwfit-cached-menu-btn" title="Actions" aria-label="Model actions"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="12" cy="19" r="2"/></svg></button></div>`;
|
||||
html += `</div>`;
|
||||
@@ -1172,9 +1204,9 @@ function _rerenderCachedModels() {
|
||||
|
||||
// Wire tag chips
|
||||
if (tagContainer) {
|
||||
tagContainer.querySelectorAll('.memory-cat-chip').forEach(chip => {
|
||||
tagContainer.querySelectorAll('[data-serve-tag]').forEach(chip => {
|
||||
chip.addEventListener('click', () => {
|
||||
tagContainer.querySelectorAll('.memory-cat-chip').forEach(c => c.classList.remove('active'));
|
||||
tagContainer.querySelectorAll('[data-serve-tag]').forEach(c => c.classList.remove('active'));
|
||||
chip.classList.add('active');
|
||||
_filterCachedList();
|
||||
});
|
||||
@@ -1187,13 +1219,21 @@ function _rerenderCachedModels() {
|
||||
const menuBtn = item.querySelector('.hwfit-cached-menu-btn');
|
||||
if (!menuBtn || item.dataset.lpWired === '1') return;
|
||||
item.dataset.lpWired = '1';
|
||||
let _t = null;
|
||||
let _y = 0;
|
||||
const _cancel = () => { if (_t) { clearTimeout(_t); _t = null; } };
|
||||
const _cancel = () => {
|
||||
if (item._longPressTimer) {
|
||||
clearTimeout(item._longPressTimer);
|
||||
item._longPressTimer = null;
|
||||
}
|
||||
};
|
||||
item.addEventListener('touchstart', (e) => {
|
||||
if (e.target.closest('button, a, input, textarea, .hwfit-cached-dropdown')) return;
|
||||
_y = e.touches?.[0]?.clientY ?? 0;
|
||||
_t = setTimeout(() => { _t = null; try { menuBtn.click(); } catch {} }, 500);
|
||||
_cancel();
|
||||
item._longPressTimer = setTimeout(() => {
|
||||
item._longPressTimer = null;
|
||||
try { menuBtn.click(); } catch {}
|
||||
}, 500);
|
||||
}, { passive: true });
|
||||
item.addEventListener('touchmove', (e) => {
|
||||
const y = e.touches?.[0]?.clientY ?? 0;
|
||||
@@ -1357,9 +1397,11 @@ function _rerenderCachedModels() {
|
||||
|
||||
// Toggle — close if already open
|
||||
if (item.classList.contains('doclib-card-expanded')) {
|
||||
unbindExpandedCardDismiss(item);
|
||||
const existingPanel = item.querySelector('.hwfit-serve-panel');
|
||||
existingPanel?._cleanupRuntimeReadiness?.();
|
||||
existingPanel?._cleanupServePanel?.();
|
||||
existingPanel?.remove();
|
||||
item.querySelector('.doclib-card-collapse-chevron')?.remove();
|
||||
item.classList.remove('doclib-card-expanded');
|
||||
item.style.flexDirection = '';
|
||||
item.style.alignItems = '';
|
||||
@@ -1371,9 +1413,11 @@ function _rerenderCachedModels() {
|
||||
|
||||
// Collapse any other expanded
|
||||
list.querySelectorAll('.doclib-card-expanded').forEach(c => {
|
||||
unbindExpandedCardDismiss(c);
|
||||
const openPanel = c.querySelector('.hwfit-serve-panel');
|
||||
openPanel?._cleanupRuntimeReadiness?.();
|
||||
openPanel?._cleanupServePanel?.();
|
||||
openPanel?.remove();
|
||||
c.querySelector('.doclib-card-collapse-chevron')?.remove();
|
||||
c.classList.remove('doclib-card-expanded');
|
||||
c.style.flexDirection = '';
|
||||
c.style.alignItems = '';
|
||||
@@ -1454,10 +1498,9 @@ function _rerenderCachedModels() {
|
||||
const _ggufOptions = _ggufChoices.map(f =>
|
||||
`<option value="${esc(f.rel_path)}"${f.rel_path === _defaultGguf ? ' selected' : ''}>${esc(_ggufFileLabel(f))}</option>`
|
||||
).join('');
|
||||
const _minimaxM3Snapshot = '/home/pewds/.cache/huggingface/hub/models--cyankiwi--MiniMax-M3-AWQ-INT4/snapshots/4082acbbec1236d21828d55b6bb0fe02ade4ab5b';
|
||||
const _defaultServeModel = _isMiniMaxM3 ? _minimaxM3Snapshot : (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
|
||||
const _defaultServeModel = (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
|
||||
const _savedModelPath = String(svm('model_path', _defaultServeModel) || '').trim();
|
||||
const _modelPathValue = _isMiniMaxM3 && (!_savedModelPath || _savedModelPath === repo) ? _minimaxM3Snapshot : _savedModelPath;
|
||||
const _modelPathValue = _savedModelPath || _defaultServeModel;
|
||||
const _defaultServedModelName = _isMiniMaxM3 ? repo : '';
|
||||
// Build save slots
|
||||
const _allPresets = _loadPresets();
|
||||
@@ -1815,7 +1858,7 @@ function _rerenderCachedModels() {
|
||||
// creates a ScheduledTask (action=cookbook_serve), so the schedule
|
||||
// ends up in the existing Tasks UI for edit/delete/pause.
|
||||
panelHtml += `<span class="hwfit-serve-launch-group">`;
|
||||
panelHtml += `<button class="cookbook-btn hwfit-serve-launch"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;flex-shrink:0;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Launch</button>`;
|
||||
panelHtml += `<button class="cookbook-btn hwfit-serve-launch"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;flex-shrink:0;"><path d="M12 22c4.4 0 8-3.1 8-7.2 0-3.8-2.5-6.8-5.3-9.8.1 2.2-.5 3.8-1.8 5.2.1-3.5-1.2-6.1-3-8.2.1 4-5.9 7.4-5.9 12.8C4 18.9 7.6 22 12 22Z"></path></svg>Launch</button>`;
|
||||
// Chevron points DOWN because the schedule form opens beneath the
|
||||
// panel — the arrow signals the direction of motion, not menu state.
|
||||
panelHtml += `<button class="cookbook-btn hwfit-serve-schedule-arrow" type="button" aria-haspopup="menu" aria-label="More launch actions" title="More launch actions"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>`;
|
||||
@@ -1824,8 +1867,23 @@ function _rerenderCachedModels() {
|
||||
panelHtml += `</div>`;
|
||||
|
||||
item.classList.add('doclib-card-expanded');
|
||||
bindExpandedCardDismiss(item, () => {
|
||||
unbindExpandedCardDismiss(item);
|
||||
const openPanel = item.querySelector('.hwfit-serve-panel');
|
||||
openPanel?._cleanupServePanel?.();
|
||||
openPanel?.remove();
|
||||
item.querySelector('.doclib-card-collapse-chevron')?.remove();
|
||||
item.classList.remove('doclib-card-expanded');
|
||||
item.style.flexDirection = '';
|
||||
item.style.alignItems = '';
|
||||
item.style.maxHeight = '';
|
||||
list.style.minHeight = '';
|
||||
list.style.maxHeight = '';
|
||||
});
|
||||
item.style.flexDirection = 'column';
|
||||
item.style.alignItems = 'stretch';
|
||||
item.querySelector('.doclib-card-collapse-chevron')?.remove();
|
||||
item.querySelector('.cookbook-serve-title')?.insertAdjacentHTML('beforeend', '<button type="button" class="doclib-card-collapse-chevron" aria-label="Collapse launch configuration" title="Collapse launch configuration"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 15 12 9 18 15"/></svg></button>');
|
||||
item.insertAdjacentHTML('beforeend', panelHtml);
|
||||
const panel = item.querySelector('.hwfit-serve-panel');
|
||||
// Scroll the serve panel into view within its nearest scrollable ancestor
|
||||
@@ -1990,7 +2048,7 @@ function _rerenderCachedModels() {
|
||||
const _sp = (_serverByVal?.(target.serverKey || host) || (_es.servers || []).find(s => s.host === host) || {}).port;
|
||||
if (_sp) params.set('ssh_port', _sp);
|
||||
}
|
||||
const res = await fetch(`/api/hwfit/profiles?${params}`);
|
||||
const res = await _fetchCookbookWithTimeout(`/api/hwfit/profiles?${params}`, {}, 30000);
|
||||
const data = await res.json();
|
||||
const ctxMax = Number(data && data.model_ctx_max) || 0;
|
||||
const weightsGb = Number(data && data.model_weights_gb) || 0;
|
||||
@@ -2091,9 +2149,12 @@ function _rerenderCachedModels() {
|
||||
// RAM-spillover, with a plain-language health/speed hint. Lets you tell at
|
||||
// a glance whether the chosen config fits VRAM (fast) or is paging into
|
||||
// system RAM over PCIe (slow). AMD sysfs reports gtt_used_mb for spillover.
|
||||
let _vramMonitorInFlight = false;
|
||||
async function _refreshVramMonitor() {
|
||||
const el = panel.querySelector('.hwfit-vram-readout');
|
||||
if (!el || !document.body.contains(el)) return false; // panel closed → stop
|
||||
if (_vramMonitorInFlight) return true;
|
||||
_vramMonitorInFlight = true;
|
||||
try {
|
||||
const host = (_es.remoteHost || '').trim();
|
||||
const params = new URLSearchParams();
|
||||
@@ -2102,7 +2163,8 @@ function _rerenderCachedModels() {
|
||||
const _sp = (_es.servers || []).find(s => s.host === host)?.port;
|
||||
if (_sp) params.set('ssh_port', _sp);
|
||||
}
|
||||
const res = await fetch('/api/cookbook/gpus' + (params.toString() ? '?' + params : ''));
|
||||
const res = await _fetchCookbookWithTimeout('/api/cookbook/gpus' + (params.toString() ? '?' + params : ''), {}, 10000);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const gpus = Array.isArray(data) ? data : (data.gpus || []);
|
||||
if (!gpus.length) { el.textContent = 'no GPU detected'; el.style.color = ''; return true; }
|
||||
@@ -2131,6 +2193,8 @@ function _rerenderCachedModels() {
|
||||
el.textContent = 'unavailable';
|
||||
el.style.color = '';
|
||||
return true;
|
||||
} finally {
|
||||
_vramMonitorInFlight = false;
|
||||
}
|
||||
}
|
||||
_refreshVramMonitor();
|
||||
@@ -2163,6 +2227,7 @@ function _rerenderCachedModels() {
|
||||
const _backendMenu = panel.querySelector('[data-backend-menu]');
|
||||
const _backendBtnLabel = panel.querySelector('[data-backend-label]');
|
||||
const _backendBtnIconSlot = _backendBtn?.querySelector('[data-backend-icon-slot]');
|
||||
let _cleanupBackendDismiss = () => {};
|
||||
|
||||
function _setBackendBtnState(v) {
|
||||
if (!_backendBtn) return;
|
||||
@@ -2216,15 +2281,21 @@ function _rerenderCachedModels() {
|
||||
if (_backendMenu.hidden) _openBackendMenu();
|
||||
else _closeBackendMenu();
|
||||
});
|
||||
document.addEventListener('click', (ev) => {
|
||||
const _onBackendOutsideClick = (ev) => {
|
||||
if (!_backendMenu.hidden && !_backendPicker?.contains(ev.target)) _closeBackendMenu();
|
||||
});
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
};
|
||||
const _onBackendEscape = (ev) => {
|
||||
if (ev.key === 'Escape' && !_backendMenu.hidden) {
|
||||
ev.stopPropagation();
|
||||
_closeBackendMenu();
|
||||
}
|
||||
}, { capture: true });
|
||||
};
|
||||
document.addEventListener('click', _onBackendOutsideClick);
|
||||
document.addEventListener('keydown', _onBackendEscape, { capture: true });
|
||||
_cleanupBackendDismiss = () => {
|
||||
document.removeEventListener('click', _onBackendOutsideClick);
|
||||
document.removeEventListener('keydown', _onBackendEscape, { capture: true });
|
||||
};
|
||||
}
|
||||
_renderBackendMenu();
|
||||
_setBackendBtnState(_backendSource?.value || defaultBackend);
|
||||
@@ -2556,7 +2627,7 @@ function _rerenderCachedModels() {
|
||||
if (p.favorite) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'memory-cat-badge memory-cat-pinned cookbook-saved-fav-badge';
|
||||
badge.textContent = 'pinned';
|
||||
badge.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>';
|
||||
it.appendChild(badge);
|
||||
}
|
||||
if (p.confirmedWorking) {
|
||||
@@ -2823,10 +2894,23 @@ function _rerenderCachedModels() {
|
||||
panel._gpuProbe.popup.remove();
|
||||
panel._gpuProbe.popup = null;
|
||||
}
|
||||
// The outside-click listener is installed on the next tick so the
|
||||
// opening click cannot immediately dismiss the popup. Keep its
|
||||
// cleanup with the popup; otherwise every open/close cycle leaves a
|
||||
// document listener behind.
|
||||
if (panel._gpuProbe.outsideAttachTimer) {
|
||||
clearTimeout(panel._gpuProbe.outsideAttachTimer);
|
||||
panel._gpuProbe.outsideAttachTimer = null;
|
||||
}
|
||||
if (panel._gpuProbe.outsideHandler) {
|
||||
document.removeEventListener('mousedown', panel._gpuProbe.outsideHandler, true);
|
||||
panel._gpuProbe.outsideHandler = null;
|
||||
}
|
||||
};
|
||||
panel._closeProbePopup = _closeProbePopup;
|
||||
|
||||
const _doKill = async (pid, sig, hostVal) => {
|
||||
const res = await fetch('/api/cookbook/kill-pid', {
|
||||
const res = await _fetchCookbookWithTimeout('/api/cookbook/kill-pid', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pid, signal: sig, host: hostVal || null }),
|
||||
@@ -2915,13 +2999,14 @@ function _rerenderCachedModels() {
|
||||
});
|
||||
|
||||
// Click outside closes the popup
|
||||
setTimeout(() => {
|
||||
panel._gpuProbe.outsideAttachTimer = setTimeout(() => {
|
||||
panel._gpuProbe.outsideAttachTimer = null;
|
||||
const outside = (ev) => {
|
||||
if (!popup.contains(ev.target) && ev.target !== anchorBtn) {
|
||||
_closeProbePopup();
|
||||
document.removeEventListener('mousedown', outside, true);
|
||||
}
|
||||
};
|
||||
panel._gpuProbe.outsideHandler = outside;
|
||||
document.addEventListener('mousedown', outside, true);
|
||||
}, 0);
|
||||
};
|
||||
@@ -2933,7 +3018,7 @@ function _rerenderCachedModels() {
|
||||
const params = new URLSearchParams();
|
||||
if (remoteHost) params.set('host', remoteHost);
|
||||
const url = '/api/cookbook/gpus' + (params.toString() ? '?' + params.toString() : '');
|
||||
const res = await fetch(url, { credentials: 'same-origin' });
|
||||
const res = await _fetchCookbookWithTimeout(url, { credentials: 'same-origin' }, 10000);
|
||||
let data;
|
||||
try { data = await res.json(); } catch (_) { data = {}; }
|
||||
if (!res.ok) {
|
||||
@@ -3049,7 +3134,7 @@ function _rerenderCachedModels() {
|
||||
// First pass: SIGTERM
|
||||
const hostVal = panel._gpuProbe.host;
|
||||
const results = await Promise.all(pids.map(p =>
|
||||
fetch('/api/cookbook/kill-pid', {
|
||||
_fetchCookbookWithTimeout('/api/cookbook/kill-pid', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pid: p.pid, signal: 'TERM', host: hostVal || null }),
|
||||
@@ -3073,7 +3158,7 @@ function _rerenderCachedModels() {
|
||||
}
|
||||
if (!await window.styledConfirm(`${survivors.length} process(es) survived SIGTERM:\n\n${survivors.map(p => p.pid + ' (' + p.name + ')').join(', ')}\n\nForce-kill with SIGKILL?`, { confirmText: 'SIGKILL', danger: true })) return;
|
||||
const killResults = await Promise.all(survivors.map(p =>
|
||||
fetch('/api/cookbook/kill-pid', {
|
||||
_fetchCookbookWithTimeout('/api/cookbook/kill-pid', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pid: p.pid, signal: 'KILL', host: hostVal || null }),
|
||||
@@ -3245,38 +3330,56 @@ function _rerenderCachedModels() {
|
||||
// tapping the row to toggle it shut). Mobile users wanted an explicit
|
||||
// "back out" affordance next to Launch.
|
||||
const _collapsePanel = () => {
|
||||
panel._cleanupRuntimeReadiness?.();
|
||||
panel.remove();
|
||||
item.classList.remove('doclib-card-expanded');
|
||||
item.style.flexDirection = '';
|
||||
item.style.alignItems = '';
|
||||
if (list) { list.style.minHeight = ''; list.style.maxHeight = ''; }
|
||||
try {
|
||||
panel._cleanupServePanel?.();
|
||||
} catch (err) {
|
||||
console.warn('Serve panel cleanup failed:', err);
|
||||
} finally {
|
||||
panel.remove();
|
||||
item.querySelector('.doclib-card-collapse-chevron')?.remove();
|
||||
item.classList.remove('doclib-card-expanded');
|
||||
item.style.flexDirection = '';
|
||||
item.style.alignItems = '';
|
||||
item.style.maxHeight = '';
|
||||
if (list) { list.style.minHeight = ''; list.style.maxHeight = ''; }
|
||||
}
|
||||
};
|
||||
panel.querySelector('.hwfit-serve-cancel')?.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
_collapsePanel();
|
||||
});
|
||||
// Esc anywhere on the page closes the open serve panel. Skips when
|
||||
// the user is typing in a field — they want Esc to deselect / blur
|
||||
// those, not collapse the form they're configuring.
|
||||
item.querySelector('.doclib-card-collapse-chevron')?.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
_collapsePanel();
|
||||
});
|
||||
// Esc anywhere on the page closes the open serve panel. This mirrors
|
||||
// the global Cookbook handler and keeps the launch expansion easy to
|
||||
// dismiss even when a form control currently has focus.
|
||||
const _onEscClose = (ev) => {
|
||||
if (ev.key !== 'Escape') return;
|
||||
if (!panel.isConnected) {
|
||||
document.removeEventListener('keydown', _onEscClose, true);
|
||||
return;
|
||||
}
|
||||
const t = ev.target;
|
||||
const inField = t && (
|
||||
t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable
|
||||
);
|
||||
if (inField) return;
|
||||
// Skip when one of the dropdown/menu popovers is open — the
|
||||
// popovers handle their own Esc and use stopPropagation, so any
|
||||
// Esc that bubbles here means nothing else claimed it.
|
||||
ev.stopPropagation();
|
||||
_collapsePanel();
|
||||
};
|
||||
// Listen on the panel itself as well as the document. The panel-level
|
||||
// capture listener runs before controls or nested menus can consume
|
||||
// Escape, so the expanded launch card always has a reliable close path.
|
||||
panel.addEventListener('keydown', _onEscClose, true);
|
||||
document.addEventListener('keydown', _onEscClose, true);
|
||||
panel._cleanupServePanel = () => {
|
||||
clearInterval(_vramTimer);
|
||||
panel._closeProbePopup?.();
|
||||
_cleanupBackendDismiss?.();
|
||||
panel.removeEventListener('keydown', _onEscClose, true);
|
||||
document.removeEventListener('keydown', _onEscClose, true);
|
||||
panel._cleanupRuntimeReadiness?.();
|
||||
};
|
||||
|
||||
// Launch button
|
||||
panel.querySelector('.hwfit-serve-launch').addEventListener('click', async (ev) => {
|
||||
@@ -3395,7 +3498,7 @@ function _rerenderCachedModels() {
|
||||
if (_btn) {
|
||||
_btn.click();
|
||||
} else if (_runningMod._tmuxGracefulKill) {
|
||||
await fetch('/api/shell/exec', {
|
||||
await _fetchCookbookWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _runningMod._tmuxGracefulKill(t) }),
|
||||
@@ -3455,7 +3558,7 @@ function _rerenderCachedModels() {
|
||||
const _sp = (_serverByVal?.(launchTarget.serverKey || _gh) || {}).port;
|
||||
if (_sp) _gp.set('ssh_port', _sp);
|
||||
}
|
||||
const _gr = await fetch('/api/cookbook/gpus' + (_gp.toString() ? '?' + _gp : ''), { credentials: 'same-origin' });
|
||||
const _gr = await _fetchCookbookWithTimeout('/api/cookbook/gpus' + (_gp.toString() ? '?' + _gp : ''), { credentials: 'same-origin' }, 10000);
|
||||
if (_gr.ok) {
|
||||
const _gd = await _gr.json();
|
||||
_hwGpus = Array.isArray(_gd) ? _gd : (_gd.gpus || []);
|
||||
@@ -3567,7 +3670,7 @@ function _rerenderCachedModels() {
|
||||
_probeParams.set('host', _probeHost);
|
||||
if (launchTarget.port) _probeParams.set('ssh_port', launchTarget.port);
|
||||
}
|
||||
const _probeRes = await fetch('/api/cookbook/gpus' + (_probeParams.toString() ? '?' + _probeParams : ''), { credentials: 'same-origin' });
|
||||
const _probeRes = await _fetchCookbookWithTimeout('/api/cookbook/gpus' + (_probeParams.toString() ? '?' + _probeParams : ''), { credentials: 'same-origin' }, 10000);
|
||||
const _probeData = await _probeRes.json();
|
||||
const _probeGpus = Array.isArray(_probeData) ? _probeData : (_probeData.gpus || []);
|
||||
if (!_probeGpus.length) {
|
||||
@@ -3603,7 +3706,7 @@ function _rerenderCachedModels() {
|
||||
const _cmd = _portHost
|
||||
? `ssh -o ConnectTimeout=4 -o StrictHostKeyChecking=no ${_sshPrefix(launchTarget.port)}${_portHost} ${JSON.stringify(_checkInner)}`
|
||||
: _checkInner;
|
||||
const _res = await fetch('/api/shell/exec', {
|
||||
const _res = await _fetchCookbookWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _cmd }),
|
||||
@@ -3863,7 +3966,7 @@ async function _deleteCachedModel(repo, itemEl, skipConfirm = false, model = nul
|
||||
itemEl.appendChild(ov);
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/shell/exec', {
|
||||
const res = await _fetchCookbookWithTimeout('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
@@ -3891,6 +3994,7 @@ async function _deleteCachedModel(repo, itemEl, skipConfirm = false, model = nul
|
||||
if (itemEl.parentElement) itemEl.remove();
|
||||
// Drop from the in-memory list so a re-render/filter doesn't resurrect it.
|
||||
_cachedAllModels = _cachedAllModels.filter(x => x.repo_id !== repo);
|
||||
_syncServeStats();
|
||||
}
|
||||
} catch (e) {
|
||||
uiModule.showError('Delete failed: ' + (e && e.message ? e.message : e));
|
||||
@@ -4028,9 +4132,29 @@ export async function openServePanelForRepo(repo, fields) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function _playCachedModelDomino(list) {
|
||||
if (!list || !list.querySelector('.doclib-card')) return;
|
||||
list.classList.remove('cookbook-serve-models-just-loaded');
|
||||
void list.offsetWidth;
|
||||
list.classList.add('cookbook-serve-models-just-loaded');
|
||||
clearTimeout(list._serveModelDominoTimer);
|
||||
list._serveModelDominoTimer = setTimeout(() => {
|
||||
list.classList.remove('cookbook-serve-models-just-loaded');
|
||||
list._serveModelDominoTimer = null;
|
||||
}, 950);
|
||||
}
|
||||
|
||||
// ── Fetch cached models from server ──
|
||||
|
||||
function _renderCachedModelsData(list, data, host) {
|
||||
// A scan rebuild replaces these cards. Cancel pending mobile long-presses
|
||||
// before their DOM nodes are discarded, so detached cards cannot open menus.
|
||||
list.querySelectorAll('.memory-item').forEach(item => {
|
||||
if (item._longPressTimer) {
|
||||
clearTimeout(item._longPressTimer);
|
||||
item._longPressTimer = null;
|
||||
}
|
||||
});
|
||||
// CHANGELOG: 'ready' already excludes partial downloads;
|
||||
// show every complete model regardless of size/backend.
|
||||
const ready = (data.models || []).filter(m => m.status === 'ready');
|
||||
@@ -4038,6 +4162,7 @@ function _renderCachedModelsData(list, data, host) {
|
||||
const downloading = (data.models || []).filter(m => m.status === 'downloading');
|
||||
const allModels = [...ready, ...downloading];
|
||||
_cachedAllModels = allModels;
|
||||
_syncServeStats();
|
||||
|
||||
if (!allModels.length) {
|
||||
if (!host) {
|
||||
@@ -4089,45 +4214,49 @@ function _renderCachedModelsData(list, data, host) {
|
||||
const tagContainer = document.getElementById('serve-tags');
|
||||
if (tagContainer) {
|
||||
const tagOrder = ['llm', 'image', 'lora', 'embedding', 'tts', 'stt', 'other'];
|
||||
let tagHtml = `<button class="memory-cat-chip active" data-serve-tag="">All (${allModels.length})</button>`;
|
||||
let tagHtml = `<button class="skills-summary-chip active" data-serve-tag=""><span>All</span><strong>${allModels.length}</strong></button>`;
|
||||
for (const t of tagOrder) {
|
||||
if (!_tagMap[t]) continue;
|
||||
tagHtml += `<button class="memory-cat-chip" data-serve-tag="${t}">${t} (${_tagMap[t]})</button>`;
|
||||
tagHtml += `<button class="skills-summary-chip" data-serve-tag="${esc(t)}"><span>${esc(t)}</span><strong>${_tagMap[t]}</strong></button>`;
|
||||
}
|
||||
const sortedFamilies = Object.entries(_familyMap).sort((a, b) => b[1] - a[1]);
|
||||
if (sortedFamilies.length) {
|
||||
for (const [fam, count] of sortedFamilies) {
|
||||
const logo = providerLogo(fam);
|
||||
const logoHtml = logo ? `<span style="width:12px;height:12px;display:inline-flex;align-items:center;vertical-align:-2px;margin-right:2px;opacity:0.6;">${logo}</span>` : '';
|
||||
tagHtml += `<button class="memory-cat-chip" data-serve-tag="fam:${fam}">${logoHtml}${fam} (${count})</button>`;
|
||||
tagHtml += `<button class="skills-summary-chip" data-serve-tag="fam:${esc(fam)}"><span class="cookbook-serve-tag-label">${logoHtml}${esc(fam)}</span><strong>${count}</strong></button>`;
|
||||
}
|
||||
}
|
||||
tagContainer.innerHTML = tagHtml;
|
||||
}
|
||||
|
||||
_rerenderCachedModels();
|
||||
_playCachedModelDomino(list);
|
||||
}
|
||||
|
||||
export async function _fetchCachedModels(fresh = false, opts = {}) {
|
||||
async function _fetchCachedModelsImpl(fresh = false, opts = {}) {
|
||||
const list = document.getElementById('hwfit-cached-list');
|
||||
if (!list) return;
|
||||
const fetchId = ++_cachedModelsFetchId;
|
||||
_cachedModelsRequestController?.abort();
|
||||
const _requestController = new AbortController();
|
||||
_cachedModelsRequestController = _requestController;
|
||||
const allowNetwork = fresh || opts.allowNetwork !== false;
|
||||
|
||||
list.innerHTML = '';
|
||||
const _dlWp = spinnerModule.createWhirlpool(22);
|
||||
_dlWp.element.classList.add('cookbook-section-loading-wp');
|
||||
_dlWp.element.style.width = '22px';
|
||||
_dlWp.element.style.height = '22px';
|
||||
const _dlWrap = document.createElement('div');
|
||||
_dlWrap.className = 'hwfit-loading';
|
||||
_dlWrap.className = 'hwfit-loading cookbook-cached-scan-loading';
|
||||
_dlWrap.style.cssText = 'flex-direction:column;gap:6px;';
|
||||
_dlWrap.appendChild(_dlWp.element);
|
||||
const _dlLabel = document.createElement('div');
|
||||
_dlLabel.textContent = 'Scanning cached models…';
|
||||
_dlLabel.style.cssText = 'opacity:0.5;font-size:11px;';
|
||||
_dlWrap.appendChild(_dlLabel);
|
||||
list.appendChild(_dlWrap);
|
||||
|
||||
let scanSig = '';
|
||||
let preserveRows = false;
|
||||
try {
|
||||
let host = _envState.remoteHost || '';
|
||||
let selectedServer = null;
|
||||
@@ -4180,26 +4309,49 @@ export async function _fetchCachedModels(fresh = false, opts = {}) {
|
||||
if (host) { qp.set('host', host); const _sp4 = _getPort(host); if (_sp4) qp.set('ssh_port', _sp4); const _plat = _getPlatform(host); if (_plat) qp.set('platform', _plat); }
|
||||
if (modelDirs.length) qp.set('model_dir', modelDirs.join(','));
|
||||
const params = qp.toString() ? `?${qp}` : '';
|
||||
const scanSig = params || 'local';
|
||||
scanSig = params || 'local';
|
||||
const hasModelRows = !!list.querySelector('.memory-item[data-repo]');
|
||||
preserveRows = hasModelRows && list.dataset.cookbookScanSig === scanSig;
|
||||
if (!preserveRows) list.innerHTML = '';
|
||||
else list.querySelector('.cookbook-cached-scan-loading')?.remove();
|
||||
_dlWrap.classList.toggle('cookbook-cached-scan-loading-inline', preserveRows);
|
||||
if (preserveRows) {
|
||||
_dlWrap.style.cssText = 'flex-direction:row;align-items:center;justify-content:center;gap:6px;padding:8px 0;';
|
||||
_dlWp.element.style.width = '16px';
|
||||
_dlWp.element.style.height = '16px';
|
||||
_dlLabel.textContent = 'Refreshing cached models…';
|
||||
}
|
||||
list.appendChild(_dlWrap);
|
||||
const cached = fresh ? null : _readCachedModelScan(scanSig);
|
||||
if (cached) {
|
||||
if (fetchId !== _cachedModelsFetchId) {
|
||||
_dlWp.destroy();
|
||||
_dlWrap.remove();
|
||||
return;
|
||||
}
|
||||
_dlWp.destroy();
|
||||
list.dataset.cookbookScanSig = scanSig;
|
||||
_renderCachedModelsData(list, cached, host);
|
||||
return;
|
||||
}
|
||||
if (!allowNetwork) {
|
||||
if (fetchId !== _cachedModelsFetchId) {
|
||||
_dlWp.destroy();
|
||||
_dlWrap.remove();
|
||||
return;
|
||||
}
|
||||
_dlWp.destroy();
|
||||
const wp = spinnerModule.createWhirlpool(22);
|
||||
list.innerHTML = '<div class="hwfit-loading serve-empty-auto-scan" style="flex-direction:column;gap:8px;text-align:center;"><div class="serve-empty-auto-wp"></div><div>No cached model scan yet</div><div style="font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;">Scanning this server\'s model cache…</div></div>';
|
||||
list.querySelector('.serve-empty-auto-wp')?.appendChild(wp.element);
|
||||
setTimeout(() => {
|
||||
if (list.querySelector('.serve-empty-auto-scan')) _fetchCachedModels(true);
|
||||
if (fetchId === _cachedModelsFetchId && list.querySelector('.serve-empty-auto-scan')) _fetchCachedModels(true);
|
||||
}, 60);
|
||||
const tagContainer = document.getElementById('serve-tags');
|
||||
if (tagContainer) tagContainer.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`/api/model/cached${params}`);
|
||||
const res = await _fetchCookbookWithTimeout(`/api/model/cached${params}`, { credentials: 'same-origin', signal: _requestController.signal }, 30000);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
let msg = '';
|
||||
@@ -4213,12 +4365,28 @@ export async function _fetchCachedModels(fresh = false, opts = {}) {
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText}${msg ? `: ${msg}` : ''}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (fetchId !== _cachedModelsFetchId) {
|
||||
_dlWp.destroy();
|
||||
_dlWrap.remove();
|
||||
return;
|
||||
}
|
||||
if (data && data.error) throw new Error(data.error);
|
||||
_writeCachedModelScan(scanSig, data);
|
||||
_dlWp.destroy();
|
||||
list.dataset.cookbookScanSig = scanSig;
|
||||
_renderCachedModelsData(list, data, host);
|
||||
} catch (e) {
|
||||
_dlWp.destroy();
|
||||
_dlWrap.remove();
|
||||
if (fetchId !== _cachedModelsFetchId) return;
|
||||
if (preserveRows) {
|
||||
list.querySelector('.cookbook-cached-scan-error')?.remove();
|
||||
list.insertAdjacentHTML('beforeend', `<div class="cookbook-cached-scan-error" style="display:flex;align-items:center;justify-content:center;gap:8px;padding:7px 0;color:var(--color-warning,#f0ad4e);font-size:11px;"><span>Refresh failed: ${esc(e.message)}</span><button type="button" class="hwfit-gpu-btn cookbook-cached-scan-retry" style="height:24px;padding:2px 8px;">Retry</button></div>`);
|
||||
list.querySelector('.cookbook-cached-scan-retry')?.addEventListener('click', () => {
|
||||
_fetchCachedModels(true);
|
||||
});
|
||||
return;
|
||||
}
|
||||
list.innerHTML = `<div class="hwfit-loading" style="flex-direction:column;gap:8px;text-align:center;"><div style="color:var(--red);font-weight:600;">Cached model scan failed</div><div style="font-size:11px;opacity:0.65;max-width:420px;line-height:1.4;">${esc(e.message)}</div><button type="button" class="hwfit-gpu-btn serve-empty-scan-btn" style="height:26px;padding:3px 10px;">Retry</button></div>`;
|
||||
list.querySelector('.serve-empty-scan-btn')?.addEventListener('click', () => {
|
||||
_fetchCachedModels(true);
|
||||
@@ -4226,6 +4394,37 @@ export async function _fetchCachedModels(fresh = false, opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Several startup/state-sync paths can request the same cache scan together.
|
||||
// Share that request per selected server so stale-response protection does not
|
||||
// merely discard duplicate SSH work after it has already happened.
|
||||
const _cachedModelsInFlight = new Map();
|
||||
export function _fetchCachedModels(fresh = false, opts = {}) {
|
||||
const cacheServer = document.getElementById('hwfit-cache-server');
|
||||
const key = [
|
||||
cacheServer?.value || 'local',
|
||||
_envState.remoteHost || '',
|
||||
opts.allowNetwork === false ? 'offline' : 'network',
|
||||
].join('|');
|
||||
const existing = _cachedModelsInFlight.get(key);
|
||||
if (existing) return existing;
|
||||
const pending = _fetchCachedModelsImpl(fresh, opts);
|
||||
_cachedModelsInFlight.set(key, pending);
|
||||
const clearPending = () => {
|
||||
if (_cachedModelsInFlight.get(key) === pending) _cachedModelsInFlight.delete(key);
|
||||
};
|
||||
pending.then(clearPending, clearPending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
// Closing the modal should not leave an SSH/cache scan running in the
|
||||
// background. Incrementing the fetch id suppresses a completion that races
|
||||
// with the abort signal.
|
||||
export function _cancelCachedModelScan() {
|
||||
_cachedModelsFetchId++;
|
||||
_cachedModelsRequestController?.abort();
|
||||
_cachedModelsRequestController = null;
|
||||
}
|
||||
|
||||
/** Filter presets matching a model repo */
|
||||
function _presetsForModel(presets, repo) {
|
||||
const short = repo.split('/').pop();
|
||||
@@ -4303,3 +4502,51 @@ document.addEventListener('click', (e) => {
|
||||
const repo = pill.dataset.repo || '';
|
||||
if (repo) _openRunningTabForRepo(repo);
|
||||
});
|
||||
|
||||
// Handle Cancel at the document level too. The serve panel is built after an
|
||||
// async cached-model lookup, so a fast click can arrive before its local
|
||||
// listener is attached.
|
||||
if (!window._cookbookServeCancelBound) {
|
||||
window._cookbookServeCancelBound = true;
|
||||
const closeFromCancel = (e) => {
|
||||
const button = e.target.closest?.('.hwfit-serve-cancel, .hwfit-action-panel button');
|
||||
if (!button) return;
|
||||
const panel = button.closest('.hwfit-serve-panel');
|
||||
const item = panel?.closest('.memory-item[data-repo]');
|
||||
const actionPanel = button.closest('.hwfit-action-panel');
|
||||
const isCancel = button.classList.contains('hwfit-serve-cancel')
|
||||
|| button.textContent.trim().toLowerCase() === 'cancel';
|
||||
if (!isCancel) return;
|
||||
if (actionPanel) {
|
||||
const row = actionPanel.previousElementSibling;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
actionPanel.remove();
|
||||
row?.classList.remove('hwfit-row-active');
|
||||
return;
|
||||
}
|
||||
if (!panel || !item) return;
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
try {
|
||||
panel._cleanupServePanel?.();
|
||||
} catch (err) {
|
||||
console.warn('Serve panel cleanup failed:', err);
|
||||
} finally {
|
||||
panel.remove();
|
||||
item.classList.remove('doclib-card-expanded');
|
||||
item.style.flexDirection = '';
|
||||
item.style.alignItems = '';
|
||||
item.style.maxHeight = '';
|
||||
const list = item.closest('.hwfit-cached-list');
|
||||
if (list) {
|
||||
list.style.minHeight = '';
|
||||
list.style.maxHeight = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
// Capture pointerdown as well as click so touch interaction cannot be
|
||||
// consumed by the expanded card's row handler before Cancel runs.
|
||||
document.addEventListener('pointerdown', closeFromCancel, true);
|
||||
document.addEventListener('click', closeFromCancel, true);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user