`. The helper handles the find/Modelfile/preload dance.
if (modelName.includes('/') && (f.gguf_file || /-GGUF$/i.test(modelName))) {
// HF-GGUF repo → import + preload + tail
const _name = (modelName.split('/').pop() || modelName)
.replace(/-GGUF$/i, '')
.toLowerCase()
.replace(/[^a-z0-9._:-]+/g, '-')
.replace(/^-+|-+$/g, '');
const _ctx = f.ctx || '8192';
const _file = (f.gguf_file || '').split('/').pop() || '';
// Trailing GGUF_FILE is optional; helper picks the first match if empty.
cmd = `docker exec ollama-test ollama-import ${modelName} ${_name} ${_ctx}${_file ? ' ' + _file : ''}`;
} else if (!modelName.includes('/') && modelName) {
// Already-pulled Ollama tag (e.g. `qwen2.5:7b`). On kierkegaard the
// runtime is the ROCm Ollama sidecar; this quick command verifies the
// tag exists, then the backend auto-registers http://host.docker.internal:11434/v1.
cmd = `docker exec ollama-rocm ollama show ${modelName}`;
} else {
const bindHost = _envState.remoteHost ? '0.0.0.0' : '127.0.0.1';
const hostEnv = ollamaPort !== '11434' ? `OLLAMA_HOST=${bindHost}:${ollamaPort} ` : '';
cmd = `${hostEnv}ollama serve`;
}
} else if (backend === 'diffusers') {
const gpuStr = f.gpus?.trim();
if (gpuStr) cmd += `CUDA_VISIBLE_DEVICES=${gpuStr} `;
cmd += `python3 scripts/diffusion_server.py --model ${modelName} --port ${f.port || '8100'}`;
if (f.diff_dtype && f.diff_dtype !== 'bfloat16') cmd += ` --dtype ${f.diff_dtype}`;
if (f.diff_device_map && f.diff_device_map !== 'balanced') cmd += ` --device-map ${f.diff_device_map}`;
if (f.diff_steps) cmd += ` --steps ${f.diff_steps}`;
if (f.diff_width) cmd += ` --width ${f.diff_width}`;
if (f.diff_height) cmd += ` --height ${f.diff_height}`;
if (f.diff_offload) cmd += ' --cpu-offload';
if (f.diff_attention_slicing) cmd += ' --attention-slicing';
if (f.diff_vae_slicing) cmd += ' --vae-slicing';
if (f.diff_harmonize_gpu) cmd += ` --harmonize-gpu ${f.diff_harmonize_gpu}`;
}
return cmd;
}
/** Get inline logo HTML for a model name/repo_id */
export function modelLogo(name) {
const logo = providerLogo(name);
const svg = logo || '';
return `${svg}`;
}
// Use shared esc() from ui module
export const esc = uiModule.esc;
// ── Clipboard ──
export function _copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text).catch(() => _fallbackCopy(text));
}
return _fallbackCopy(text);
}
function _fallbackCopy(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch (_) {}
document.body.removeChild(ta);
return Promise.resolve();
}
// ── Presets (server-synced; localStorage is offline cache) ──
// Presets sync to/from cookbook_state.json via _syncToServer / _syncFromServer.
// _loadPresets reads the cache (which gets refreshed at app boot and on modal open).
export function _loadPresets() {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; }
catch { return []; }
}
export function _savePresets(presets) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
// Trigger sync to server (via running module's _syncToServer debounce)
_saveTasks(_loadTasks());
}
function _envStateForStorage() {
const { hfToken, ...safeState } = _envState;
return safeState;
}
function _readStoredEnvState() {
const stored = JSON.parse(localStorage.getItem(LAST_STATE_KEY) || '{}');
delete stored.hfToken;
return stored;
}
export function _persistEnvState() {
try { localStorage.setItem(LAST_STATE_KEY, JSON.stringify(_envStateForStorage())); }
catch (_) {}
_saveTasks(_loadTasks());
}
// ── Dependencies ──
// Category colors removed — using theme CSS classes instead
async function _fetchDependencies() {
const list = document.getElementById('cookbook-deps-list');
if (!list) return;
// Use the shared whirlpool spinner so the user sees the request is in
// flight (the package list takes a few seconds to enumerate on slow links).
list.innerHTML = '';
let _spin = null;
try {
const sp = (await import('./spinner.js')).default;
_spin = sp.createWhirlpool(28);
_spin.element.style.cssText = 'margin:24px auto 0;display:block;';
list.appendChild(_spin.element);
const label = document.createElement('div');
label.className = 'hwfit-loading';
label.textContent = 'Loading packages…';
label.style.cssText = 'text-align:center;opacity:0.5;font-size:11px;margin-top:6px;';
list.appendChild(label);
} catch {
list.innerHTML = 'Loading packages...
';
}
try {
// Resolve the target server from the deps dropdown so remote-target
// packages are checked on THAT server's venv (not just the local host).
let _depHost = '', _depPort = '', _depVenv = '';
const _dsel = document.getElementById('hwfit-deps-server');
const _depSrv = _dsel && _dsel.value !== 'local' ? _serverByVal(_dsel.value) : null;
if (_depSrv) {
_depHost = _depSrv.host || ''; _depPort = _depSrv.port || ''; _depVenv = _depSrv.envPath || '';
} else if (_envState.remoteHost) {
_depHost = _envState.remoteHost; _depPort = _getPort(_envState.remoteHost) || ''; _depVenv = _envState.envPath || '';
}
const _pkgParams = new URLSearchParams();
if (_depHost) {
_pkgParams.set('host', _depHost);
if (_depPort) _pkgParams.set('ssh_port', _depPort);
if (_depVenv) _pkgParams.set('venv', _depVenv);
}
const resp = await fetch('/api/cookbook/packages' + (_pkgParams.toString() ? '?' + _pkgParams.toString() : ''));
const data = await resp.json();
const pkgs = data.packages || [];
if (!pkgs.length) { list.innerHTML = 'No packages found
'; return; }
const _winUnsupported = new Set(['diffusers', 'hf_transfer', 'vllm', 'rembg', 'gfpgan']);
const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => {
if (winBlocked) return `N/A`;
if (pkg.installed && isSystemDep) return `Installed`;
if (pkg.installed && pkg.pip_update_available === false) {
const tip = esc(pkg.update_note || pkg.status_note || 'Found externally; update outside Odysseus.');
return `Installed`;
}
if (pkg.installed) return ``;
if (isSystemDep) {
const depTip = esc(pkg.install_hint || 'Install this OS package on the selected server.');
const depLabel = pkg.applicable === false ? 'N/A ?' : 'Missing';
return `${depLabel}`;
}
return ``;
};
// Per-package inline glyphs — same accent-coloured marks used in the
// Backend picker on the Run page, so the Dependencies row visually
// matches the engine you're configuring. Unknown packages get no
// icon (the name alone is fine for librosa, hf_transfer, etc.).
const _DEP_GLYPHS = {
vllm: '',
sglang: '',
llama_cpp: '',
ollama: '',
diffusers: '',
};
const _depGlyphHtml = (name) => {
const g = _DEP_GLYPHS[name];
return g ? `${g}` : '';
};
const _depRow = (pkg) => {
const isLocal = pkg.target === 'local';
const isSystemDep = pkg.kind === 'system';
const winBlocked = !isLocal && _isWindows() && _winUnsupported.has(pkg.name);
const note = pkg.status_note ? `${esc(pkg.status_note)}
` : '';
const updateNote = pkg.installed && pkg.pip_update_available === false && pkg.update_note ? `${esc(pkg.update_note)}
` : '';
// Inline rebuild/reinstall tag. Styled as a .cookbook-dep-tag so it
// matches the LLM category tag's pill look, and lives to the LEFT of the
// category tag. llama_cpp uses the /api/cookbook/rebuild-engine flow
// (clear cached binary so next serve recompiles); vllm/sglang use the
// diagnosis-style `_launchServeTask` with `pip install --force-reinstall`
// so the user can watch the pip install in the Running tab.
let _rebuildBtn = '';
if (pkg.name === 'llama_cpp') {
_rebuildBtn = ``;
} else if (pkg.name === 'vllm' && pkg.installed) {
_rebuildBtn = ``;
} else if (pkg.name === 'sglang' && pkg.installed) {
_rebuildBtn = ``;
}
// For backends with a recipe catalog (vllm / sglang / llama_cpp),
// append a caret button that toggles a per-row recipe panel below.
const hasRecipe = RECIPE_BACKENDS.has(pkg.name);
const recipeCaret = hasRecipe
? ``
: '';
const recipePanel = hasRecipe ? _recipePanelHtml(pkg.name) : '';
return ``
+ `
`
+ `
${_depGlyphHtml(pkg.name)}${esc(pkg.name)}
`
+ `
${esc(pkg.desc)}
`
+ note
+ updateNote
+ `
`
+ _rebuildBtn
+ `
${esc(pkg.category)}`
+ _statusTag(pkg, isLocal, isSystemDep, winBlocked)
+ recipeCaret
+ `
`
+ recipePanel;
};
// Prepend the configured venv's activate line (pip variant only) so
// the user sees a paste-ready sequence; Run keeps using env_prefix to
// activate the same venv before the pip command. Docker variant skips
// the activate line — `docker pull` doesn't need a venv.
function _recipeDisplayText(commands, variant) {
if (variant === 'docker') return commands.join('\n');
const envPath = (_envState.envPath || '').replace(/\/+$/, '');
const activate = envPath
? `source ${envPath}${envPath.endsWith('/bin/activate') ? '' : '/bin/activate'}`
: '# (activate your venv first)';
return [activate, ...commands].join('\n');
}
// Per-backend recipe panel (model picker + commands + Copy/Run).
// Lives directly below the row it expands and starts collapsed.
// The model picker lists every downloaded model from _cachedModelIds
// (the same set the Launch tab uses); pickRecipe() then finds the
// best-matching recipe for whatever the user selects, with the
// backend's generic entry as the fallback.
function _recipePanelHtml(backend) {
const candidates = recipesForBackend(backend);
if (!candidates.length) return '';
const downloadedIds = _cachedModelIds ? Array.from(_cachedModelIds).sort() : [];
const modelOptions = downloadedIds.length
? downloadedIds.map(id => ``).join('')
: '';
// "Other" entry: user types/pastes an id, OR uses the generic fallback
// when no models have been downloaded yet.
const otherOpt = ``;
const opts = modelOptions + otherOpt;
// Initial recipe: the generic fallback (matches first time, no model id).
const initial = pickRecipe(backend, '') || candidates[0];
const initialVariant = RECIPE_DEFAULT_VARIANT;
const initialCmds = recipeCommands(initial, initialVariant);
const rightActive = initialVariant === 'docker' ? ' mode-right' : '';
return `
Serving which model?
${esc(_recipeDisplayText(initialCmds, initialVariant))}
`;
}
const _section = (title, note, items) =>
items.length
? `${title}${note}
` + items.map(_depRow).join('')
: '';
const _viewingRemote = !!(_dsel && _dsel.value && _dsel.value !== 'local');
const _appDeps = pkgs.filter(p => p.target === 'local');
const _serverDeps = pkgs.filter(p => p.target !== 'local');
list.innerHTML = [
_viewingRemote ? '' : _section('Odysseus app', 'Run inside the Odysseus app itself.', _appDeps),
_section('Server', 'Run on the server chosen above (Local, or a remote box over SSH).', _serverDeps),
].join('');
// Shared install/update routine — used by the Install button and the
// "Update" item in an installed package's ⋮ menu. `upgrade` adds pip -U;
// `statusEl`, when given, shows "Installing…/Updating…" and is disabled.
async function _installDep(pipName, pkgName, isLocalOnly, upgrade, statusEl) {
if (isLocalOnly) {
_envState.remoteHost = '';
_envState.env = 'none';
_envState.envPath = '';
} else {
const depsServerSel = document.getElementById('hwfit-deps-server');
if (depsServerSel) _applyServerSelection(depsServerSel.value);
}
const targetHost = isLocalOnly ? 'this server' : (_envState.remoteHost || 'local');
// Always go through `python -m pip` so the leading token is `python`
// — matches the /api/model/serve allow-list (bare `pip` is blocked).
// Inside a venv/conda env, `--user` is invalid (pip refuses), so we
// only add `--user --break-system-packages` when there's no env —
// for PEP-668-locked system pythons (Arch, newer Debian).
const _inEnv = _envState.env === 'venv' || _envState.env === 'conda';
const _pipFlags = (!_isWindows() && !_inEnv) ? ' --user --break-system-packages' : '';
// Use the venv's python3 by absolute path when configured. Even with the
// env_prefix sourcing activate, SSH non-interactive sessions sometimes
// pick a `python3` ahead of the venv's bin on PATH, so the install
// silently lands in the wrong site-packages.
let _py;
if (_isWindows()) {
_py = 'python';
} else if (_envState.env === 'venv' && _envState.envPath) {
_py = `${_envState.envPath.replace(/\/+$/, '')}/bin/python3`;
} else {
_py = 'python3';
}
const cmd = `${_py} -m pip install${upgrade ? ' -U' : ''}${_pipFlags} "${pipName}"`;
let envPrefix = '';
if (_isWindows()) {
if (_envState.env === 'venv' && _envState.envPath) {
envPrefix = '& ' + _psQuote(_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1');
} else if (_envState.env === 'conda' && _envState.envPath) {
envPrefix = 'conda activate ' + _psQuote(_envState.envPath);
}
} else {
if (_envState.env === 'venv' && _envState.envPath) {
const p = _envState.envPath;
envPrefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate');
} else if (_envState.env === 'conda' && _envState.envPath) {
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
}
}
try {
const reqBody = {
repo_id: pipName,
cmd: cmd,
remote_host: _envState.remoteHost || undefined,
ssh_port: _getPort(_envState.remoteHost) || undefined,
env_prefix: envPrefix || undefined,
platform: _envState.platform || undefined,
};
const res = await fetch('/api/model/serve', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqBody),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
// FastAPI HTTPException returns {detail: …}; the route's own
// path returns {ok:false, error:…}. Surface whichever we get.
const reason = data.detail || data.error || `HTTP ${res.status}`;
uiModule.showToast('Install failed: ' + String(reason).slice(0, 200));
return;
}
// _dep flags this as a pip dependency/driver install (not a servable
// model) so the running-task card doesn't offer a "Serve →" button.
const payload = { repo_id: pipName, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true, env_path: _envState.envPath || '' };
_addTask(data.session_id, 'pip ' + pkgName, 'download', payload);
if (statusEl) { statusEl.textContent = upgrade ? 'Updating...' : 'Installing...'; statusEl.disabled = true; }
uiModule.showToast(`${upgrade ? 'Updating' : 'Installing'} ${pkgName} on ${targetHost}...`);
} catch (err) {
uiModule.showToast('Install failed: ' + err.message);
}
}
// Wire install buttons (not-installed packages)
list.querySelectorAll('.cookbook-dep-install:not(.cookbook-dep-recipe-run)').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const pipName = btn.dataset.depPip;
const pkgName = btn.closest('.cookbook-dep-row')?.querySelector('.memory-item-title')?.textContent || pipName;
await _installDep(pipName, pkgName, btn.dataset.depTarget === 'local', !!btn.dataset.upgrade, btn);
});
});
// ── Recipe panel wiring (per-backend dropdown with model + commands) ──
// Caret toggle: shows/hides the panel directly below the backend row.
list.querySelectorAll('[data-dep-recipe-toggle]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const backend = btn.dataset.depRecipeToggle;
const panel = list.querySelector(`[data-dep-recipe-panel="${CSS.escape(backend)}"]`);
if (!panel) return;
const open = panel.style.display === 'none' || !panel.style.display;
panel.style.display = open ? 'block' : 'none';
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
const caret = btn.querySelector('svg');
if (caret) caret.style.transform = open ? 'rotate(180deg)' : '';
});
});
// Re-render the for a backend using the currently-active variant
// (pip / docker) and the currently-picked model. Used by every input
// that changes which install sequence we should show.
function _refreshRecipePre(backend) {
const panel = list.querySelector(`[data-dep-recipe-panel="${CSS.escape(backend)}"]`);
if (!panel) return;
const variant = panel.dataset.depRecipeActiveVariant || RECIPE_DEFAULT_VARIANT;
const sel = panel.querySelector('[data-dep-recipe-pick]');
const recipe = pickRecipe(backend, (sel && sel.value) || '');
const cmds = recipeCommands(recipe, variant);
const pre = panel.querySelector('[data-dep-recipe-cmds]');
if (pre) {
pre.textContent = _recipeDisplayText(cmds, variant);
pre.dataset.depRecipeInstall = cmds.join('\n');
}
}
// Model select: pickRecipe matches the model id against the catalog.
list.querySelectorAll('[data-dep-recipe-pick]').forEach(sel => {
sel.addEventListener('change', () => _refreshRecipePre(sel.dataset.depRecipePick));
});
// Variant toggle (Pip/uv vs Docker): mirrors the agent/chat mode-toggle
// pattern — buttons get .active, container gets .mode-right when the
// right slot is selected so the sliding pill animates over.
list.querySelectorAll('[data-dep-recipe-variant]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const backend = btn.dataset.depRecipeVariant;
const variant = btn.dataset.variant;
const panel = list.querySelector(`[data-dep-recipe-panel="${CSS.escape(backend)}"]`);
if (!panel) return;
panel.dataset.depRecipeActiveVariant = variant;
const container = panel.querySelector('.mode-toggle[data-dep-recipe-variants]');
if (container) container.classList.toggle('mode-right', variant === 'docker');
panel.querySelectorAll('[data-dep-recipe-variant]').forEach(b => {
const on = b.dataset.variant === variant;
b.classList.toggle('active', on);
b.setAttribute('aria-pressed', on ? 'true' : 'false');
});
_refreshRecipePre(backend);
});
});
// Copy: drop the visible command block on the clipboard.
list.querySelectorAll('[data-dep-recipe-copy]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const backend = btn.dataset.depRecipeCopy;
const pre = list.querySelector(`[data-dep-recipe-cmds="${CSS.escape(backend)}"]`);
if (!pre) return;
try {
await navigator.clipboard.writeText(pre.textContent);
uiModule.showToast('Copied');
} catch {
// Fallback for non-secure contexts: select the pre's text so
// the user can Ctrl+C themselves.
const sel = window.getSelection(); const range = document.createRange();
range.selectNodeContents(pre); sel.removeAllRanges(); sel.addRange(range);
}
});
});
// Run: launch the install command(s) as a tmux task on the currently-
// selected deps server. Activation comes from env_prefix (same plumbing
// the Install button uses) so the install lands in the configured venv
// instead of a fresh .venv in some random CWD.
list.querySelectorAll('[data-dep-recipe-run]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const backend = btn.dataset.depRecipeRun;
const pre = list.querySelector(`[data-dep-recipe-cmds="${CSS.escape(backend)}"]`);
if (!pre) return;
// Use the install-only command list (no activate line) — the
// displayed source line is for the user's reading; env_prefix
// handles it for the actual run.
const installRaw = pre.dataset.depRecipeInstall || pre.textContent;
const cmd = installRaw.split('\n').map(s => s.trim()).filter(Boolean).join(' && ');
const depsSel = document.getElementById('hwfit-deps-server');
if (depsSel) _applyServerSelection(depsSel.value);
const targetHost = _envState.remoteHost || 'local';
// Build env_prefix from the configured envPath (matches _installDep).
let envPrefix = '';
if (_envState.env === 'venv' && _envState.envPath) {
const p = _envState.envPath;
envPrefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate');
} else if (_envState.env === 'conda' && _envState.envPath) {
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
}
const reqBody = {
repo_id: `${backend} setup`,
cmd: cmd,
remote_host: _envState.remoteHost || undefined,
ssh_port: _getPort(_envState.remoteHost) || undefined,
env_prefix: envPrefix || undefined,
platform: _envState.platform || undefined,
};
try {
const res = await fetch('/api/model/serve', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqBody),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
uiModule.showToast('Run failed: ' + String(data.detail || data.error || `HTTP ${res.status}`).slice(0, 200));
return;
}
const payload = { repo_id: `${backend} setup`, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true };
_addTask(data.session_id, `${backend} setup`, 'download', payload);
uiModule.showToast(`Running ${backend} setup on ${targetHost}…`);
} catch (err) {
uiModule.showToast('Run failed: ' + err.message);
}
});
});
// Wire the ⋮ menu on installed packages — currently just "Update".
function _showDepMenu(anchor) {
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
const row = anchor.closest('.cookbook-dep-row');
if (!row) return;
const pipName = row.dataset.depPip;
const pkgName = row.querySelector('.memory-item-title')?.textContent || pipName;
const isLocalOnly = row.dataset.depTarget === 'local';
const dropdown = document.createElement('div');
dropdown.className = 'dropdown cookbook-dep-menu';
const rect = anchor.getBoundingClientRect();
const minW = 150;
let left = Math.min(rect.right - minW, window.innerWidth - minW - 8);
left = Math.max(8, left);
dropdown.style.cssText = `position:fixed;display:block;z-index:10001;top:${rect.bottom + 6}px;left:${left}px;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
const upIco = '';
const it = document.createElement('div');
it.className = 'dropdown-item-compact';
it.innerHTML = `${upIco}Update`;
it.title = `Update ${pkgName} to the latest version (pip install -U)`;
it.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.remove();
await _installDep(pipName, pkgName, isLocalOnly, true, null);
});
dropdown.appendChild(it);
document.body.appendChild(dropdown);
const close = (ev) => {
if (!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)) {
dropdown.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
}
list.querySelectorAll('.cookbook-dep-installed-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (document.querySelector('.cookbook-dep-menu')) {
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
return;
}
_showDepMenu(btn);
});
});
} catch (err) {
list.innerHTML = `Error loading packages: ${esc(err.message)}
`;
}
}
// ── Tab wiring ──
function _applyServerSelection(val) {
if (val === 'local') {
_envState.remoteHost = '';
_envState.remoteServerKey = '';
_envState.env = 'none';
_envState.envPath = '';
_envState.platform = '';
} else {
const s = _serverByVal(val);
if (s) {
_envState.remoteHost = s.host;
_envState.remoteServerKey = _serverKey(s);
_envState.env = s.env || 'none';
_envState.envPath = s.envPath || '';
_envState.platform = s.platform || '';
}
}
// Persist + keep every server dropdown in sync, so the choice sticks across
// re-renders and the scan/download all target the SAME host (this was the
// bug: the Download/Cache/Deps dropdowns set the host but never saved it, so
// it silently reverted and downloads/scans hit the wrong server).
_persistEnvState();
const _want = _currentServerValue();
document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => {
if (!sel || sel.tagName !== 'SELECT') return;
// Option values are host strings now ('local' for the local box).
sel.value = _want;
// If the host isn't among this select's current options (stale options after
// the server list changed), the browser leaves the box BLANK/grey even though
// the value is "set". Rebuild the options so the chosen host has an entry, then
// re-apply; fall back to 'local' only if it's genuinely gone.
if (sel.selectedIndex < 0) {
sel.innerHTML = _buildServerOpts(sel.id === 'hwfit-dl-server');
sel.value = _want;
if (sel.selectedIndex < 0) sel.value = 'local';
}
});
}
function _wireTabEvents(body) {
// Tab switching
body.querySelectorAll('.cookbook-tab').forEach(tab => {
tab.addEventListener('click', () => {
body.querySelectorAll('.cookbook-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
const backend = tab.dataset.backend;
body.querySelectorAll('.cookbook-group').forEach(g => {
g.classList.toggle('hidden', g.dataset.backendGroup !== backend);
});
if (backend === 'Search') {
_hwfitInit();
_hwfitFetch();
}
if (backend === 'Serve') {
_fetchCachedModels();
}
if (backend === 'Dependencies') {
_fetchDependencies();
}
});
});
// Mobile: swipe left/right anywhere in the body to move to the next/previous
// tab. Guarded so it ignores vertical scrolls, tiny moves, and form fields.
if (!body._swipeWired) {
body._swipeWired = true;
let _sx = null, _sy = null;
body.addEventListener('touchstart', (e) => {
// Ignore swipes that start in a horizontally-scrollable tag row — those
// should scroll the chips, not flip the tab.
if (window.innerWidth > 768 || e.touches.length !== 1
|| e.target.closest('input, textarea, select, .doclib-lang-chips')) { _sx = null; return; }
_sx = e.touches[0].clientX; _sy = e.touches[0].clientY;
}, { passive: true });
body.addEventListener('touchend', (e) => {
if (_sx === null) return;
const dx = e.changedTouches[0].clientX - _sx;
const dy = e.changedTouches[0].clientY - _sy;
_sx = null;
// Require a clear horizontal swipe (>60px and mostly horizontal).
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
const tabs = [...body.querySelectorAll('.cookbook-tab')];
const idx = tabs.findIndex(t => t.classList.contains('active'));
if (idx < 0) return;
const next = dx < 0 ? idx + 1 : idx - 1; // swipe left → next tab
if (next >= 0 && next < tabs.length) tabs[next].click();
}, { passive: true });
}
// Sync server form DOM → _envState.servers
function _syncServers() {
const entries = document.querySelectorAll('.cookbook-server-entry');
const servers = [];
entries.forEach(entry => {
const name = entry.querySelector('.cookbook-srv-name')?.value?.trim() || '';
const host = entry.querySelector('.cookbook-srv-host')?.value?.trim() || '';
const port = entry.querySelector('.cookbook-srv-port')?.value?.trim() || '';
const env = entry.querySelector('.cookbook-srv-env')?.value || 'none';
const envPath = entry.querySelector('.cookbook-srv-path')?.value?.trim() || '';
const platform = entry.dataset.platform || '';
const dirs = [];
entry.querySelectorAll('.cookbook-modeldir-tag').forEach(tag => {
// Read from data attribute (authoritative) — never parse displayed text
const d = (tag.dataset.dir || '').replaceAll('✕', '').replaceAll('✖', '').trim();
if (d) dirs.push(d);
});
// Directory flagged as the download target ('' = default HF cache).
const dlEl = entry.querySelector('.cookbook-modeldir-dl.active');
const downloadDir = dlEl ? (dlEl.dataset.dlDir || '') : '';
servers.push({ name, host, port, env, envPath, modelDirs: dirs, downloadDir, platform });
});
_envState.servers = servers;
// Auto-default: when the user has configured EXACTLY ONE remote server
// and hasn't picked one yet, select it. Without this, the dropdown
// stays on "Local" so the eventual serve/scan/launch resolves to no
// remote host and the backend rejects the call with 403 (Forbidden),
// which read to the user as a permission bug.
if (!_envState.remoteHost) {
const remotes = servers.filter(s => !_isLocalEntry(s));
if (remotes.length === 1) {
_envState.remoteHost = remotes[0].host;
_envState.env = remotes[0].env || 'none';
_envState.envPath = remotes[0].envPath || '';
}
}
const activeSrv = servers.find(s => s.host === _envState.remoteHost);
_envState.platform = activeSrv?.platform || '';
localStorage.setItem('cookbook-last-state', JSON.stringify(_envStateForStorage()));
_saveTasks(_loadTasks());
// Reflect the auto-default selection into every server dropdown so the
// UI matches the resolved host. Done in a microtask so the dropdowns
// exist by the time we set their .value.
Promise.resolve().then(() => {
const _want = _currentServerValue();
document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => {
if (sel && sel.tagName === 'SELECT') sel.value = _want;
});
});
}
// Wire server form inputs
document.querySelectorAll('.cookbook-srv-name, .cookbook-srv-host, .cookbook-srv-port, .cookbook-srv-path').forEach(el => {
el.addEventListener('change', _syncServers);
});
document.querySelectorAll('.cookbook-srv-env').forEach(el => {
el.addEventListener('change', _syncServers);
});
// Server selector — the server is global, so switching it here re-scans the
// main Scan/Download list (#hwfit-list) for the new server's hardware too.
// (The trending sublist reloads via its own handler in the HF-latest wiring.)
const dlServer = document.getElementById('hwfit-dl-server');
if (dlServer) {
dlServer.addEventListener('change', () => {
_applyServerSelection(dlServer.value);
// Reset toggle state (no flicker) so the new server's hardware re-renders.
_resetGpuToggleState();
_hwfitFetch();
});
}
// Add server link — switch to Settings tab
const addServerLink = document.querySelector('.cookbook-dl-add-server');
if (addServerLink) {
addServerLink.addEventListener('click', () => {
const settingsTab = body.querySelector('.cookbook-tab[data-backend="Settings"]');
if (settingsTab) settingsTab.click();
});
}
// Cache server selector
const cacheServer = document.getElementById('hwfit-cache-server');
const cacheDirEl = document.getElementById('hwfit-cache-dir');
if (cacheServer) {
cacheServer.addEventListener('change', () => {
_applyServerSelection(cacheServer.value);
const val = cacheServer.value;
let srv;
if (val === 'local') {
srv = _envState.servers.find(_isLocalEntry) || _envState.servers[0] || {};
} else {
srv = _serverByVal(val) || {};
}
if (cacheDirEl) cacheDirEl.value = srv.modelDir || '~/.cache/huggingface/hub';
const dirsEl = document.querySelector('.cookbook-serve-dirs');
if (dirsEl) {
const dirs = (Array.isArray(srv.modelDirs) ? srv.modelDirs : [srv.modelDir || '~/.cache/huggingface/hub']).map(d => d.replaceAll('✕', '').replaceAll('✖', '').trim()).filter(Boolean);
dirsEl.innerHTML = dirs.map(d => `${esc(d)}`).join('') +
'edit';
dirsEl.querySelector('.cookbook-serve-dir-edit')?.addEventListener('click', () => {
const settingsTab = body.querySelector('.cookbook-tab[data-backend="Settings"]');
if (settingsTab) settingsTab.click();
});
}
_fetchCachedModels();
});
}
const scanBtn = document.getElementById('hwfit-cache-scan');
if (scanBtn) {
scanBtn.addEventListener('click', () => _fetchCachedModels());
}
const editDirsLink = document.querySelector('.cookbook-serve-dir-edit');
if (editDirsLink) {
editDirsLink.addEventListener('click', () => {
const settingsTab = body.querySelector('.cookbook-tab[data-backend="Settings"]');
if (settingsTab) settingsTab.click();
});
}
const depsServer = document.getElementById('hwfit-deps-server');
if (depsServer) {
depsServer.addEventListener('change', () => {
_applyServerSelection(depsServer.value);
// Re-fetch the package list for the newly selected server — the installed
// status is per-server, so the list must refresh on a server switch.
_fetchDependencies();
});
}
// "Rebuild llama.cpp" clears the cached build so the next serve recompiles.
// The serve bootstrap only builds llama-server when it is missing from PATH,
// so a host that first built CPU-only (no nvcc at build time) keeps reusing
// that binary forever; this is the lever to force a fresh GPU build after a
// CUDA/ROCm toolkit is installed.
const rebuildBtn = document.getElementById('cookbook-rebuild-engine');
if (rebuildBtn && !rebuildBtn._wired) {
rebuildBtn._wired = true;
rebuildBtn.addEventListener('click', async () => {
// Match _installDep: honor the Dependencies server selector so the clear
// runs on the same host the build runs on.
const sel = document.getElementById('hwfit-deps-server');
if (sel) _applyServerSelection(sel.value);
const host = _envState.remoteHost || '';
const where = host || 'this server';
if (!confirm(`Rebuild the llama.cpp engine on ${where}?\n\nThis clears the cached llama-server build so the next serve recompiles from source (with CUDA/HIP if a toolchain is present). It does not download or install anything.`)) return;
const _label = rebuildBtn.textContent;
rebuildBtn.disabled = true;
rebuildBtn.textContent = 'Clearing...';
try {
const res = await fetch('/api/cookbook/rebuild-engine', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
engine: 'llamacpp',
remote_host: host || undefined,
ssh_port: _getPort(host) || undefined,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
const reason = data.detail || data.error || `HTTP ${res.status}`;
uiModule.showToast('Rebuild failed: ' + String(reason).slice(0, 200));
} else {
uiModule.showToast(`Cleared llama.cpp build on ${where}. Re-launch the serve task to rebuild with GPU support.`);
}
} catch (err) {
uiModule.showToast('Rebuild failed: ' + err.message);
} finally {
rebuildBtn.disabled = false;
rebuildBtn.textContent = _label;
}
});
}
// "Reinstall" buttons for pip-based serving stacks (vllm, sglang). The
// deps list renders ASYNCHRONOUSLY after _fetchDependencies resolves, so
// attaching listeners directly here would miss buttons that don't exist
// yet. Use document-level delegation instead — the click always finds the
// right .cookbook-dep-reinstall button no matter when it was painted.
if (!document._cookbookReinstallWired) {
document._cookbookReinstallWired = true;
document.addEventListener('click', async (ev) => {
const btn = ev.target.closest?.('.cookbook-dep-reinstall');
if (!btn) return;
const pkg = btn.dataset.reinstallPkg || '';
if (!pkg) return;
ev.preventDefault();
ev.stopPropagation();
const sel = document.getElementById('hwfit-deps-server');
if (sel) _applyServerSelection(sel.value);
const host = _envState.remoteHost || '';
const where = host || 'this server';
if (!confirm(`Reinstall ${pkg} on ${where}?\n\nRuns "pip install --force-reinstall --no-deps ${pkg}" as a tmux task. Watch progress in the Running tab.`)) return;
const _venvPy = (_envState.env === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3`
: 'python3';
_launchServeTask(`reinstall-${pkg}`, 'pip-reinstall', `${_venvPy} -m pip install --force-reinstall --no-deps ${pkg}`);
}, true);
}
// Serve sort
const serveSort = document.getElementById('serve-sort');
if (serveSort) {
serveSort.addEventListener('change', () => {
if (_cachedAllModels.length) _rerenderCachedModels();
});
}
// Serve search
const serveSearch = document.getElementById('serve-search');
if (serveSearch) {
let _srvDebounce = null;
serveSearch.addEventListener('input', () => {
clearTimeout(_srvDebounce);
_srvDebounce = setTimeout(() => _filterCachedList(), 200);
});
}
// Select mode — bulk actions
const selectBtn = document.getElementById('hwfit-cache-select');
const bulkBar = document.getElementById('serve-bulk-bar');
if (selectBtn && bulkBar) {
selectBtn.addEventListener('click', () => {
const active = selectBtn.classList.toggle('active');
selectBtn.textContent = active ? 'Cancel' : 'Select';
bulkBar.classList.toggle('hidden', !active);
document.querySelectorAll('.serve-select-cb').forEach(dot => {
dot.style.display = active ? '' : 'none';
dot.classList.remove('selected');
});
_updateBulkCount();
});
document.getElementById('hwfit-cached-list')?.addEventListener('click', (e) => {
if (!selectBtn.classList.contains('active')) return;
const item = e.target.closest('.memory-item[data-repo]');
if (!item) return;
if (e.target.closest('a, .hwfit-cached-menu-btn, .memory-item-btn, .hwfit-serve-panel')) return;
const dot = item.querySelector('.serve-select-cb');
if (dot) {
dot.classList.toggle('selected');
_updateBulkCount();
}
});
function _updateBulkCount() {
const count = document.querySelectorAll('.serve-select-cb.selected').length;
const countEl = document.getElementById('serve-bulk-count');
if (countEl) countEl.textContent = count + ' selected';
}
document.getElementById('serve-bulk-cancel')?.addEventListener('click', () => {
selectBtn.classList.remove('active');
selectBtn.textContent = 'Select'; // reset label so the button doesn't stay reading "Cancel" after exit
bulkBar.classList.add('hidden');
document.querySelectorAll('.serve-select-cb').forEach(dot => { dot.style.display = 'none'; dot.classList.remove('selected'); });
});
document.getElementById('serve-bulk-delete')?.addEventListener('click', async () => {
const checked = document.querySelectorAll('.serve-select-cb.selected');
if (!checked.length) return;
const repos = [];
checked.forEach(dot => {
const item = dot.closest('.memory-item[data-repo]');
if (item?.dataset.repo) repos.push(item.dataset.repo);
});
if (!(await uiModule.styledConfirm(`Delete ${repos.length} model(s)? This removes cached files.`, { confirmText: 'Delete', danger: true }))) return;
for (const repo of repos) {
const item = document.querySelector(`.memory-item[data-repo="${repo}"]`);
if (item) await _deleteCachedModel(repo, item, true);
}
selectBtn.classList.remove('active');
selectBtn.textContent = 'Select'; // same reset as bulk-cancel
bulkBar.classList.add('hidden');
document.querySelectorAll('.serve-select-cb').forEach(dot => { dot.style.display = 'none'; dot.classList.remove('selected'); });
});
}
// Download input
const dlBtn = document.getElementById('cookbook-dl-btn');
const dlInput = document.getElementById('cookbook-dl-repo');
const dlCardToggle = document.getElementById('cookbook-download-card-toggle');
const dlCardBody = document.getElementById('cookbook-download-card-body');
const dlCardArrow = document.getElementById('cookbook-download-card-arrow');
if (dlCardToggle && dlCardBody) {
dlCardToggle.addEventListener('click', () => {
const isOpen = dlCardBody.style.display !== 'none';
dlCardBody.style.display = isOpen ? 'none' : 'block';
if (dlCardArrow) dlCardArrow.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)';
});
}
if (dlBtn && dlInput) {
function _stripHfUrl(input) {
let repo = input.trim();
// Strip Ollama-style "hf.co/" prefix if present (e.g. hf.co/unsloth/...:tag)
repo = repo.replace(/^hf\.co\//, '');
const hfMatch = repo.match(/^https?:\/\/huggingface\.co\/([^/]+\/[^/?#]+(?::[^/?#\s]+)?)/);
if (hfMatch) repo = hfMatch[1];
return repo;
}
// Split `org/repo:tag` (Ollama/llama.cpp style) into repo + include-glob.
// The `:tag` picks a specific GGUF quantization file from the repo.
function _splitRepoTag(raw) {
const m = raw.match(/^([^\s/:]+\/[^\s/:]+):([^\s/]+)$/);
if (!m) return { repo: raw, include: null };
return { repo: m[1], include: `*${m[2]}*` };
}
// Ollama-library name. Matches `qwen2.5:14b`, `llama3:latest`, and the
// (rare) `library/:` form which we normalize by stripping the
// namespace. The backend's _is_ollama_download check expects the same
// shape (no slash + has a colon).
function _ollamaName(raw) {
const stripped = raw.replace(/^library\//, '');
if (/^[A-Za-z0-9][A-Za-z0-9._-]{0,200}:[A-Za-z0-9][A-Za-z0-9._-]{0,200}$/.test(stripped)) {
return stripped;
}
return null;
}
const triggerDownload = () => {
const rawRepo = _stripHfUrl(dlInput.value);
if (!rawRepo) return;
const ollamaName = _ollamaName(rawRepo);
const { repo, include: autoInclude } = ollamaName ? { repo: ollamaName, include: null } : _splitRepoTag(rawRepo);
// HuggingFace repo IDs must be `org/model`. A bare model name would 404
// at snapshot_download time with a raw traceback, so reject it up front.
// Ollama names (single-segment with a tag) skip this check — they go
// through `ollama pull` server-side, not snapshot_download.
if (!ollamaName && !/^[^\s/]+\/[^\s/]+$/.test(repo)) {
uiModule.showToast('Enter a full HuggingFace repo ID like "org/model-name", or an Ollama name like "qwen2.5:14b".');
dlInput.focus();
return;
}
// Resolve the host straight from THIS window's server dropdown, by index
// into the (consistent) servers list. We deliberately don't use
// _envState.remoteHost — there can be multiple copies of the cookbook
// state in memory and they disagree on the active host, which is what sent
// downloads to the wrong server. The dropdown the user sees is the truth.
const dlSrv = document.getElementById('hwfit-dl-server');
const srvVal = dlSrv ? dlSrv.value : 'local';
let host = '';
if (srvVal !== 'local') {
host = _serverByVal(srvVal)?.host || '';
}
const _hsrv = _envState.servers.find(sv => sv.host === host) || {};
let env = host ? (_hsrv.env || 'none') : _envState.env;
let envPath = host ? (_hsrv.envPath || '') : _envState.envPath;
const payload = { repo_id: repo };
if (ollamaName) payload.backend = 'ollama';
if (autoInclude) payload.include = autoInclude;
if (_envState.hfToken && !ollamaName) payload.hf_token = _envState.hfToken;
if (host) { payload.remote_host = host; const _sp3 = _getPort(host); if (_sp3) payload.ssh_port = _sp3; }
const srvPlatform = _getPlatform(host);
if (srvPlatform) payload.platform = srvPlatform;
if (srvPlatform === 'windows') {
if (env === 'venv' && envPath) {
payload.env_prefix = '& ' + _psQuote(envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
} else if (env === 'conda' && envPath) {
payload.env_prefix = 'conda activate ' + _psQuote(envPath);
}
} else {
if (env === 'venv' && envPath) {
const p = envPath;
payload.env_prefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate');
} else if (env === 'conda' && envPath) {
payload.env_prefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(envPath);
}
}
const shortName = repo.split('/').pop();
_retryDownload(shortName, payload);
dlInput.value = '';
};
dlBtn.addEventListener('click', triggerDownload);
dlInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') triggerDownload();
});
}
// Latest HF models that fit — collapsible card list
// Foldable Download admin-card — h2 "Download" doubles as the chevron
// toggle; collapses the entire card body (description + input + HF list).
// State persisted to localStorage so the fold sticks across reloads.
const dlFold = document.getElementById('cookbook-dl-tab-fold');
const dlFoldBody = document.getElementById('cookbook-dl-tab-fold-body');
const dlFoldChevron = document.getElementById('cookbook-dl-tab-chevron');
if (dlFold && dlFoldBody && dlFoldChevron) {
const _setFolded = (folded, persist = true) => {
// Toggle via class so CSS transition animates the height/opacity
// — display:none was an instant on/off and felt jarring.
dlFoldBody.classList.toggle('is-folded', folded);
dlFoldChevron.textContent = folded ? '▸' : '▾';
dlFold.classList.toggle('is-folded', folded);
if (persist) {
try { localStorage.setItem('cookbook_dl_tab_folded_v1', folded ? '1' : '0'); } catch {}
}
};
dlFold.addEventListener('click', () => {
const folded = dlFoldBody.classList.contains('is-folded');
_setFolded(!folded);
});
// Auto-fold on any downward scroll inside the cookbook modal,
// and auto-expand when the user scrolls all the way back to the
// top of whichever scroller they're in. The chevron ▸ still
// toggles manually.
const _maybeFold = () => {
if (dlFoldBody.classList.contains('is-folded')) return;
_setFolded(true, /* persist */ false);
};
const _maybeExpand = () => {
if (!dlFoldBody.classList.contains('is-folded')) return;
_setFolded(false, /* persist */ false);
};
// Capture phase so scrolls on nested scrollers (.hwfit-list,
// .cookbook-body, .modal-content) all hit us.
const _modal = dlFold.closest('#cookbook-modal') || document;
const _lastY = new WeakMap();
_modal.addEventListener('scroll', (e) => {
const tgt = e.target;
if (!tgt || typeof tgt.scrollTop !== 'number') return;
// Ignore scrolls that originate INSIDE the Direct Download body
// (e.g. the Trending models list) — those are local to the
// section and shouldn't auto-fold the section that owns them.
if (dlFoldBody.contains && (tgt === dlFoldBody || dlFoldBody.contains(tgt))) return;
const y = tgt.scrollTop;
const prev = _lastY.get(tgt) || 0;
if (y > prev) _maybeFold();
else if (y <= 0) _maybeExpand();
_lastY.set(tgt, y);
}, true);
}
const hfToggle = document.getElementById('cookbook-hf-latest-toggle');
const hfArrow = document.getElementById('cookbook-hf-latest-arrow');
const hfList = document.getElementById('cookbook-hf-latest-list');
const hfRefresh = document.getElementById('cookbook-hf-latest-refresh');
if (hfToggle && hfList) {
let _loaded = false;
// Per-server VRAM cache so we don't re-probe on every expand
const _hwCache = {};
function _hfModelLooksAwqLike(m) {
const text = `${m?.repo_id || ''} ${(m?.tags || []).join(' ')}`.toLowerCase();
return /\b(awq|gptq|fp8|4bit|int4)\b/.test(text);
}
async function _getSelectedServerHw() {
// Prefer the "What Fits" dropdown (the main control that shows hardware);
// fall back to the download dropdown. This is the server the list ranks for.
const dlSrv = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server');
const val = dlSrv?.value || 'local';
let host = '';
let sshPort = '';
let platform = '';
if (val !== 'local') {
const s = _serverByVal(val);
if (s) {
host = s.host || '';
sshPort = s.port || '';
platform = s.platform || '';
}
}
const cacheKey = host || 'local';
if (_hwCache[cacheKey]) return _hwCache[cacheKey];
// Fetch system info for this server from hwfit
try {
const qp = new URLSearchParams();
if (host) qp.set('host', host);
if (sshPort) qp.set('ssh_port', sshPort);
if (platform) qp.set('platform', platform);
const r = await fetch(`/api/hwfit/system?${qp}`);
if (r.ok) {
const sys = await r.json();
const hw = { vram: sys?.gpu_vram_gb || 0, backend: String(sys?.backend || '').toLowerCase() };
_hwCache[cacheKey] = hw;
return hw;
}
} catch {}
_hwCache[cacheKey] = { vram: 0, backend: '' };
return _hwCache[cacheKey];
}
async function _loadLatest() {
// Match the Dependencies loader: whirlpool spinner + text label so the
// user gets immediate feedback while the scan runs.
hfList.innerHTML = '';
try {
const sp = (await import('./spinner.js')).default;
const _spin = sp.createWhirlpool(28);
_spin.element.style.cssText = 'margin:24px auto 0;display:block;';
hfList.appendChild(_spin.element);
const lbl = document.createElement('div');
lbl.className = 'hwfit-loading';
lbl.textContent = 'Scanning models…';
lbl.style.cssText = 'text-align:center;opacity:0.5;font-size:11px;margin-top:6px;';
hfList.appendChild(lbl);
} catch {
hfList.innerHTML = 'Scanning models…
';
}
const hwInfo = await _getSelectedServerHw();
const vram = hwInfo.vram || 0;
try {
let lastErr = '';
const _fetchLatest = async (v) => {
const res = await fetch(`/api/cookbook/hf-latest?vram_gb=${v}&limit=10`);
const data = await res.json();
if (data.error) lastErr = data.error; // HF API timeout/rate-limit etc.
return data.models || [];
};
let models = await _fetchLatest(vram);
// If the VRAM filter wiped everything out (often a flaky/zero hardware
// probe for a remote server — a huge-VRAM box should fit MORE, not
// fewer), fall back to the unfiltered trending list so something shows.
if (!models.length && vram > 0) {
models = await _fetchLatest(0);
}
if (['rocm', 'metal', 'mps', 'apple', 'generic', 'cpu'].includes(hwInfo.backend)) {
models = models.filter(m => !_hfModelLooksAwqLike(m));
}
if (!models.length) {
// Distinguish "the HF API failed" from "nothing matched" so an outage
// doesn't masquerade as no-fitting-models.
const msg = lastErr
? `Couldn't load trending models (${esc(lastErr)})`
: 'No trending models found';
hfList.innerHTML = `${msg}
`;
return;
}
let html = '';
for (const m of models) {
const shortName = m.repo_id.split('/').pop() || m.repo_id;
const org = m.repo_id.includes('/') ? m.repo_id.split('/')[0] : '';
const meta = [];
if (org) meta.push(esc(org));
if (m.needed_vram_gb) meta.push(`~${m.needed_vram_gb}GB`);
if (m.downloads) meta.push(`${m.downloads.toLocaleString()} downloads`);
const date = m.createdAt ? new Date(m.createdAt).toISOString().slice(0, 10) : '';
if (date) meta.push(date);
html += ``;
html += `
`;
html += `
`;
html += `
${meta.join(' \u00b7 ')}
`;
html += `
`;
html += `
`;
}
hfList.innerHTML = html;
// Wire card clicks → fill download input
hfList.querySelectorAll('.cookbook-hf-latest-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('a')) return;
if (dlInput) {
dlInput.value = card.dataset.repo;
dlInput.focus();
}
});
});
} catch (e) {
hfList.innerHTML = 'Failed to load
';
}
}
hfToggle.addEventListener('click', () => {
const isOpen = hfList.style.display !== 'none';
hfList.style.display = isOpen ? 'none' : 'flex';
if (hfArrow) hfArrow.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)';
if (!isOpen && !_loaded) {
_loaded = true;
_loadLatest();
}
});
if (hfRefresh) hfRefresh.addEventListener('click', (e) => {
e.stopPropagation();
_loaded = true;
_loadLatest();
// If list is hidden, open it
if (hfList.style.display === 'none') {
hfList.style.display = 'flex';
if (hfArrow) hfArrow.style.transform = 'rotate(90deg)';
}
});
// Re-fetch when a server dropdown changes — different server = different
// hardware/VRAM. Mark the list stale so it reloads for the new server even
// if it's currently collapsed (otherwise reopening showed the old server's
// models); reload immediately when it's open.
const _onServerChange = () => {
_loaded = false;
if (hfList.style.display !== 'none') { _loaded = true; _loadLatest(); }
};
document.getElementById('hwfit-dl-server')?.addEventListener('change', _onServerChange);
document.getElementById('hwfit-server-select')?.addEventListener('change', _onServerChange);
}
// Browse Ollama library popup removed — Engine = Ollama in the
// Scan / Download filter covers this use case. The handler below is a
// no-op now because the elements no longer exist.
const olToggle = document.getElementById('cookbook-ollama-toggle');
const olArrow = document.getElementById('cookbook-ollama-arrow');
const olList = document.getElementById('cookbook-ollama-list');
const olRefresh = document.getElementById('cookbook-ollama-refresh');
if (olToggle && olList) {
let _olLoaded = false;
async function _loadOllama(refresh = false) {
olList.innerHTML = 'Loading…
';
try {
const res = await fetch(`/api/cookbook/ollama/library${refresh ? '?refresh=1' : ''}`);
const data = await res.json();
const models = data.models || [];
if (!models.length) {
olList.innerHTML = 'No models
';
return;
}
let html = '';
for (const m of models) {
const sizes = Array.isArray(m.sizes) && m.sizes.length ? m.sizes : ['latest'];
const sizeChips = sizes.map(s => ``).join('');
html += ``;
html += `
`;
html += `
`;
if (m.description) html += `
${esc(m.description)}
`;
html += `
${sizeChips}
`;
html += `
`;
}
olList.innerHTML = html;
olList.querySelectorAll('.cookbook-ol-size').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const name = btn.dataset.name;
const size = btn.dataset.size;
if (dlInput) {
dlInput.value = `${name}:${size}`;
dlInput.focus();
}
});
});
// Clicking the card body (not a size chip / link) → default to first size
olList.querySelectorAll('.cookbook-ollama-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest('a') || e.target.closest('.cookbook-ol-size')) return;
const name = card.dataset.name;
const firstSize = card.querySelector('.cookbook-ol-size')?.dataset.size || 'latest';
if (dlInput) {
dlInput.value = `${name}:${firstSize}`;
dlInput.focus();
}
});
});
} catch (e) {
olList.innerHTML = 'Failed to load
';
}
}
olToggle.addEventListener('click', () => {
const isOpen = olList.style.display !== 'none';
olList.style.display = isOpen ? 'none' : 'flex';
if (olArrow) olArrow.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)';
if (!isOpen && !_olLoaded) {
_olLoaded = true;
_loadOllama(false);
}
});
if (olRefresh) olRefresh.addEventListener('click', (e) => {
e.stopPropagation();
_olLoaded = true;
_loadOllama(true);
if (olList.style.display === 'none') {
olList.style.display = 'flex';
if (olArrow) olArrow.style.transform = 'rotate(90deg)';
}
});
}
// Server add button, row removal, model-dir add/remove, and per-row wiring
// are ALL owned by cookbook-hwfit.js's _hwfitInit / _wireServerEntry.
// A duplicate add handler used to live here and fired alongside the hwfit
// one, appending two rows per click — removed.
// HF token — save on change
const hfInput = document.getElementById('hwfit-hftoken');
if (hfInput) {
hfInput.addEventListener('change', async () => {
const val = hfInput.value.trim();
_envState.hfToken = val;
try { await _persistEnvState(); } catch {}
if (val) {
_envState.hfTokenConfigured = true;
const masked = val.length > 6 ? val.slice(0, 3) + '…' + val.slice(-3) : '••••';
_envState.hfTokenMasked = masked;
hfInput.placeholder = `Stored (${masked}) - enter a new token to replace`;
hfInput.value = '';
let check = hfInput.parentNode.querySelector('.hwfit-hf-check');
if (!check) {
check = document.createElement('span');
check.className = 'hwfit-hf-check';
check.title = 'Token stored';
check.textContent = '✓';
check.style.cssText = 'font-weight:800;color:var(--green,#50fa7b);font-size:15px;line-height:1;flex-shrink:0;position:relative;top:2px;';
hfInput.parentNode.insertBefore(check, hfInput);
}
const flash = document.createElement('span');
flash.textContent = 'Saved';
flash.style.cssText = 'margin-left:8px;font-size:11px;color:var(--green,#50fa7b);opacity:0;transition:opacity 0.18s;flex-shrink:0;position:relative;top:1px;';
hfInput.parentNode.appendChild(flash);
requestAnimationFrame(() => { flash.style.opacity = '1'; });
setTimeout(() => { flash.style.opacity = '0'; setTimeout(() => flash.remove(), 220); }, 1400);
}
});
}
}
// ── Main render ──
// Build one server entry's HTML — shared by the Settings render loop AND the
// "+ Add server" handler, so a freshly-added server has the IDENTICAL layout
// (Model Directory header, default-server checkmark, trash delete, platform icon).
// forceRemote renders an editable remote entry even before a host is typed
// (a new server's host is empty, which would otherwise read as "Local").
export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) {
const isLocal = (forceRemote || isNew) ? false : (!s.host || s.host === 'local');
const envOpts = ['none', 'venv'].map(e => ``).join('');
let html = '';
html += ``;
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 += '';
html += '
';
html += '
';
html += '
';
html += '
';
html += '
';
// Search group
html += '';
html += '
';
// Foldable Download admin-card: clicking the h2 header collapses the
// entire card body (description + download input + HF latest section).
// State persisted to localStorage so the fold survives reloads.
const _dlTabFolded = (() => { try { return localStorage.getItem('cookbook_dl_tab_folded_v1') === '1'; } catch { return false; } })();
html += '
';
html += `
Direct Download${_dlTabFolded ? '▸' : '▾'}
`;
html += '';
html += `
`;
html += '
Download from HuggingFace by pasting model link, or download directly in the Scan section below.
';
html += '
';
// Section 1: Settings
const _es = _envState;
if (!_es.servers) _es.servers = [];
let _localSeen = false;
_es.servers = _es.servers.filter(s => {
const isLocal = !s.host || s.host.toLowerCase() === 'local';
if (isLocal) {
s.host = '';
if (_localSeen) return false;
_localSeen = true;
}
return true;
});
if (!_localSeen) {
_es.servers.unshift({ host: '', env: _es.env || 'none', envPath: _es.envPath || '', modelDir: '~/.cache/huggingface/hub' });
}
if (_es.remoteHost && !_es.servers.some(s => s.host === _es.remoteHost)) {
_es.servers.push({ host: _es.remoteHost, env: _es.env || 'none', envPath: _es.envPath || '', modelDir: '~/.cache/huggingface/hub' });
_persistEnvState();
}
// NOTE: deliberately do NOT auto-pick the first remote server when no host is
// selected. That fallback turned any momentarily-empty remoteHost (a clobber,
// a render before the user's pick registered) into the first saved server,
// silently sending downloads to the wrong server. An empty selection means Local; the user
// chooses a remote server explicitly via the dropdown.
// Manual download input — server picker on the same row as the repo input,
// on the left. The standalone "add server" button is gone (use Settings).
html += `
`;
if (_es.servers.length > 1) {
html += ``;
} else {
html += ``;
}
html += ``;
html += ``;
html += `
`;
// Ollama-library browse used to live here as its own collapsible dropdown,
// but that duplicated the Engine filter (which already has Ollama). The
// standalone UI is gone — to find Ollama models, set Engine = Ollama in
// the Scan / Download section below.
// Latest HF models that fit — collapsible card list
html += `
`;
html += `
`;
html += `
`;
html += `
`;
html += `
`;
html += `
`;
html += `
`; // /#cookbook-dl-tab-fold-body (whole Download card body)
// Search section
html += '
';
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 += '';
html += '
';
html += '
';
html += '
Dependencies
';
// Rebuild llama.cpp button moved into the llama_cpp dep row (see _depRow);
// having it in the title polluted the section header.
html += 'Server';
html += '';
html += '';
html += '
Optional packages that extend Odysseus capabilities.
';
html += '
';
html += '
';
// Settings tab
// Settings tab — split into two separate `.admin-card` blocks so the
// HF Token and Server config look like distinct panels (matches the
// Download tab's block-per-section layout).
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;