PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/cookbookDownload.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 17:00:11 UTC 24,323 bytes, 633 lines download raw bytes

1// ============================================
2// COOKBOOK DOWNLOAD SUB-MODULE
3// Download tab: SSE streaming, model download,
4// panel rendering, command building
5// ============================================
6
7import uiModule from './ui.js';
8import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.js';
9
10// Shared state/functions injected by init()
11let _envState;
12let _sshCmd;
13let _getPort;
14let _getPlatform;
15let _isWindows;
16let _buildEnvPrefix;
17let _buildServeCmd;
18let _detectBackend;
19let _detectToolParser;
20let _loadPresets;
21let _savePresets;
22let _copyText;
23let _persistEnvState;
24let modelLogo;
25let esc;
26let _addTask;
27let _renderRunningTab;
28let _loadTasks;
29let _saveTasks;
30
31// Storage keys
32const SERVE_STATE_KEY = 'cookbook-serve-state';
33
34// ── Panel field helpers ──
35
36export function _setPanelField(panel, field, value) {
37  const input = panel.querySelector(`[data-field="${field}"]`);
38  if (!input) return;
39  if (input.tagName === 'SELECT') {
40    input.value = value;
41  } else if (input.type === 'checkbox') {
42    input.checked = !!value;
43  } else {
44    input.value = value;
45  }
46  input.dispatchEvent(new Event('input', { bubbles: true }));
47  input.dispatchEvent(new Event('change', { bubbles: true }));
48}
49
50export function _setPanelCheckbox(panel, field, checked) {
51  const cb = panel.querySelector(`[data-field="${field}"]`);
52  if (cb) {
53    cb.checked = checked;
54    cb.dispatchEvent(new Event('change', { bubbles: true }));
55  }
56}
57
58// ── Command builder: download ──
59
60function _firstGgufSource(model) {
61  const sources = Array.isArray(model?.gguf_sources) ? model.gguf_sources : [];
62  return sources.find(src => src && src.repo) || null;
63}
64
65function _looksLikeGgufRepo(model) {
66  const haystack = `${model?.quant_repo || ''} ${model?.repo_id || ''} ${model?.path || ''} ${model?.name || ''}`.toLowerCase();
67  return !!model?.is_gguf || haystack.includes('gguf') || haystack.includes('.gguf');
68}
69
70function _ggufDownloadSource(model, backend) {
71  if (backend !== 'llamacpp') return null;
72  const source = _firstGgufSource(model);
73  if (source) return source;
74  if (_looksLikeGgufRepo(model)) {
75    const repo = model?.quant_repo || model?.repo_id || model?.name;
76    if (repo) return { repo };
77  }
78  return null;
79}
80
81function _ggufIncludePattern(model, source) {
82  if (source?.file) return source.file;
83  if (model?.quant) return `*${model.quant}*`;
84  return '*.gguf';
85}
86
87function _missingGgufMessage(model) {
88  const name = model?.name || 'this model';
89  if (/\bnvfp4\b/i.test(name)) {
90    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.`;
91  }
92  return `No GGUF source is configured for ${name}. Pick a model with a GGUF source, or paste the GGUF repo in Download.`;
93}
94
95function _bashQuote(value) {
96  return "'" + String(value ?? '').replace(/'/g, "'\\''") + "'";
97}
98
99function _missingGgufCommand(model) {
100  const msg = _missingGgufMessage(model);
101  if (_isWindows()) {
102    return `Write-Error ${JSON.stringify(msg)}; exit 1`;
103  }
104  return `printf '%s\\n' ${_bashQuote(msg)} >&2; exit 1`;
105}
106
107export function _buildDownloadCmd(model, backend) {
108  let cmd = '';
109  if (backend === 'ollama') {
110    cmd = `ollama pull ${model.name.split('/').pop().toLowerCase()}`;
111  } else {
112    const ggufSource = _ggufDownloadSource(model, backend);
113    if (backend === 'llamacpp' && !ggufSource) {
114      cmd = _missingGgufCommand(model);
115    } else {
116      const repo = ggufSource?.repo || model.name;
117      const includePattern = backend === 'llamacpp' ? _ggufIncludePattern(model, ggufSource) : null;
118      const includeArg = includePattern ? `, allow_patterns=["${includePattern.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]` : '';
119      // Reflect the server's download target in the preview (matches the real
120      // download path built server-side). '' = default HF cache.
121      const _dlDir = (_envState.servers.find(s => s.host === (_envState.remoteHost || '')) || {}).downloadDir || '';
122      const _localDirArg = _dlDir ? `, local_dir=os.path.expanduser('${_dlDir.replace(/\/$/, '')}/${repo.split('/').pop()}')` : '';
123      const _py = _isWindows() ? 'python' : 'python3';
124      cmd = `${_py} -u -c "
125import sys, time, os
126os.environ['HF_HUB_DISABLE_PROGRESS_BARS']='0'
127os.environ['TQDM_DISABLE']='0'
128_lp={}
129class T:
130 def __init__(s,*a,**k):
131  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
132  if s.it is not None and s.total is None:
133   try: s.total=len(s.it)
134   except: pass
135 def __iter__(s):
136  if s.it is None: return
137  for i in s.it: yield i; s.update(1)
138 def __enter__(s): return s
139 def __exit__(s,*a): s.close()
140 def __len__(s): return s.total or 0
141 def update(s,n=1):
142  s.n+=n;t=s.total or 0
143  if t==0: return
144  now=time.time();k=id(s)
145  if now-_lp.get(k,0)<0.5 and s.n<t: return
146  _lp[k]=now;p=int(100*s.n/t);e=now-s.st;sp=s.n/e if e>
1460 else 0;d=(s.desc or '').strip()
147  if t>=1073741824: ds=f'{s.n/1073741824:.2f}';ts=f'{t/1073741824:.2f}GB';ss=f'{sp/1048576:.1f}MB/s'
148  elif t>=1048576: ds=f'{s.n/1048576:.1f}';ts=f'{t/1048576:.1f}MB';ss=f'{sp/1048576:.1f}MB/s'
149  else: ds=str(s.n);ts=str(t);ss=f'{sp:.0f}/s'
150  f=int(20*s.n/t);bar='#'*f+'-'*(20-f)
151  print(f'FILE {d} [{bar}] {p}% {ds}/{ts} {ss}',flush=True)
152 def set_description(s,d=None,refresh=True): s.desc=d or ''
153 def set_postfix(s,*a,**k): pass
154 def set_postfix_str(s,st='',refresh=True): pass
155 def reset(s,total=None): s.n=0;s.total=total if total is not None else s.total;s.st=time.time()
156 def refresh(s): pass
157 def close(s): s._c=True
158 def clear(s): pass
159 def display(s,msg=None,pos=None): pass
160 @property
161 def format_dict(s): return {'n':s.n,'total':s.total,'elapsed':time.time()-s.st}
162import tqdm;tqdm.tqdm=T
163try: import tqdm.auto;tqdm.auto.tqdm=T
164except: pass
165try:
166 import huggingface_hub.utils;huggingface_hub.utils.tqdm=T
167 if hasattr(huggingface_hub.utils,'_tqdm'): huggingface_hub.utils._tqdm.tqdm=T
168except: pass
169from huggingface_hub import snapshot_download
170repo='${repo}'
171print(f'START {repo}',flush=True)
172try:
173 path=snapshot_download(repo${includeArg}${_localDirArg})
174 print(f'DONE {path}',flush=True)
175except Exception as e:
176 print(f'ERROR {e}',file=sys.stderr,flush=True);sys.exit(1)
177"`;
178    }
179  }
180  const prefix = _buildEnvPrefix();
181  let full = prefix ? prefix + ' ' + cmd : cmd;
182  if (_envState.remoteHost) {
183    full = _sshCmd(_envState.remoteHost, full, _getPort(_envState.remoteHost));
184  }
185  return full;
186}
187
188// ── Panel rendering helpers ──
189
190function _getPanelFields(panel) {
191  const vals = {};
192  panel.querySelectorAll('.hwfit-f').forEach(el => {
193    const key = el.dataset.field;
194    if (!key) return;
195    if (el.type === 'checkbox') {
196      vals[key] = el.checked;
197    } else {
198      vals[key] = el.value;
199    }
200  });
201  return vals;
202}
203
204function _syncEnvFromPanel(panel) {
205  const f = _getPanelFields(panel);
206  if (f.env_type !== undefined) _envState.env = f.env_type;
207  if (f.env_path !== undefined) _envState.envPath = f.env_path;
208  if (f.hf_token !== undefined) _envState.hfToken = f.hf_token;
209  if (f.gpus !== undefined) _envState.gpus = f.gpus;
210}
211
212export function _wirePanelEvents(panel, model, backend) {
213  // Populate env fields from _envState
214  const envFields = {
215    env_type: _envState.env || 'none',
216    env_path: _envState.envPath || '',
217    hf_token: _envState.hfToken || '',
218    gpus: _envState.gpus || '',
219  };
220  for (const [field, val] of Object.entries(envFields)) {
221    const el = panel.querySelector(`[data-field="${field}"]`);
222    if (el && val) el.value = val;
223  }
224
225  // All inputs: update cmd preview + sync env state
226  panel.querySelectorAll('.hwfit-f').forEach(input => {
227    const evts = input.tagName === 'SELECT' ? ['change'] : ['input', 'change'];
228    for (const evt of evts) {
229      input.addEventListener(evt, () => {
230        _updatePanelCmd(panel, model, backend);
231        const f = input.dataset.field;
232        if (f === 'env_type') { _envState.env = input.value; _persistEnvState(); }
233        else if (f === 'env_path') { _envState.envPath = input.value; _persistEnvState(); }
234        else if (f === 'hf_token') { _envState.hfToken = input.value; _persistEnvState(); }
235        else if (f === 'gpus') { _envState.gpus = input.value; _persistEnvState(); }
236      });
237    }
238  });
239
240  // Download button
241  const dlBtn = panel.querySelector('.hwfit-dl-btn');
242  if (dlBtn) {
243    dlBtn.addEventListener('click', () => {
244      if (backend === 'ollama') {
245        _runPanelCmd(panel, _buildDownloadCmd(model, backend), { timeout: 0 });
246      } else {
247        _runModelDownload(panel, model, backend);
248      }
249    });
250  }
251
252  // Stop button
253  const stopBtn = panel.querySelector('.hwfit-stop-btn');
254  if (stopBtn) {
255    stopBtn.addEventListener('click', () => {
256      if (panel._cookbookAbort) panel._cookbookAbort.abort();
257    });
258  }
259
260  // Kill & close output button
261  const killBtn = panel.querySelector('.cookbook-output-kill');
262  if (killBtn) {
263    killBtn.addEventListener('click', () => {
264      if (panel._cookbookAbort) panel._cookbookAbort.abort();
265      const outputText = panel.querySelector('.cookbook-output-pre')?.textContent || '';
266      const tmuxMatch = outputText.match(/Started tmux session: (cookbook-[a-f0-9]+)/);
267      if (tmuxMatch) {
268        fetch('/api/shell/exec', {
269          method: 'POST',
270          credentials: 'same-origin',
271          headers: { 'Content-Type': 'application/json' },
272          body: JSON.stringify({ command: `tmux kill-session -t ${tmuxMatch[1]} 2>/dev/null` }),
273        }).catch(() => {});
274      }
275      const wrap = panel.querySelector('.cookbook-output-wrap');
276      if (wrap) wrap.classList.add('hidden');
277      const output = panel.querySelector('.cookbook-output-pre');
278      if (output) output.textContent = '';
279      _clearDiagnosis(panel);
280    });
281  }
282
283  // Copy button
284  const copyBtn = panel.querySelector('.hwfit-copy-btn');
285  if (copyBtn) {
286    copyBtn.addEventListener('click', () => {
287      const cmd = panel.querySelector('.hwfit-panel-cmd')?.textContent || '';
288      _copyText(cmd).then(() => {
289        copyBtn.textContent = 'Copied';
290        setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
291      });
292    });
293  }
294
295  // Save button
296  const saveBtn = panel.querySelector('.hwfit-save-btn');
297  if (saveBtn) {
298    saveBtn.addEventListener('click', () => {
299      const shortName = model.name.split('/').pop() || model.name;
300      const name = prompt('Preset name:', shortName);
301      if (!name) return;
302      const fields = _getPanelFields(panel);
303      const presets = _loadPresets();
304      presets.push({ name, model: model.name, backend, fields });
305      _savePresets(presets);
306      uiModule.showToast('Preset saved');
307    });
308  }
309
310  // Output copy button
311  const outputCopyBtn = panel.querySelector('.cookbook-output-copy');
312  if (outputCopyBtn) {
313    outputCopyBtn.addEventListener('click', (e) => {
314      e.stopPropagation();
315      const text = panel.querySelector('.cookbook-output-pre')?.textContent || '';
316      _copyText(text).then(() => {
317        const origHTML = outputCopyBtn.innerHTML;
318        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>';
319        outputCopyBtn.classList.add('copied');
320        setTimeout(() => {
321          outputCopyBtn.innerHTML = origHTML;
322          outputCopyBtn.classList.remove('copied');
323        }, 1500);
324      });
325    });
326  }
327}
328
329function _updatePanelCmd(panel, model, backend) {
330  const pre = panel.querySelector('.hwfit-panel-cmd');
331  if (!pre) return;
332  const f = _getPanelFields(panel);
333  _syncEnvFromPanel(panel);
334  if (backend === 'llamacpp') {
335    f._gguf_path = (model.gguf_sources && model.gguf_sources.length)
336      ? model.gguf_sources[0].file || 'model.gguf'
337      : 'model.gguf';
338  }
339  const cmd = _buildServeCmd(f, model.name, backend);
340  const prefix = _buildEnvPrefix();
341  let full = prefix ? prefix + ' ' + cmd : cmd;
342  if (f.extra && f.extra.trim()) full += ' ' + f.extra.trim();
343  if (_envState.remoteHost) full = _sshCmd(_envState.remoteHost, full, _getPort(_envState.remoteHost));
344  pre.textContent = full;
345}
346
347// ── SSE streaming ──
348
349export async function _runPanelCmd(panel, cmd, opts = {}) {
350  const outputWrap = panel.querySelector('.cookbook-output-wrap');
351  const output = panel.querySelector('.cookbook-output-pre');
352  if (outputWrap) outputWrap.classList.remove('hidden');
353  output.classList.remove('cookbook-output-error');
354  output.textContent = '';
355  _clearDiagnosis(panel);
356
357  const controller = new AbortController();
358  panel._cookbookAbort = controller;
359
360  const serveBtn = panel.querySelector('.hwfit-serve-btn');
361  const stopBtn = panel.querySelector('.hwfit-stop-btn');
362  if (serveBtn) serveBtn.style.display = 'none';
363  if (stopBtn) stopBtn.style.display = '';
364
365  let fullOutput = '';
366  const payload = { command: cmd };
367  if (opts.timeout !== undefined) payload.timeout = opts.timeout;
368  if (opts.use_pty) payload.use_pty = true;
369  if (opts.use_tmux) payload.use_tmux = true;
370
371  try {
372    const res = await fetch('/api/shell/stream', {
373      method: 'POST',
374      credentials: 'same-origin',
375      headers: { 'Content-Type': 'application/json' },
376      body: JSON.stringify(payload),
377      signal: controller.signal,
378    });
379
380    if (!res.ok) {
381      output.classList.add('cookbook-output-error');
382      output.textContent = 'HTTP ' + res.status + ': ' + (await res.text());
383      return;
384    }
385
386    const reader = res.body.getReader();
387    const decoder = new TextDecoder();
388    let buf = '';
389    let exitCode = null;
390
391    while (true) {
392      const { done, value } = await reader.read();
393      if (done) break;
394      buf += decoder.decode(value, { stream: true });
395
396      let idx;
397      while ((idx = buf.indexOf('\n\n')) !== -1) {
398        const chunk = buf.slice(0, idx);
399        buf = buf.slice(idx + 2);
400        for (const line of chunk.split('\n')) {
401          if (!line.startsWith('data: ')) continue;
402          try {
403            const ev = JSON.parse(line.slice(6));
404            if (ev.data !== undefined) {
405              const isProgress = /^FILE .+\d+%/.test(ev.data) || /\d+%\|/.test(ev.data);
406              if (isProgress && output.textContent) {
407                const lines = output.textContent.split('\n');
408                const lastLine = lines[lines.length - 1] || '';
409                const curFile = ev.data.match(/^FILE\s+(\S+)/)?.[1];
410                const prevFile = lastLine.match(/^FILE\s+(\S+)/)?.[1];
411                if (curFile && prevFile && curFile === prevFile) {
412                  lines[lines.length - 1] = ev.data;
413                  output.textContent = lines.join('\n');
414                } else {
415                  output.textContent += '\n' + ev.data;
416                }
417              } else {
418                output.textContent += (output.textContent ? '\n' : '') + ev.data;
419              }
420              output.scrollTop = output.scrollHeight;
421              fullOutput += ev.data + '\n';
422              const diag = _diagnose(fullOutput);
423              if (diag) _showDiagnosis(panel, diag, fullOutput);
424            }
425            if (ev.exit_code !== undefined) {
426              exitCode = ev.exit_code;
427            }
428          } catch (_) {}
429        }
430      }
431    }
432
433    if (!output.textContent) output.textContent = '(no output)';
434    if (exitCode !== null && exitCode !== 0) {
435      output.classList.add('cookbook-output-error');
436      const diag = _diagnose(fullOutput);
437      if (diag) _showDiagnosis(panel, diag, fullOutput);
438    }
439  } catch (err) {
440    if (err.name === 'AbortError') {
441      output.textContent += (output.textContent ? '\n' : '') + '(stopped)';
442    } else {
443      output.classList.add('cookbook-output-error');
444      output.textContent += (output.textContent ? '\n' : '') + 'Request failed: ' + err.message;
445    }
446  } finally {
447    if (serveBtn) serveBtn.style.display = '';
448    if (stopBtn) stopBtn.style.display = 'none';
449    delete panel._cookbookAbort;
450  }
451}
452
453// ── Model download (dedicated endpoint, tmux-backed) ──
454
455export async function _runModelDownload(panel, model, backend, hostOverride) {
456  const ggufSource = _ggufDownloadSource(model, backend);
457  if (backend === 'llamacpp' && !ggufSource) {
458    uiModule.showToast(_missingGgufMessage(model));
459    return;
460  }
461  const repo = ggufSource?.repo || model.quant_repo || model.name;
462  const include = backend === 'llamacpp' ? _ggufIncludePattern(model, ggufSource) : null;
463
464  _syncEnvFromPanel(panel);
465
466  // The host is whatever the caller resolved from the dropdown the user picked
467  // (passed explicitly as hostOverride). We do NOT trust _envState.remoteHost
468  // here: there can be more than one copy of the cookbook state in memory and
469  // they disagree on the active host. The servers LIST is consistent, so we look
470  // up the matching server to get its env / path / platform / port.
471  let host;
472  if (hostOverride !== undefined) {
473    host = hostOverride || '';
474  } else {
475    // No explicit host passed: resolve from the visible server dropdown rather
476    // than _envState.remoteHost (unreliable — multiple state copies disagree).
477    const ssEl = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server');
478    // Dropdown values are host strings now ('local' for local); resolve by host
479    // (numeric fallback for any stale value).
480    const _ssv = ssEl ? ssEl.value : null;
481    const _dsrv = (_ssv && _ssv !== 'local') ? (_envState.servers.find(s => s.host === _ssv) || _envState.servers[parseInt(_ssv)]) : null;
482    if (_dsrv) {
483      host = _dsrv.host;
484    } else if (ssEl && ssEl.value === 'local') {
485      host = '';
486    } else {
487      host = _envState.remoteHost || '';
488    }
489  }
490  const srv = _envState.servers.find(s => s.host === host) || {};
491  const env = host ? (srv.env || 'none') : (_envState.env || 'none');
492  const envPath = host ? (srv.envPath || '') : (_envState.envPath || '');
493  const platform = host ? (srv.platform || '') : (_envState.platform || '');
494  const isWin = host ? (platform === 'windows') : _isWindows();
495
496  const payload = { repo_id: repo };
497  if (include) payload.include = include;
498  // Large downloads are where hf_transfer most often dies near the end. Use the
499  // plain HuggingFace downloader up front for big model files; it is slower, but
500  // resumes cached partials more reliably.
501  if ((model.required_gb || 0) >= 10 || backend === 'llamacpp') payload.disable_hf_transfer = true;
502  if (_envState.hfToken) payload.hf_token = _envState.hfToken;
503  if (host) { payload.remote_host = host; const _sp = _getPort(host); if (_sp) payload.ssh_port = _sp; }
504  if (platform) payload.platform = platform;
505  // If this server has a directory flagged as the download target, send it so
506  // the backend downloads into <dir>/<model> instead of the default HF cache.
507  if (srv.downloadDir) payload.local_dir = srv.downloadDir;
508  if (isWin) {
509    if (env === 'venv' && envPath) {
510      payload.env_prefix = '& ' + (envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
511    } else if (env === 'conda' && envPath) {
512      payload.env_prefix = 'conda activate ' + envPath;
513    }
514  } else {
515    if (env === 'venv' && envPath) {
516      payload.env_prefix = 'source ' + (envPath.endsWith('/bin/activate') ? envPath : envPath + '/bin/activate');
517    } else if (env === 'conda' && envPath) {
518      payload.env_prefix = 'eval "$(conda shell.bash hook)" && conda activate ' + envPath;
519    }
520  }
521
522  const shortName = (model.name || repo).split('/').pop();
523  const targetHost = host || 'local';
524
525  const tasks = _loadTasks();
526  const sameDownload = (t) => {
527    if (!t || t.type !== 'download') return false;
528    const tRepo = t?.payload?.repo_id || t?.repo_id || t?.repo || t?.name || '';
529    const tHost = t?.remoteHost || t?.payload?.remote_host || 'local';
530    return String(tRepo) === String(payload.repo_id) && String(tHost || 'local') === String(targetHost);
531  };
532  const duplicate = tasks.find(t => sameDownload(t) && (t.status === 'running' || t.status === 'queued'));
533  if (duplicate) {
534    _renderRunningTab();
535    uiModule.showToast(`${shortName} is already ${duplicate.status === 'queued' ? 'queued' : 'downloading'}`);
536    return;
537  }
538  // Also catch zombie "done" tasks — the cookbook may have lost track of a
539  // download (server restart, stale state) while its tmux session is still
540  // alive on the host. Probe it; if alive, flip back to running + treat as
541  // duplicate so we don't kick off a second concurrent download writing to
542  // the same target dir.
543  const zombieCandidate = tasks.find(t => sameDownload(t)
544    && ['done', 'error', 'crashed', 'stopped'].includes(t.status)
545    && t.sessionId && !String(t.sessionId).startsWith('queue-'));
546  if (zombieCandidate) {
547    try {
548      const _zh = zombieCandidate.remoteHost || '';
549      const _zPort = (_envState.servers || []).find(s => s.host === _zh)?.port;
550      const _sshPf = _zh ? `ssh ${_zPort && _zPort !== '22' ? `-p ${_zPort} ` : ''}${_zh} '` : '';
551      const _sshSf = _zh ? `'` : '';
552      const _probeCmd = `${_sshPf}tmux has-session -t ${zombieCandidate.sessionId} 2>/dev/null${_sshSf}`;
553      const _r = await fetch('/api/shell/exec', {
554        method: 'POST', credentials: 'same-origin',
555        headers: { 'Content-Type': 'application/json' },
556        body: JSON.stringify({ command: _probeCmd, timeout: 5 }),
557      });
558      const _d = await _r.json();
559      if (_d.exit_code === 0) {
560        // tmux still alive → not actually done. Revive + tell the user.
561        const _fresh = _loadTasks();
562        const _ft = _fresh.find(t => t.sessionId === zombieCandidate.sessionId);
563        if (_ft) {
564          _ft.status = 'running';
565          _ft._selfHealed = true;
566          _saveTasks(_fresh);
567        }
568        _renderRunningTab();
569        uiModule.showToast(`${shortName} is still downloading (was marked finished after a restart — revived)`);
570        return;
571      }
572    } catch { /* probe failed — fall through and let the user launch */ }
573  }
574  const activeOnHost = tasks.find(t => t.type === 'download' && (t.status === 'running' || t.status === 'queued') && (t.remoteHost || 'local') === targetHost);
575
576  if (activeOnHost) {
577    const queueId = `queue-${Date.now().toString(36)}`;
578    const allTasks = _loadTasks();
579    allTasks.push({ id: queueId, sessionId: queueId, name: shortName, type: 'download', status: 'queued', output: '', ts: Date.now(), payload, remoteHost: host });
580    _saveTasks(allTasks);
581    _renderRunningTab();
582    uiModule.showToast(`Queued ${shortName} — waiting for current download`);
583    return;
584  }
585
586  try {
587    const res = await fetch('/api/model/download', {
588      method: 'POST',
589      credentials: 'same-origin',
590      headers: { 'Content-Type': 'application/json' },
591      body: JSON.stringify(payload),
592    });
593    if (!res.ok) {
594      // Errors carry actionable text (e.g. "tmux is required …"); keep them up
595      // long enough to read, matching the serve path's duration (issue #1355).
596      uiModule.showToast('Download failed: HTTP ' + res.status, 9000);
597      return;
598    }
599    const data = await res.json();
600    if (!data.ok) {
601      uiModule.showToast('Download failed: ' + (data.error || ''), 9000);
602      return;
603    }
604    _addTask(data.session_id, shortName, 'download', payload);
605    uiModule.showToast(`Downloading ${shortName}...`);
606  } catch (e) {
607    uiModule.showToast('Download failed: ' + e.message, 9000);
608  }
609}
610
611// ── Init ──
612
613export function initDownload(shared) {
614  _envState = shared._envState;
615  _sshCmd = shared._sshCmd;
616  _getPort = shared._getPort;
617  _getPlatform = shared._getPlatform;
618  _isWindows = shared._isWindows;
619  _buildEnvPrefix = shared._buildEnvPrefix;
620  _buildServeCmd = shared._buildServeCmd;
621  _detectBackend = shared._detectBackend;
622  _detectToolParser = shared._detectToolParser;
623  _loadPresets = shared._loadPresets;
624  _savePresets = shared._savePresets;
625  _copyText = shared._copyText;
626  _persistEnvState = shared._persistEnvState;
627  modelLogo = shared.modelLogo;
628  esc = shared.esc;
629  _addTask = shared._addTask;
630  _renderRunningTab = shared._renderRunningTab;
631  _loadTasks = shared._loadTasks;
632  _saveTasks = shared._saveTasks;
633}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.