Files
odysseus/static/js/cookbookDownload.js
pewdiepie-archdaemon 562bc4dedc Cookbook polish: auto-reconnect, ctx slider fixes, scoring, lots of UI
Backend (services/hwfit + routes):
- VRAM column sort now shows global highest first (was special-cased to
  ascending then truncated top-N, which made "highest VRAM" mathematically
  unreachable). Every column path uses reverse=True for the truncation.
- Hardware probe cache TTL 30min -> 24h so changing filters doesn't keep
  re-probing the rig during a session; Rescan button still forces fresh.
- Multi-GPU rigs filter GGUF Q*/IQ quants (vLLM/SGLang can't serve them);
  default non-prequantized to BF16 on 2+ GPUs.
- AWQ / AWQ-8bit / GPTQ-8bit get a -1.0 quality penalty so FP8 wins ties.
- Version-aware tiebreaker (parse Mn.n / Vn) — MiniMax-M2.7 ranks above M2.5.
- hf_models.json: zai-org/GLM-5.1 added; zai-org/GLM-5 quantization flipped
  Q4_K_M -> BF16. DeepSeek-V4-Flash / -Pro + their -Base variants registered
  with new FP4-MoE-Mixed / FP8-Mixed quant keys (calibrated BPP from the
  actual 156 GB / 284 GB disk footprints).
- New FP4-MoE-Mixed + FP8-Mixed entries in QUANT_BPP / QUANT_SPEED_MULT /
  QUANT_QUALITY_PENALTY / QUANT_BYTES_PER_PARAM / PREQUANTIZED_PREFIXES.

Frontend — Scan/Download:
- Engine + Quant swapped in the toolbar; Quant defaults to "All".
- Ctx (range slider) ported from origin/main: 8k/16k/32k/50k/128k/Max. Drag
  re-sorts by vram ascending (smallest fitting first); back to Max → score.
- Ctx slider rail now visible — was background:transparent in a duplicate
  later-cascade rule. Hardcoded grey + !important.
- Search input moved to the far right of the toolbar.
- Type/Standard default; "Context" not uppercased; Search placeholder dimmed.
- Engine "?" + Quant "?" inline help chips inside their dropdown boxes.
- Fit-column dot toggles fit-only filter; un-toggling re-sorts by VRAM desc.
- Quant column truncates to 9 chars + ellipsis ("FP4-MoE-M..."), full in
  tooltip. Smart title-suffix strips the parts already in the repo name
  (QuantTrio/MiniMax-M2-AWQ + quant AWQ-4bit -> just "(4bit)").
- Conditional warning for safetensors models on non-GPU rigs only.
- Dependency Install / Installed / Installed▾ / N/A all 75.85px wide.
- Rebuild llama.cpp moved into the llama_cpp dep row, styled as a tag.
- Foldable Download admin-card (h2 chevron); line under h2 only when folded.
- HF token save gets a green ✓ + "Saved" flash.
- Cached scan no longer counts stalled rows as downloaded.
- Footer: "Request it →" link with GitHub mark to the public discussion
  (#1962) for model-add requests.

Frontend — Running tab:
- Strict download-finish check (DOWNLOAD_OK or /snapshots/, not bare
  "Download complete"). True overall % for multi-shard downloads:
  ((N-1)+frac)/total instead of hf_transfer's per-shard aggregate.
- ETA in the uptime ticker: "downloading: 12m 34s · ETA 1h 23m".
- Clear button kills the tmux session too; if the output still shows a
  live shard line, the pill is hidden + relabels as "reconnect" + revives
  on click.
- Self-heal: on cookbook open AND every bg-monitor cycle (10s, throttled
  to 8s), scan persisted done/error/crashed downloads and probe their
  tmux session — if alive, flip status back to running and reattach.
- Per-launch zombie probe: clicking Download on a model whose persisted
  state is done but tmux is still alive revives the existing task and
  refuses to start a duplicate.
- Pre-launch GPU probe: vllm / sglang / diffusers serve check
  /api/cookbook/gpus first; warns + confirms if no GPU is visible.
- Server-side state guard: rejects "done" POSTs for downloads lacking
  DOWNLOAD_OK / DOWNLOAD_FAILED / /snapshots/ when the last-mentioned
  shard is N<total — stale tabs can't poison persisted state any more.
- Running count includes tasks whose output looks active even if persisted
  status got stuck. Dir text on the running row, font matched to uptime.

Serve panel:
- Ctx text input always resets to model max on open (default 20000 when
  metadata is missing).
- Max Seqs default 8 -> 4. KV Cache dtype select 32px tall.
- Lightning icon on Launch (same as Action toggle).
- Diagnosis card simplified (no fold/copy/dismiss), suggestion font
  matches body; action buttons get icons on the left (Retry/Copy/Edit/
  Install/Kill/Switch/etc.).
- Incomplete-download serve warning when model status is
  downloading / stalled / has_incomplete.
- MTP "?" tooltip ("supported on a few model families … up to ~3× faster").
2026-06-03 20:25:25 +09:00

634 lines
24 KiB
JavaScript

// ============================================
// COOKBOOK DOWNLOAD SUB-MODULE
// Download tab: SSE streaming, model download,
// panel rendering, command building
// ============================================
import uiModule from './ui.js';
import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.js';
// Shared state/functions injected by init()
let _envState;
let _sshCmd;
let _getPort;
let _getPlatform;
let _isWindows;
let _buildEnvPrefix;
let _buildServeCmd;
let _detectBackend;
let _detectToolParser;
let _loadPresets;
let _savePresets;
let _copyText;
let _persistEnvState;
let modelLogo;
let esc;
let _addTask;
let _renderRunningTab;
let _loadTasks;
let _saveTasks;
// Storage keys
const SERVE_STATE_KEY = 'cookbook-serve-state';
// ── Panel field helpers ──
export function _setPanelField(panel, field, value) {
const input = panel.querySelector(`[data-field="${field}"]`);
if (!input) return;
if (input.tagName === 'SELECT') {
input.value = value;
} else if (input.type === 'checkbox') {
input.checked = !!value;
} else {
input.value = value;
}
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
export function _setPanelCheckbox(panel, field, checked) {
const cb = panel.querySelector(`[data-field="${field}"]`);
if (cb) {
cb.checked = checked;
cb.dispatchEvent(new Event('change', { bubbles: true }));
}
}
// ── Command builder: download ──
function _firstGgufSource(model) {
const sources = Array.isArray(model?.gguf_sources) ? model.gguf_sources : [];
return sources.find(src => src && src.repo) || null;
}
function _looksLikeGgufRepo(model) {
const haystack = `${model?.quant_repo || ''} ${model?.repo_id || ''} ${model?.path || ''} ${model?.name || ''}`.toLowerCase();
return !!model?.is_gguf || haystack.includes('gguf') || haystack.includes('.gguf');
}
function _ggufDownloadSource(model, backend) {
if (backend !== 'llamacpp') return null;
const source = _firstGgufSource(model);
if (source) return source;
if (_looksLikeGgufRepo(model)) {
const repo = model?.quant_repo || model?.repo_id || model?.name;
if (repo) return { repo };
}
return null;
}
function _ggufIncludePattern(model, source) {
if (source?.file) return source.file;
if (model?.quant) return `*${model.quant}*`;
return '*.gguf';
}
function _missingGgufMessage(model) {
const name = model?.name || 'this model';
if (/\bnvfp4\b/i.test(name)) {
return `${name} is an NVIDIA NVFP4 checkpoint, not a GGUF download. Pick the base model row with an Unsloth GGUF source, or paste the GGUF repo directly.`;
}
return `No GGUF source is configured for ${name}. Pick a model with a GGUF source, or paste the GGUF repo in Download.`;
}
function _bashQuote(value) {
return "'" + String(value ?? '').replace(/'/g, "'\\''") + "'";
}
function _missingGgufCommand(model) {
const msg = _missingGgufMessage(model);
if (_isWindows()) {
return `Write-Error ${JSON.stringify(msg)}; exit 1`;
}
return `printf '%s\\n' ${_bashQuote(msg)} >&2; exit 1`;
}
export function _buildDownloadCmd(model, backend) {
let cmd = '';
if (backend === 'ollama') {
cmd = `ollama pull ${model.name.split('/').pop().toLowerCase()}`;
} else {
const ggufSource = _ggufDownloadSource(model, backend);
if (backend === 'llamacpp' && !ggufSource) {
cmd = _missingGgufCommand(model);
} else {
const repo = ggufSource?.repo || model.name;
const includePattern = backend === 'llamacpp' ? _ggufIncludePattern(model, ggufSource) : null;
const includeArg = includePattern ? `, allow_patterns=["${includePattern.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]` : '';
// Reflect the server's download target in the preview (matches the real
// download path built server-side). '' = default HF cache.
const _dlDir = (_envState.servers.find(s => s.host === (_envState.remoteHost || '')) || {}).downloadDir || '';
const _localDirArg = _dlDir ? `, local_dir=os.path.expanduser('${_dlDir.replace(/\/$/, '')}/${repo.split('/').pop()}')` : '';
const _py = _isWindows() ? 'python' : 'python3';
cmd = `${_py} -u -c "
import sys, time, os
os.environ['HF_HUB_DISABLE_PROGRESS_BARS']='0'
os.environ['TQDM_DISABLE']='0'
_lp={}
class T:
def __init__(s,*a,**k):
s.it=a[0] if a else k.get('iterable');s.total=k.get('total');s.desc=k.get('desc','');s.n=0;s.st=time.time();s._c=False
if s.it is not None and s.total is None:
try: s.total=len(s.it)
except: pass
def __iter__(s):
if s.it is None: return
for i in s.it: yield i; s.update(1)
def __enter__(s): return s
def __exit__(s,*a): s.close()
def __len__(s): return s.total or 0
def update(s,n=1):
s.n+=n;t=s.total or 0
if t==0: return
now=time.time();k=id(s)
if now-_lp.get(k,0)<0.5 and s.n<t: return
_lp[k]=now;p=int(100*s.n/t);e=now-s.st;sp=s.n/e if e>0 else 0;d=(s.desc or '').strip()
if t>=1073741824: ds=f'{s.n/1073741824:.2f}';ts=f'{t/1073741824:.2f}GB';ss=f'{sp/1048576:.1f}MB/s'
elif t>=1048576: ds=f'{s.n/1048576:.1f}';ts=f'{t/1048576:.1f}MB';ss=f'{sp/1048576:.1f}MB/s'
else: ds=str(s.n);ts=str(t);ss=f'{sp:.0f}/s'
f=int(20*s.n/t);bar='#'*f+'-'*(20-f)
print(f'FILE {d} [{bar}] {p}% {ds}/{ts} {ss}',flush=True)
def set_description(s,d=None,refresh=True): s.desc=d or ''
def set_postfix(s,*a,**k): pass
def set_postfix_str(s,st='',refresh=True): pass
def reset(s,total=None): s.n=0;s.total=total if total is not None else s.total;s.st=time.time()
def refresh(s): pass
def close(s): s._c=True
def clear(s): pass
def display(s,msg=None,pos=None): pass
@property
def format_dict(s): return {'n':s.n,'total':s.total,'elapsed':time.time()-s.st}
import tqdm;tqdm.tqdm=T
try: import tqdm.auto;tqdm.auto.tqdm=T
except: pass
try:
import huggingface_hub.utils;huggingface_hub.utils.tqdm=T
if hasattr(huggingface_hub.utils,'_tqdm'): huggingface_hub.utils._tqdm.tqdm=T
except: pass
from huggingface_hub import snapshot_download
repo='${repo}'
print(f'START {repo}',flush=True)
try:
path=snapshot_download(repo${includeArg}${_localDirArg})
print(f'DONE {path}',flush=True)
except Exception as e:
print(f'ERROR {e}',file=sys.stderr,flush=True);sys.exit(1)
"`;
}
}
const prefix = _buildEnvPrefix();
let full = prefix ? prefix + ' ' + cmd : cmd;
if (_envState.remoteHost) {
full = _sshCmd(_envState.remoteHost, full, _getPort(_envState.remoteHost));
}
return full;
}
// ── Panel rendering helpers ──
function _getPanelFields(panel) {
const vals = {};
panel.querySelectorAll('.hwfit-f').forEach(el => {
const key = el.dataset.field;
if (!key) return;
if (el.type === 'checkbox') {
vals[key] = el.checked;
} else {
vals[key] = el.value;
}
});
return vals;
}
function _syncEnvFromPanel(panel) {
const f = _getPanelFields(panel);
if (f.env_type !== undefined) _envState.env = f.env_type;
if (f.env_path !== undefined) _envState.envPath = f.env_path;
if (f.hf_token !== undefined) _envState.hfToken = f.hf_token;
if (f.gpus !== undefined) _envState.gpus = f.gpus;
}
export function _wirePanelEvents(panel, model, backend) {
// Populate env fields from _envState
const envFields = {
env_type: _envState.env || 'none',
env_path: _envState.envPath || '',
hf_token: _envState.hfToken || '',
gpus: _envState.gpus || '',
};
for (const [field, val] of Object.entries(envFields)) {
const el = panel.querySelector(`[data-field="${field}"]`);
if (el && val) el.value = val;
}
// All inputs: update cmd preview + sync env state
panel.querySelectorAll('.hwfit-f').forEach(input => {
const evts = input.tagName === 'SELECT' ? ['change'] : ['input', 'change'];
for (const evt of evts) {
input.addEventListener(evt, () => {
_updatePanelCmd(panel, model, backend);
const f = input.dataset.field;
if (f === 'env_type') { _envState.env = input.value; _persistEnvState(); }
else if (f === 'env_path') { _envState.envPath = input.value; _persistEnvState(); }
else if (f === 'hf_token') { _envState.hfToken = input.value; _persistEnvState(); }
else if (f === 'gpus') { _envState.gpus = input.value; _persistEnvState(); }
});
}
});
// Download button
const dlBtn = panel.querySelector('.hwfit-dl-btn');
if (dlBtn) {
dlBtn.addEventListener('click', () => {
if (backend === 'ollama') {
_runPanelCmd(panel, _buildDownloadCmd(model, backend), { timeout: 0 });
} else {
_runModelDownload(panel, model, backend);
}
});
}
// Stop button
const stopBtn = panel.querySelector('.hwfit-stop-btn');
if (stopBtn) {
stopBtn.addEventListener('click', () => {
if (panel._cookbookAbort) panel._cookbookAbort.abort();
});
}
// Kill & close output button
const killBtn = panel.querySelector('.cookbook-output-kill');
if (killBtn) {
killBtn.addEventListener('click', () => {
if (panel._cookbookAbort) panel._cookbookAbort.abort();
const outputText = panel.querySelector('.cookbook-output-pre')?.textContent || '';
const tmuxMatch = outputText.match(/Started tmux session: (cookbook-[a-f0-9]+)/);
if (tmuxMatch) {
fetch('/api/shell/exec', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: `tmux kill-session -t ${tmuxMatch[1]} 2>/dev/null` }),
}).catch(() => {});
}
const wrap = panel.querySelector('.cookbook-output-wrap');
if (wrap) wrap.classList.add('hidden');
const output = panel.querySelector('.cookbook-output-pre');
if (output) output.textContent = '';
_clearDiagnosis(panel);
});
}
// Copy button
const copyBtn = panel.querySelector('.hwfit-copy-btn');
if (copyBtn) {
copyBtn.addEventListener('click', () => {
const cmd = panel.querySelector('.hwfit-panel-cmd')?.textContent || '';
_copyText(cmd).then(() => {
copyBtn.textContent = 'Copied';
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
});
});
}
// Save button
const saveBtn = panel.querySelector('.hwfit-save-btn');
if (saveBtn) {
saveBtn.addEventListener('click', () => {
const shortName = model.name.split('/').pop() || model.name;
const name = prompt('Preset name:', shortName);
if (!name) return;
const fields = _getPanelFields(panel);
const presets = _loadPresets();
presets.push({ name, model: model.name, backend, fields });
_savePresets(presets);
uiModule.showToast('Preset saved');
});
}
// Output copy button
const outputCopyBtn = panel.querySelector('.cookbook-output-copy');
if (outputCopyBtn) {
outputCopyBtn.addEventListener('click', (e) => {
e.stopPropagation();
const text = panel.querySelector('.cookbook-output-pre')?.textContent || '';
_copyText(text).then(() => {
const origHTML = outputCopyBtn.innerHTML;
outputCopyBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
outputCopyBtn.classList.add('copied');
setTimeout(() => {
outputCopyBtn.innerHTML = origHTML;
outputCopyBtn.classList.remove('copied');
}, 1500);
});
});
}
}
function _updatePanelCmd(panel, model, backend) {
const pre = panel.querySelector('.hwfit-panel-cmd');
if (!pre) return;
const f = _getPanelFields(panel);
_syncEnvFromPanel(panel);
if (backend === 'llamacpp') {
f._gguf_path = (model.gguf_sources && model.gguf_sources.length)
? model.gguf_sources[0].file || 'model.gguf'
: 'model.gguf';
}
const cmd = _buildServeCmd(f, model.name, backend);
const prefix = _buildEnvPrefix();
let full = prefix ? prefix + ' ' + cmd : cmd;
if (f.extra && f.extra.trim()) full += ' ' + f.extra.trim();
if (_envState.remoteHost) full = _sshCmd(_envState.remoteHost, full, _getPort(_envState.remoteHost));
pre.textContent = full;
}
// ── SSE streaming ──
export async function _runPanelCmd(panel, cmd, opts = {}) {
const outputWrap = panel.querySelector('.cookbook-output-wrap');
const output = panel.querySelector('.cookbook-output-pre');
if (outputWrap) outputWrap.classList.remove('hidden');
output.classList.remove('cookbook-output-error');
output.textContent = '';
_clearDiagnosis(panel);
const controller = new AbortController();
panel._cookbookAbort = controller;
const serveBtn = panel.querySelector('.hwfit-serve-btn');
const stopBtn = panel.querySelector('.hwfit-stop-btn');
if (serveBtn) serveBtn.style.display = 'none';
if (stopBtn) stopBtn.style.display = '';
let fullOutput = '';
const payload = { command: cmd };
if (opts.timeout !== undefined) payload.timeout = opts.timeout;
if (opts.use_pty) payload.use_pty = true;
if (opts.use_tmux) payload.use_tmux = true;
try {
const res = await fetch('/api/shell/stream', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
});
if (!res.ok) {
output.classList.add('cookbook-output-error');
output.textContent = 'HTTP ' + res.status + ': ' + (await res.text());
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
let exitCode = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let idx;
while ((idx = buf.indexOf('\n\n')) !== -1) {
const chunk = buf.slice(0, idx);
buf = buf.slice(idx + 2);
for (const line of chunk.split('\n')) {
if (!line.startsWith('data: ')) continue;
try {
const ev = JSON.parse(line.slice(6));
if (ev.data !== undefined) {
const isProgress = /^FILE .+\d+%/.test(ev.data) || /\d+%\|/.test(ev.data);
if (isProgress && output.textContent) {
const lines = output.textContent.split('\n');
const lastLine = lines[lines.length - 1] || '';
const curFile = ev.data.match(/^FILE\s+(\S+)/)?.[1];
const prevFile = lastLine.match(/^FILE\s+(\S+)/)?.[1];
if (curFile && prevFile && curFile === prevFile) {
lines[lines.length - 1] = ev.data;
output.textContent = lines.join('\n');
} else {
output.textContent += '\n' + ev.data;
}
} else {
output.textContent += (output.textContent ? '\n' : '') + ev.data;
}
output.scrollTop = output.scrollHeight;
fullOutput += ev.data + '\n';
const diag = _diagnose(fullOutput);
if (diag) _showDiagnosis(panel, diag, fullOutput);
}
if (ev.exit_code !== undefined) {
exitCode = ev.exit_code;
}
} catch (_) {}
}
}
}
if (!output.textContent) output.textContent = '(no output)';
if (exitCode !== null && exitCode !== 0) {
output.classList.add('cookbook-output-error');
const diag = _diagnose(fullOutput);
if (diag) _showDiagnosis(panel, diag, fullOutput);
}
} catch (err) {
if (err.name === 'AbortError') {
output.textContent += (output.textContent ? '\n' : '') + '(stopped)';
} else {
output.classList.add('cookbook-output-error');
output.textContent += (output.textContent ? '\n' : '') + 'Request failed: ' + err.message;
}
} finally {
if (serveBtn) serveBtn.style.display = '';
if (stopBtn) stopBtn.style.display = 'none';
delete panel._cookbookAbort;
}
}
// ── Model download (dedicated endpoint, tmux-backed) ──
export async function _runModelDownload(panel, model, backend, hostOverride) {
const ggufSource = _ggufDownloadSource(model, backend);
if (backend === 'llamacpp' && !ggufSource) {
uiModule.showToast(_missingGgufMessage(model));
return;
}
const repo = ggufSource?.repo || model.quant_repo || model.name;
const include = backend === 'llamacpp' ? _ggufIncludePattern(model, ggufSource) : null;
_syncEnvFromPanel(panel);
// The host is whatever the caller resolved from the dropdown the user picked
// (passed explicitly as hostOverride). We do NOT trust _envState.remoteHost
// here: there can be more than one copy of the cookbook state in memory and
// they disagree on the active host. The servers LIST is consistent, so we look
// up the matching server to get its env / path / platform / port.
let host;
if (hostOverride !== undefined) {
host = hostOverride || '';
} else {
// No explicit host passed: resolve from the visible server dropdown rather
// than _envState.remoteHost (unreliable — multiple state copies disagree).
const ssEl = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server');
// Dropdown values are host strings now ('local' for local); resolve by host
// (numeric fallback for any stale value).
const _ssv = ssEl ? ssEl.value : null;
const _dsrv = (_ssv && _ssv !== 'local') ? (_envState.servers.find(s => s.host === _ssv) || _envState.servers[parseInt(_ssv)]) : null;
if (_dsrv) {
host = _dsrv.host;
} else if (ssEl && ssEl.value === 'local') {
host = '';
} else {
host = _envState.remoteHost || '';
}
}
const srv = _envState.servers.find(s => s.host === host) || {};
const env = host ? (srv.env || 'none') : (_envState.env || 'none');
const envPath = host ? (srv.envPath || '') : (_envState.envPath || '');
const platform = host ? (srv.platform || '') : (_envState.platform || '');
const isWin = host ? (platform === 'windows') : _isWindows();
const payload = { repo_id: repo };
if (include) payload.include = include;
// Large downloads are where hf_transfer most often dies near the end. Use the
// plain HuggingFace downloader up front for big model files; it is slower, but
// resumes cached partials more reliably.
if ((model.required_gb || 0) >= 10 || backend === 'llamacpp') payload.disable_hf_transfer = true;
if (_envState.hfToken) payload.hf_token = _envState.hfToken;
if (host) { payload.remote_host = host; const _sp = _getPort(host); if (_sp) payload.ssh_port = _sp; }
if (platform) payload.platform = platform;
// If this server has a directory flagged as the download target, send it so
// the backend downloads into <dir>/<model> instead of the default HF cache.
if (srv.downloadDir) payload.local_dir = srv.downloadDir;
if (isWin) {
if (env === 'venv' && envPath) {
payload.env_prefix = '& ' + (envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
} else if (env === 'conda' && envPath) {
payload.env_prefix = 'conda activate ' + envPath;
}
} else {
if (env === 'venv' && envPath) {
payload.env_prefix = 'source ' + (envPath.endsWith('/bin/activate') ? envPath : envPath + '/bin/activate');
} else if (env === 'conda' && envPath) {
payload.env_prefix = 'eval "$(conda shell.bash hook)" && conda activate ' + envPath;
}
}
const shortName = (model.name || repo).split('/').pop();
const targetHost = host || 'local';
const tasks = _loadTasks();
const sameDownload = (t) => {
if (!t || t.type !== 'download') return false;
const tRepo = t?.payload?.repo_id || t?.repo_id || t?.repo || t?.name || '';
const tHost = t?.remoteHost || t?.payload?.remote_host || 'local';
return String(tRepo) === String(payload.repo_id) && String(tHost || 'local') === String(targetHost);
};
const duplicate = tasks.find(t => sameDownload(t) && (t.status === 'running' || t.status === 'queued'));
if (duplicate) {
_renderRunningTab();
uiModule.showToast(`${shortName} is already ${duplicate.status === 'queued' ? 'queued' : 'downloading'}`);
return;
}
// Also catch zombie "done" tasks — the cookbook may have lost track of a
// download (server restart, stale state) while its tmux session is still
// alive on the host. Probe it; if alive, flip back to running + treat as
// duplicate so we don't kick off a second concurrent download writing to
// the same target dir.
const zombieCandidate = tasks.find(t => sameDownload(t)
&& ['done', 'error', 'crashed', 'stopped'].includes(t.status)
&& t.sessionId && !String(t.sessionId).startsWith('queue-'));
if (zombieCandidate) {
try {
const _zh = zombieCandidate.remoteHost || '';
const _zPort = (_envState.servers || []).find(s => s.host === _zh)?.port;
const _sshPf = _zh ? `ssh ${_zPort && _zPort !== '22' ? `-p ${_zPort} ` : ''}${_zh} '` : '';
const _sshSf = _zh ? `'` : '';
const _probeCmd = `${_sshPf}tmux has-session -t ${zombieCandidate.sessionId} 2>/dev/null${_sshSf}`;
const _r = await fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: _probeCmd, timeout: 5 }),
});
const _d = await _r.json();
if (_d.exit_code === 0) {
// tmux still alive → not actually done. Revive + tell the user.
const _fresh = _loadTasks();
const _ft = _fresh.find(t => t.sessionId === zombieCandidate.sessionId);
if (_ft) {
_ft.status = 'running';
_ft._selfHealed = true;
_saveTasks(_fresh);
}
_renderRunningTab();
uiModule.showToast(`${shortName} is still downloading (was marked finished after a restart — revived)`);
return;
}
} catch { /* probe failed — fall through and let the user launch */ }
}
const activeOnHost = tasks.find(t => t.type === 'download' && (t.status === 'running' || t.status === 'queued') && (t.remoteHost || 'local') === targetHost);
if (activeOnHost) {
const queueId = `queue-${Date.now().toString(36)}`;
const allTasks = _loadTasks();
allTasks.push({ id: queueId, sessionId: queueId, name: shortName, type: 'download', status: 'queued', output: '', ts: Date.now(), payload, remoteHost: host });
_saveTasks(allTasks);
_renderRunningTab();
uiModule.showToast(`Queued ${shortName} — waiting for current download`);
return;
}
try {
const res = await fetch('/api/model/download', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
// Errors carry actionable text (e.g. "tmux is required …"); keep them up
// long enough to read, matching the serve path's duration (issue #1355).
uiModule.showToast('Download failed: HTTP ' + res.status, 9000);
return;
}
const data = await res.json();
if (!data.ok) {
uiModule.showToast('Download failed: ' + (data.error || ''), 9000);
return;
}
_addTask(data.session_id, shortName, 'download', payload);
uiModule.showToast(`Downloading ${shortName}...`);
} catch (e) {
uiModule.showToast('Download failed: ' + e.message, 9000);
}
}
// ── Init ──
export function initDownload(shared) {
_envState = shared._envState;
_sshCmd = shared._sshCmd;
_getPort = shared._getPort;
_getPlatform = shared._getPlatform;
_isWindows = shared._isWindows;
_buildEnvPrefix = shared._buildEnvPrefix;
_buildServeCmd = shared._buildServeCmd;
_detectBackend = shared._detectBackend;
_detectToolParser = shared._detectToolParser;
_loadPresets = shared._loadPresets;
_savePresets = shared._savePresets;
_copyText = shared._copyText;
_persistEnvState = shared._persistEnvState;
modelLogo = shared.modelLogo;
esc = shared.esc;
_addTask = shared._addTask;
_renderRunningTab = shared._renderRunningTab;
_loadTasks = shared._loadTasks;
_saveTasks = shared._saveTasks;
}