`;
const _srvTitle = s.name || (isLocal ? 'Local' : (s.host || `Server ${i + 1}`));
const _srvKey = isLocal ? 'local' : (s.host || '');
const _isDefaultSrv = (defaultServer || '') === _srvKey;
const _pIco = _platformIcon(s.platform);
const _keyBtn = `
`;
const _checkBtn = `
`;
html += `
`;
html += `${esc(_srvTitle)}`;
html += _pIco ? `${_pIco}` : '';
html += ``;
if (isNew) {
// New server: Cancel (discard) sits top-right; the default toggle only makes
// sense once the server is saved.
html += `${_checkBtn}${_keyBtn}`;
} else {
html += `${!isLocal ? _checkBtn + _keyBtn : ''}${_isDefaultSrv ? _MODELDIR_CHECK_ON : _MODELDIR_CHECK_OFF}default`;
}
html += ``;
html += `
`;
html += ``;
html += ``;
html += ``;
html += ``;
html += ``;
html += `placeholder`;
html += ``;
html += `
`;
const modelDirs = Array.isArray(s.modelDirs) && s.modelDirs.length ? s.modelDirs : ['~/.cache/huggingface/hub'];
const activeDlDir = s.downloadDir || '';
html += `
`;
html += `
Model Directory — check the one downloads should go to`;
for (let j = 0; j < modelDirs.length; j++) {
const isDefault = modelDirs[j] === '~/.cache/huggingface/hub';
const dirVal = isDefault ? '' : modelDirs[j];
const isTarget = activeDlDir === dirVal;
const dlBtn = `
${isTarget ? _MODELDIR_CHECK_ON : _MODELDIR_CHECK_OFF}`;
const rmBtn = isDefault ? '' : '
✖';
html += `
${dlBtn} ${esc(modelDirs[j])}${rmBtn}`;
}
html += `
`;
const _btnStyle = 'margin-left:auto;position:relative;top:-2px;height:22px;box-sizing:border-box;display:inline-flex;align-items:center;';
if (isNew) {
// A brand-new server: Save (confirm) sits where Delete would be; Cancel is
// top-right in the title. Save confirms with a checkmark (auto-saves on edit too).
html += `
`;
} else if (!isLocal) {
html += `
`;
}
html += `
`;
if (!isLocal) {
html += `
`;
html += `
`;
html += ``;
html += ``;
html += `Docker: run this command in your terminal once.`;
html += `
`;
html += `
`;
html += `
`;
}
html += `
`;
return html;
}
function _renderRecipes() {
const body = document.querySelector('#cookbook-modal .cookbook-body');
if (!body) return;
const presets = _loadPresets();
const hasSaved = presets.length > 0;
let html = '';
// Tabs
html += '';
// Serve group
html += '';
html += '
';
html += '
';
html += '
Serve
';
html += '';
const _selSrv = _es.servers.find(s => s.host === _es.remoteHost) || _es.servers[0] || {};
const _srvDirs = (Array.isArray(_selSrv.modelDirs) ? _selSrv.modelDirs : [_selSrv.modelDir || '~/.cache/huggingface/hub']).map(d => d.replaceAll('✕', '').replaceAll('✖', '').trim()).filter(Boolean);
html += '
';
html += _srvDirs.map(d => `${esc(d)}`).join('');
html += 'edit';
html += '
';
html += '
';
html += '';
html += '';
html += '
';
html += '
';
html += '
';
html += '
';
html += '
0 selected';
html += '
';
html += '
';
html += '
';
html += '
';
html += '
';
// Dependencies tab
html += '';
// ── HuggingFace Token block ─────────────────────────────────────────
html += '
';
html += '
';
html += '
HuggingFace Token
';
html += '';
html += '
Personal access token for downloading gated and private models.
';
html += '
';
html += '
';
// ── Servers block ───────────────────────────────────────────────────
html += '
';
html += '
';
html += '
Servers
';
// Reuse the calendar +New pill: spinning plus, label fades in idea uses
// the same `.cal-add-btn-text` rules, so styling stays consistent.
html += '';
html += '';
html += '
Configure SSH servers, install Odysseus keys, choose model directories, and set the default server. Local is this machine.
';
html += '
';
html += '
';
body.innerHTML = html;
_wireTabEvents(body);
// Auto-init What Fits
_hwfitInit();
_hwfitFetch();
}
// ── Public API ──
import * as Modals from './modalManager.js';
let _rendered = false;
let _closeGen = 0;
// ESC while a Serve card is expanded should collapse just that card, not
// close the whole Cookbook modal. Capture-phase so we run before the
// modal manager's global ESC-to-close handler and can stop it.
if (typeof window !== 'undefined' && !window._cookbookServeEscBound) {
window._cookbookServeEscBound = true;
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const modal = document.getElementById('cookbook-modal');
if (!modal || modal.classList.contains('hidden')) return;
// Layer 1: a model row in the scan/download list is highlighted —
// deselect it before doing anything else.
const activeRow = modal.querySelector('.hwfit-row-active');
if (activeRow) {
e.stopImmediatePropagation();
e.preventDefault();
activeRow.classList.remove('hwfit-row-active');
return;
}
const expanded = modal.querySelector('.memory-item.doclib-card-expanded');
if (!expanded) return; // nothing expanded — let the modal close normally
e.stopImmediatePropagation();
e.preventDefault();
// Collapse the card (mirror the toggle-close path in cookbookServe.js).
expanded.querySelector('.hwfit-serve-panel')?.remove();
expanded.classList.remove('doclib-card-expanded');
expanded.style.flexDirection = '';
expanded.style.alignItems = '';
const list = expanded.closest('.hwfit-cached-list') || document.getElementById('hwfit-cached-list');
if (list) { list.style.minHeight = ''; list.style.maxHeight = ''; }
}, true); // capture
}
export async function open(opts) {
const modal = document.getElementById('cookbook-modal');
if (!modal) return;
// Run any post-open intent (switch tab, prefill search, etc) after the
// current render pass so the target elements exist.
const _applyIntent = () => {
if (!opts) return;
if (opts.tab) {
const t = modal.querySelector(`.cookbook-tab[data-backend="${opts.tab}"]`);
if (t && !t.classList.contains('active')) t.click();
}
if (opts.usecase) {
const u = document.getElementById('hwfit-usecase');
if (u && u.value !== opts.usecase) { u.value = opts.usecase; u.dispatchEvent(new Event('change', { bubbles: true })); }
}
if (opts.serveSearch) {
const s = document.getElementById('serve-search');
if (s) { s.value = opts.serveSearch; s.dispatchEvent(new Event('input', { bubbles: true })); }
}
};
// If minimized, restore in place — preserve all state
if (Modals.isMinimized('cookbook-modal')) {
Modals.restore('cookbook-modal');
_renderRunningTab();
setTimeout(_applyIntent, 0);
return;
}
// If already visible, no-op (but still honour the intent)
if (!modal.classList.contains('hidden')) {
setTimeout(_applyIntent, 0);
return;
}
_setCookbookOpening(true);
try {
// Invalidate any pending close() animation handlers so they won't re-hide us
_closeGen++;
// Clear any leftover inline styles from a previous swipe-dismiss or close animation
const _content = modal.querySelector('.modal-content');
if (_content) {
_content.classList.remove('modal-closing', 'sheet-ready', 'cookbook-modal-entering');
_content.style.transform = '';
_content.style.transition = '';
_content.style.animation = '';
_content.style.opacity = '';
}
modal.style.display = '';
Modals.register('cookbook-modal', {
railBtnId: 'rail-cookbook',
sidebarBtnId: 'tool-cookbook-btn',
closeFn: () => _doClose(),
restoreFn: () => { _renderRunningTab(); },
});
_wireCookbookDrag(modal);
await _syncFromServer();
// `_syncFromServer` lives in cookbookRunning.js and populates *its* _envState
// (a different object reference than this module's), then mirrors the merged
// state to localStorage. So ALWAYS hydrate our _envState from that mirror —
// on a successful sync it holds the freshly-fetched servers; on failure it
// holds the last-known state. Gating this on `!synced` left the render's
// _envState empty whenever sync succeeded → "servers don't show".
try { Object.assign(_envState, _readStoredEnvState()); } catch {}
// Honour a user-set default server: always land on it when Cookbook opens, so
// every dropdown (scan/download/serve/cache/deps) starts on the same machine.
if (_envState.defaultServer) {
const _dk = _envState.defaultServer;
if (_dk === 'local') {
_envState.remoteHost = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = '';
} else {
const _ds = (_envState.servers || []).find(s => s.host === _dk);
if (_ds) { _envState.remoteHost = _ds.host; _envState.env = _ds.env || 'none'; _envState.envPath = _ds.envPath || ''; _envState.platform = _ds.platform || ''; }
}
}
// Re-render on every open AFTER sync so the freshly-fetched state (servers,
// HF token, presets) is always reflected. Gating this to once-per-page used
// to freeze a stale/empty servers list whenever the first sync raced or
// returned before hydration — and since close/reopen doesn't reset the page,
// only a full reload recovered it. Re-rendering is cheap and the in-progress
// Running tab is rendered separately just below.
_renderRecipes();
_rendered = true;
_clearCookbookNotif();
_renderRunningTab();
// Self-heal: revive any download tasks whose tmux session is still alive
// but were persisted as done/error (covers the "restarted server while a
// big multi-shard download was in flight" case — the task survived in
// tmux, the cookbook just lost track of it).
try { _selfHealStaleTasks({ oneShot: true }); } catch {}
if (_content) {
// Put the panel in its entering state before it becomes visible. On
// mobile, showing first and adding the class a frame later can paint the
// sheet at its final position, which makes the slide-up look like a snap.
_content.classList.add('cookbook-modal-entering');
}
modal.classList.remove('hidden');
if (_content) {
void _content.offsetWidth;
_content.addEventListener('animationend', () => {
_content.classList.remove('cookbook-modal-entering');
}, { once: true });
}
setTimeout(_applyIntent, 0);
} finally {
_setCookbookOpening(false);
}
}
// Make the Cookbook modal draggable (it had no drag wiring at all). We do
// NOT supply a fsClass fullscreen here — that would cover the whole viewport
// incl. the sidebar. Instead tileManager.js handles maximize/tiling (its
// safe-rect sits the window NEXT TO the sidebar), same as tasks/gallery/etc.
let _cookbookDragWired = false;
function _wireCookbookDrag(modal) {
if (_cookbookDragWired || !modal) return;
const content = modal.querySelector('.modal-content');
const header = modal.querySelector('.modal-header');
if (!content || !header) return;
_cookbookDragWired = true;
makeWindowDraggable(modal, {
content, header,
skipSelector: '.close-btn, .modal-close',
// Keep only the "close to the edge" dock gesture for Cookbook. The
// tileManager side snap is suppressed for this modal so there isn't a
// second, tighter edge state fighting the working one.
enableDock: true,
});
}
function _doClose() {
const modal = document.getElementById('cookbook-modal');
if (!modal) return;
const content = modal.querySelector('.modal-content');
const myGen = ++_closeGen;
if (content && !content.classList.contains('modal-closing')) {
content.classList.add('modal-closing');
content.addEventListener('animationend', () => {
if (myGen !== _closeGen) return;
modal.classList.add('hidden');
content.classList.remove('modal-closing');
}, { once: true });
setTimeout(() => {
if (myGen !== _closeGen) return;
if (!modal.classList.contains('hidden')) { modal.classList.add('hidden'); content.classList.remove('modal-closing'); }
}, 250);
} else {
modal.classList.add('hidden');
}
}
export function close() {
// Full close — fires registered closeFn, removes badge, unregisters
if (Modals.isRegistered('cookbook-modal')) {
Modals.close('cookbook-modal');
} else {
_doClose();
}
}
export function isVisible() {
const modal = document.getElementById('cookbook-modal');
if (!modal) return false;
if (Modals.isMinimized('cookbook-modal')) return false;
return !modal.classList.contains('hidden');
}
// Close button
document.addEventListener('DOMContentLoaded', () => {
const closeBtn = document.getElementById('close-cookbook-modal');
if (closeBtn) closeBtn.addEventListener('click', close);
const modal = document.getElementById('cookbook-modal');
if (modal) {
modal.addEventListener('click', (e) => {
if (uiModule.isTouchInsideModal()) return;
if (e.target === modal) close();
});
}
});
// ── Initialize sub-modules ──
// Shared SSH-port resolver — sub-modules use this via the shared bundle
// instead of redefining it. Kept here as the single source of truth.
function _sshPrefix(port) {
return port && port !== '22' ? `-p ${port} ` : '';
}
const shared = {
_envState,
_sshCmd,
_getPort,
_sshPrefix,
_serverByVal,
_selectedServer,
_getPlatform,
_isWindows,
_isMetal,
_buildEnvPrefix,
_buildServeCmd,
_shellQuote,
_psQuote,
_detectBackend,
_detectToolParser,
_detectModelOptimizations,
_loadPresets,
_savePresets,
_copyText,
_persistEnvState,
_refreshDependencies: _fetchDependencies,
_getGpuToggleTotal: () => _gpuToggleTotal,
modelLogo,
esc,
};
// Init running module (adds task management, auto-fix, launch, background monitor)
initRunning({
...shared,
});
// Init download module (adds SSE, panel rendering, download commands)
initDownload({
...shared,
_addTask,
_renderRunningTab,
_loadTasks,
_saveTasks,
});
// Init serve module (adds cached models, serve panels, launch)
initServe({
...shared,
_launchServeTask,
_retryDownload,
_nextAvailablePort,
});
// ── Re-exports for cookbook-diagnosis.js and cookbook-hwfit.js ──
// These modules import from cookbook.js, so we re-export what they need
export {
_loadTasks, _saveTasks, _addTask, _removeTask,
_tmuxCmd, _renderRunningTab,
_launchServeTask, _serveAutoFix, _serveAutoRetry, _serveAutoRetryReplace, _serveAutoRetryRemove,
_startBackgroundMonitor,
_setPanelField, _setPanelCheckbox,
_wirePanelEvents, _runPanelCmd, _runModelDownload, _buildDownloadCmd,
_isLocalEntry,
};
const cookbookModule = { open, close, isVisible, startBackgroundMonitor: _startBackgroundMonitor };
export default cookbookModule;