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.