1// ============================================ 2// COOKBOOK MODULE (v2 â simplified) 3// What Fits? + Saved presets, inline action panels 4// ============================================ 5 6import uiModule from './ui.js'; 7import spinnerModule from './spinner.js'; 8import { providerLogo } from './providers.js'; 9import { makeWindowDraggable } from './windowDrag.js'; 10import { _diagnose, _showDiagnosis, _clearDiagnosis, _runQuickCmd, ERROR_PATTERNS } from './cookbook-diagnosis.js'; 11import { RECIPE_BACKENDS, recipesForBackend, pickRecipe, recipeCommands, RECIPE_DEFAULT_VARIANT } from './cookbook-deps-recipes.js'; 12import { _hwfitCache, _hwfitDebounce, _hwfitFetch, _hwfitInit, _hwfitRenderList, _hwfitRenderHw, _renderGpuToggles, _expandModelRow, _fitColors, _hwfitColumns, _cachedModelIds, _gpuToggleTotal, _resetGpuToggleState } from './cookbook-hwfit.js'; 13 14// Sub-modules 15import { 16 initRunning, 17 _loadTasks, _saveTasks, _addTask, _removeTask, 18 _tmuxCmd, _renderRunningTab, _clearCookbookNotif, 19 _launchServeTask, _serveAutoFix, _serveAutoRetry, _serveAutoRetryReplace, _serveAutoRetryRemove, 20 _startBackgroundMonitor, _syncFromServer, 21 _retryDownload, _nextAvailablePort, _processQueue, 22 _selfHealStaleTasks, 23} from './cookbookRunning.js'; 24 25import { 26 initDownload, 27 _setPanelField, _setPanelCheckbox, 28 _wirePanelEvents, _runPanelCmd, _runModelDownload, _buildDownloadCmd, 29} from './cookbookDownload.js'; 30 31import { 32 initServe, 33 _fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel, 34} from './cookbookServe.js'; 35 36import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js'; 37import { topPortalZ } from './toolWindowZOrder.js'; 38 39const STORAGE_KEY = 'cookbook-presets'; 40const LAST_STATE_KEY = 'cookbook-last-state'; 41const SERVE_STATE_KEY = 'cookbook-serve-state'; 42 43// Global, once: tag chip rows (.doclib-lang-chips) scroll horizontally on mobile. 44// Stop their touch events (capture phase, before any ancestor sees them) so a 45// sideways tag scroll never triggers a swipe-to-change-tab / swipe-dismiss 46// gesture in ANY modal (cookbook, document library, etc.). We don't preventDefault, 47// so the browser's native horizontal scroll of the chips still works. 48if (typeof window !== 'undefined' && !window._tagScrollGuardWired) { 49 window._tagScrollGuardWired = true; 50 ['touchstart', 'touchmove'].forEach(evt => { 51 document.addEventListener(evt, (e) => { 52 const t = e.target; 53 if (t && t.closest && t.closest('.doclib-lang-chips')) e.stopPropagation(); 54 }, true); 55 }); 56} 57 58// Radio-style check marking which model directory is a server's download target. 59// OFF = hollow circle (pickable); ON = checked circle (accent-tinted via CSS). 60export const _MODELDIR_CHECK_OFF = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/></svg>'; 61export const _MODELDIR_CHECK_ON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="8 12 11 15 16 9"/></svg>'; 62 63function _normalizeCookbookModelDir(dir) { 64 const d = String(dir || '').replaceAll('â', '').replaceAll('â', '').trim(); 65 return /^(home|mnt|media|data|opt|srv|var)\//.test(d) ? `/${d}` : d; 66} 67 68// Monochrome platform glyphs (currentColor) for a server's OS tag: a penguin for 69// Linux, the four-pane logo for Windows, an Android robot for Termux/Android. 70function _platformIcon(platform) { 71 const k = (platform || '').toLowerCase(); 72 if (k === 'windows') { 73 return '<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M3 4.6l8-1.2v8.1H3V4.6zm9-1.3L21 2v9.5h-9V3.3zM3 12.5h8v8.1l-8-1.2v-6.9zm9 0h9V22l-9-1.3v-8.2z"/></svg>'; 74 } 75 if (k === 'termux' || k === 'android') { 76 return '<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M7 9h10v6.6a1 1 0 0 1-1 1h-.7v2.6a1.15 1.15 0 1 1-2.3 0V16.6h-1.5v2.6a1.15 1.15 0 1 1-2.3 0V16.6H8a1 1 0 0 1-1-1V9zM4.3 9.1a1.15 1.15 0 0 1 2.3 0v4.6a1.15 1.15 0 1 1-2.3 0V9.1zm13.1 0a1.15 1.15 0 0 1 2.3 0v4.6a1.15 1.15 0 1 1-2.3 0V9.1zM8 8a4 4 0 0 1 8 0H8zm1.7-2.6-.8-1.2a.28.28 0 0 1 .47-.3l.83 1.25a4.8 4.8 0 0 1 3.66 0l.83-1.25a.28.28 0 0 1 .47.3L14.3 5.4M9.8 6.6a.62.62 0 1 0 0-1.24.62.62 0 0 0 0 1.24zm4.4 0a.62.62 0 1 0 0-1.24.62.62 0 0 0 0 1.24z"/></svg>'; 77 } 78 if (k === 'linux' || k === 'termux-linux') { 79 return '<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor" aria-hidden="true"><path d="M12 2a4 4 0 0 0-4 4v4.7c0 .9-.4 1.7-1 2.4-1.2 1.4-2 3-2 4.5C5 20.4 8.1 22 12 22s7-1.6 7-4.4c0-1.5-.8-3.1-2-4.5-.6-.7-1-1.5-1-2.4V6a4 4 0 0 0-4-4zm-1.7 4.8a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm3.4 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2zM12 9.4c.75 0 1.4.45 1.7 1.1h-3.4c.3-.65.95-1.1 1.7-1.1z"/></svg>'; 80 } 81 return ''; 82} 83 84export let _envState = { env: 'none', envPath: '', hfToken: '', hfTokenConfigured: false, hfTokenMasked: '', gpus: '', remoteHost: '', servers: [], modelPaths: [], platform: '', hostPlatform: '', defaultServer: '' }; 85let _lastCacheHostVal = null; 86let _cookbookOpeningSpinners = []; 87export function _lastCacheHost() { return _lastCacheHostVal; } 88export function _setLastCacheHost(v) { _lastCacheHostVal = v; } 89 90function _setCookbookOpening(on) { 91 // Sidebar (tool-cookbook-btn) deliberately excluded â the inline 92 // whirlpool on the sidebar row read as "the click didn't register" 93 // rather than "loading", which made users (rightly) think clicks 94 // were being eaten. Keep only the icon-rail spinner since the 95 // rail is narrow enough that an obvious loading state still helps. 96 const targets = [ 97 document.getElementById('rail-cookbook'),
98 ].filter(Boolean); 99 if (!on) { 100 _cookbookOpeningSpinners.forEach(({ spinner, wrap, target }) => { 101 try { spinner?.stop?.(); } catch {} 102 try { wrap?.remove?.(); } catch {} 103 target?.classList?.remove('cookbook-opening'); 104 }); 105 _cookbookOpeningSpinners = []; 106 return; 107 } 108 if (_cookbookOpeningSpinners.length) return; 109 targets.forEach(target => { 110 const spinner = spinnerModule.create('', 'clean', 'whirlpool'); 111 spinner._wpSize = target.id === 'rail-cookbook' ? 12 : 13; 112 const wrap = document.createElement('span'); 113 wrap.className = 'cookbook-open-loading'; 114 wrap.appendChild(spinner.createElement()); 115 target.appendChild(wrap); 116 target.classList.add('cookbook-opening'); 117 spinner.start(); 118 _cookbookOpeningSpinners.push({ spinner, wrap, target }); 119 }); 120} 121 122/** Build server <option> HTML from _envState.servers. excludeLocal skips local-only entries. */ 123// True for the local server entry (empty / "local" / "localhost" host). 124function _isLocalEntry(s) { return !s || !s.host || s.host === 'local' || s.host.toLowerCase() === 'localhost'; } 125 126// Resolve a dropdown option value to a server entry. New option values are 127// stable per-profile keys, so same-host SSH profiles stay distinguishable. 128// Host strings and numeric indices remain accepted for stale saved state. 129export function _serverKey(s) { 130 if (_isLocalEntry(s)) return 'local'; 131 return 'srv:' + [ 132 s?.name || '', 133 s?.host || '', 134 s?.port || '', 135 s?.envPath || '', 136 s?.platform || '', 137 ].map(v => encodeURIComponent(String(v).trim())).join('|'); 138} 139 140export function _serverByVal(val) { 141 if (val == null || val === 'local' || val === '') return null; 142 const raw = String(val); 143 let s = _envState.servers.find(x => _serverKey(x) === raw); 144 if (!s) s = _envState.servers.find(x => x.host === raw); 145 if (!s) s = _envState.servers.find(x => x.name === raw); 146 if (!s && /^\d+$/.test(String(val))) s = _envState.servers[parseInt(val)]; 147 return s || null; 148} 149 150export function _selectedServer() { 151 if (_envState.remoteServerKey) { 152 const keyed = _serverByVal(_envState.remoteServerKey); 153 if (keyed) return keyed; 154 } 155 if (_envState.remoteHost) return _envState.servers.find(s => s.host === _envState.remoteHost) || null; 156 return null; 157} 158 159export function _currentServerValue() { 160 const selected = _selectedServer(); 161 if (selected) return _serverKey(selected); 162 return _envState.remoteHost || 'local'; 163} 164 165const GEMMA4_THINKING_CHAT_TEMPLATE = `{% for message in messages %}{% if message['role'] == 'system' %}<|turn>system\n<|think|>{{ message['content'] }}<turn|>\n{% elif message['role'] == 'user' %}<|turn>user\n{{ message['content'] }}<turn|>\n{% elif message['role'] == 'assistant' %}<|turn>model\n{{ message['content'] }}<turn|>\n{% endif %}{% endfor %}{% if add_generation_prompt %}<|turn>model\n<|channel>thought{% endif %}`; 166 167function _isGemma4ThinkingModel(modelName) { 168 const n = (modelName || '').toLowerCase(); 169 return n.includes('gemma-4') || n.includes('gemma4'); 170} 171 172function _gemma4ThinkingChatTemplateArg(modelName) { 173 return _isGemma4ThinkingModel(modelName) 174 ? _shellQuote(GEMMA4_THINKING_CHAT_TEMPLATE) 175 : ''; 176} 177 178const _SERVER_COLOR_CHOICES = [ 179 ['', 'Auto'], 180 ['#bd93f9', 'Purple'], 181 ['#ff79c6', 'Pink'], 182 ['#fca5a5', 'Red'], 183 ['#93c5fd', 'Blue'], 184 ['#86efac', 'Green'], 185 ['#d6b37a', 'Bronze'], 186 ['#111827', 'Black'], 187 ['#f8fafc', 'White'], 188 ['#c0c4cc', 'Silver'], 189 ['#d9f99d', 'Lime'], 190 ['#ccfbf1', 'Mint'], 191]; 192const _SERVER_AUTO_COLOR_VALUES = _SERVER_COLOR_CHOICES.slice(1).map(([value]) => value); 193 194function _serverColorValue(value) { 195 const v = String(value || '').trim(); 196 return /^#[0-9a-fA-F]{6}$/.test(v) ? v : ''; 197} 198 199function _serverColor(s) { 200 return _serverColorValue(s && s.color); 201} 202 203function _serverColorLabel(color) { 204 const hit = _SERVER_COLOR_CHOICES.find(([value]) => value === color); 205 return hit ? hit[1] : 'Auto'; 206} 207 208function _autoServerColor(index) { 209 const servers = Array.isArray(_envState.servers) ? _envState.servers : []; 210 const explicit = new Set(servers.map(s =>
210 _serverColor(s)).filter(Boolean)); 211 const used = new Set(explicit); 212 for (let j = 0; j <= index; j++) { 213 const s = servers[j] || {}; 214 const explicitColor = _serverColor(s); 215 if (explicitColor) continue; 216 const picked = _SERVER_AUTO_COLOR_VALUES.find(c => !used.has(c)) || _SERVER_AUTO_COLOR_VALUES[j % _SERVER_AUTO_COLOR_VALUES.length] || ''; 217 if (j === index) return picked; 218 if (picked) used.add(picked); 219 } 220 return _SERVER_AUTO_COLOR_VALUES[index % _SERVER_AUTO_COLOR_VALUES.length] || ''; 221} 222 223function _resolvedServerColor(s, index) { 224 return _serverColor(s) || _autoServerColor(index); 225} 226 227function _serverOptionLabel(label, color) { 228 return color ? `â ${label}` : label; 229} 230 231function _serverOptionStyle(color) { 232 return color ? ` style="color:${esc(color)};"` : ''; 233} 234 235function _serverColorOptionStyle(color) { 236 if (!color) return ' style="background:var(--bg);color:var(--fg);"'; 237 const c = String(color).toLowerCase(); 238 const fg = (c === '#111827') ? '#f8fafc' : (c === '#f8fafc' || c === '#fca5a5' || c === '#93c5fd' || c === '#86efac' || c === '#d9f99d' || c === '#ccfbf1' || c === '#c0c4cc') ? '#111827' : color; 239 return ` style="color:${esc(fg)};background-color:color-mix(in srgb, ${esc(color)} 28%, var(--bg));"`; 240} 241 242function _serverColorForValue(value) { 243 const s = _serverByVal(value); 244 const idx = Array.isArray(_envState.servers) ? _envState.servers.indexOf(s) : -1; 245 return s ? _resolvedServerColor(s, idx >= 0 ? idx : 0) : ''; 246} 247 248export function _applyServerSelectColor(sel) { 249 if (!sel || sel.tagName !== 'SELECT') return; 250 const color = _serverColorForValue(sel.value); 251 if (color) { 252 sel.style.setProperty('--cookbook-server-color', color); 253 sel.classList.add('cookbook-server-select-colored'); 254 } else { 255 sel.style.removeProperty('--cookbook-server-color'); 256 sel.classList.remove('cookbook-server-select-colored'); 257 } 258} 259 260export function _syncServerSelectColors(root = document) { 261 root.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(_applyServerSelectColor); 262} 263 264function _buildServerOpts(excludeLocal = false) { 265 // The local server is ALWAYS represented by the synthetic value="local" option 266 // (showing its custom name from the "server name" feature). We must therefore 267 // skip that same entry in the loop below â otherwise it appeared twice. 268 const _localIdx = _envState.servers.findIndex(_isLocalEntry); 269 const _localSrv = _localIdx >= 0 ? _envState.servers[_localIdx] : null; 270 const _localLabel = (_localSrv && _localSrv.name) ? _localSrv.name : 'Local'; 271 const _localColor = _localSrv ? _resolvedServerColor(_localSrv, _localIdx) : ''; 272 let html = `<option value="local"${_serverOptionStyle(_localColor)}${!_envState.remoteHost ? ' selected' : ''}>${esc(_serverOptionLabel(_localLabel, _localColor))}</option>`; 273 const selectedKey = _envState.remoteServerKey || ''; 274 let legacyHostSelected = false; 275 for (let i = 0; i < _envState.servers.length; i++) { 276 const s = _envState.servers[i]; 277 if (i === _localIdx) continue; // already the synthetic "local" option 278 if (excludeLocal && _isLocalEntry(s)) continue; 279 const label = s.name || s.host || `Server ${i + 1}`; 280 const value = _serverKey(s); 281 const color = _resolvedServerColor(s, i); 282 let selected = selectedKey ? value === selectedKey : false; 283 if (!selectedKey && _envState.remoteHost === s.host && !legacyHostSelected) { 284 selected = true; 285 legacyHostSelected = true; 286 } 287 html += `<option value="${esc(value)}"${_serverOptionStyle(color)}${selected ? ' selected' : ''}>${esc(_serverOptionLabel(label, color))}</option>`; 288 } 289 return html; 290} 291 292/** Wrap a command in SSH for a remote host, with proper single-quote escaping. */ 293export function _sshCmd(host, cmd, port) { 294 const portFlag = port && port !== '22' ? `-p ${port} ` : ''; 295 return `ssh ${portFlag}${host} '${cmd.replace(/'/g, "'\\''")}'`; 296} 297 298/** Get SSH port for a given host (or task object) */ 299function _getPort(hostOrTask) { 300 if (!hostOrTask) return ''; 301 if (typeof hostOrTask === 'object') return hostOrTask.sshPort || _getPort(hostOrTask.remoteServerKey || hostOrTask.remoteHost || hostOrTask.payload?.remote_host); 302 const selected = hostOrTask === _envState.remoteHost ? _selectedServer() : null; 303 const srv = selected || _serverByVal(hostOrTask); 304 return srv?.port || ''; 305} 306
307/** Get platform for a given host (or task object). Returns 'windows', 'termux', 'linux', or '' */ 308export function _getPlatform(hostOrTask) { 309 if (hostOrTask === 'local') return _envState.hostPlatform || ''; 310 if (!hostOrTask) return _envState.remoteHost ? (_envState.platform || '') : (_envState.hostPlatform || ''); 311 if (typeof hostOrTask === 'object') { 312 const taskHost = hostOrTask.remoteServerKey || hostOrTask.remoteHost || ''; 313 if (!taskHost || taskHost === 'local') return _envState.hostPlatform || ''; 314 return hostOrTask.platform || _getPlatform(taskHost); 315 } 316 const selected = hostOrTask === _envState.remoteHost ? _selectedServer() : null; 317 const srv = selected || _serverByVal(hostOrTask); 318 return srv?.platform || ''; 319} 320 321/** Check if the current active server is Windows */ 322export function _isWindows(hostOrTask) { 323 return _getPlatform(hostOrTask) === 'windows'; 324} 325 326/** Check if the detected (local) hardware is Apple Silicon / Metal. Keys off the 327 * hardware probe's backend rather than a platform string, since a local Mac 328 * reports no platform but does report backend: "metal". */ 329export function _isMetal() { 330 return ['metal', 'mps', 'apple'].includes(String(_hwfitCache?.system?.backend || '').toLowerCase()); 331} 332 333/** Detect model-specific vLLM optimizations */ 334function _isStepFunStepModel(modelName) { 335 const n = (modelName || '').toLowerCase(); 336 return n.includes('stepfun') 337 || n.includes('step-3') 338 || n.includes('step3') 339 || n.includes('step_3'); 340} 341 342function _detectModelOptimizations(modelName) { 343 const n = (modelName || '').toLowerCase(); 344 const opts = { envVars: [], flags: [], tips: [] }; 345 346 // StepFun Step-3.x MoE models. Their tokenizer defines the Step tool-call 347 // and thinking tags; vLLM/SGLang need the step3p5 parser instead of generic 348 // Hermes/XML guesses, and the MoE backend should default to expert parallel. 349 if (_isStepFunStepModel(modelName)) { 350 opts.flags.push('--enable-expert-parallel'); 351 opts.tips.push('StepFun Step-3 MoE: expert parallel'); 352 opts.tips.push('StepFun parser: step3p5 for native tool calls and reasoning tags'); 353 } 354 // Qwen3.5 MoE models â MoE-specific env vars + expert-parallel. 355 // The --reasoning-parser flag is added uniformly below via 356 // _detectReasoningParser, no longer hardcoded here. 357 else if (n.includes('qwen3.5') || n.includes('qwen3-') && (n.includes('a10b') || n.includes('a22b') || n.includes('a3b'))) { 358 opts.envVars.push('VLLM_USE_DEEP_GEMM=0', 'VLLM_USE_FLASHINFER_MOE_FP16=1', 'VLLM_USE_FLASHINFER_SAMPLER=0', 'OMP_NUM_THREADS=4'); 359 opts.flags.push('--enable-expert-parallel'); 360 opts.tips.push('MoE optimizations: expert parallel + flashinfer MoE kernels'); 361 } 362 // Qwen3 MoE (non-3.5) 363 else if (n.includes('qwen3') && (n.includes('a10b') || n.includes('a22b') || n.includes('a3b'))) { 364 opts.envVars.push('VLLM_USE_DEEP_GEMM=0', 'VLLM_USE_FLASHINFER_MOE_FP16=1'); 365 opts.flags.push('--enable-expert-parallel'); 366 opts.tips.push('MoE optimizations: expert parallel'); 367 } 368 // DeepSeek MoE â V3 / V3.1 / V4 (and future Vx), R1 / R2 reasoning. 369 // Anything v-{integer} or r-{integer} family from DeepSeek is MoE in 370 // current architectures. These models also require fp8 KV cache to 371 // fit at meaningful context with current tensor-parallel layouts â 372 // the launch crashes otherwise (--kv-cache-dtype auto â bf16 OOMs). 373 else if (n.includes('deepseek') && /\b(v[3-9]|v\d{2,}|r[1-9])\b/.test(n)) { 374 opts.flags.push('--enable-expert-parallel'); 375 opts.tips.push('MoE expert parallel for DeepSeek'); 376 opts.kvCacheDtype = 'fp8'; 377 opts.tips.push('fp8 KV cache required â bf16 OOMs at usable context'); 378 } 379 // MiniMax MoE â Abab/M1/M2/M2.5/M2.7 are all MoE (Lightning Attention + 380 // MoE in M1, full sparse MoE from M2 onward). They benefit from the 381 // same --enable-expert-parallel flag as the Qwen/DeepSeek families, 382 // and the toggle has to be detectable here for the Expert Parallel 383 // checkbox in the serve form to render at all. 384 else if (n.includes('minimax')) { 385 opts.flags.push('--enable-expert-parallel'); 386 opts.tips.push('MoE expert parallel for MiniMax'); 387 if (/\bm3\b/.test(n)) { 388 opts.kvCacheDtype = 'fp8'; 389 opts.tips.push('MiniMax M3 defaults: fp8 KV cache, block-size 128, TRITON attention'); 390 } 391 } 392 // Reasoning parser â applies independently of MoE detection. Without this 393 // flag, models like MiniMax-M2.x, DeepSeek-R1, Qwen3 reasoning, GLM-4.x, 394 // gpt-oss leak <think> blocks as plain text instead of separating them 395 // into the reasoning_content channel. 396 const _reasoningParser = _detectReasoningParser(modelName); 397 if (_reasoningParser) { 398 opts.flags.push(`--reasoning-parser ${_reasoningParser}`); 399 opts.tips.push(`Reasoning parser (${_reasoningParser}): splits <think> tokens into a separate channel`); 400 } 401 // Speculative decoding â pick the right MTP method per model family. 402 // opts.spec.{method,tokens} seed the UI dropdown/input; the actual flag is 403 // assembled by the command builder so the user can edit before launching. 404 let specDefault = null; 405 if (n.includes('qwen3-next') || (n.includes('qwen3.5') && (n.includes('a10b') || n.includes('a22b')))) { 406 specDefault = { method: 'qwen3_next_mtp', tokens: 2 }; 407 } else if ( 408 (n.includes('deepseek') && /\b(v[3-9]|v\d{2,}|r[1-9])\b/.test(n)) || 409 n.includes('kimi-k2') || n.includes('kimi_k2') || 410 n.includes('glm-4.5') || n.includes('glm4.5') || 411 n.includes('minimax-m1') || n.includes('minimax_m1') 412 ) { 413 specDefault = { method: 'mtp', tokens: 3 }; 414 } 415 if (specDefault) { 416 opts.spec = specDefault; 417 opts.flags.push(`--speculative-config '{"method":"${specDefault.method}","num_speculative_tokens":${specDefault.tokens}
417}'`); 418 opts.tips.push(`Speculative decoding (${specDefault.method}, ${specDefault.tokens} tokens): ~1.5-2x faster generation`); 419 } 420 421 return opts; 422} 423 424/** Detect the right vLLM --reasoning-parser based on model name. 425 * Returns the parser slug (matches vLLM's official list) or null when the 426 * model isn't a reasoning model. Without the right parser, thinking tokens 427 * leak as plain text instead of being split into a separate channel. 428 * Source: vllm/reasoning/__init__.py registered parsers. 429 */ 430export function _detectReasoningParser(modelName) { 431 const n = (modelName || '').toLowerCase(); 432 // StepFun Step-3.x uses Step's native <think> / tool-call tokens. vLLM 433 // registers this parser as step3p5. 434 if (_isStepFunStepModel(modelName)) return 'step3p5'; 435 // MiniMax M3 â newer vLLM nightly/parser builds use minimax_m3. This must 436 // be checked before the M2.x rule and before the generic MiniMax tool parser. 437 if (n.includes('minimax') && /\bm3\b/.test(n)) return 'minimax_m3'; 438 // MiniMax M2 / M2.5 / M2.7 â released with a dedicated parser. Catch M2 439 // before plain "minimax" so M2.x doesn't fall through to a wrong parser. 440 if (n.includes('minimax') && n.match(/\bm2(?:\.\d)?\b/)) return 'minimax_m2'; 441 // DeepSeek-V4 has a dedicated parser in SGLang. Keep it before R1/V3. 442 if (n.includes('deepseek') && /\bv[-_]?4\b/.test(n)) return 'deepseek-v4'; 443 // DeepSeek-R1 / V3-Thinking / V3.1-Thinking variants. Bare V3/V3.1 (non- 444 // thinking) skip this â they're not reasoning models. 445 if (n.includes('deepseek') && (n.includes('r1') || n.includes('thinking'))) return 'deepseek_r1'; 446 // Qwen3 / Qwen3.5 reasoning models. Qwen3-Coder + Qwen3-Instruct don't 447 // emit <think> blocks, so skip the parser there. 448 if (n.includes('qwen3') && !n.includes('coder') && !n.includes('instruct')) return 'qwen3'; 449 // GLM-4 / GLM-4.5 / GLM-4.6 with reasoning. 450 if (n.includes('glm-4') || n.includes('glm-5')) return 'glm45'; 451 // OpenAI gpt-oss family. 452 if (n.includes('gpt-oss')) return 'gpt_oss'; 453 // Hunyuan A13B reasoning. 454 if (n.includes('hunyuan') && n.includes('a13b')) return 'hunyuan_a13b'; 455 // IBM Granite reasoning. 456 if (n.includes('granite') && (n.includes('reason') || n.includes('think'))) return 'granite'; 457 // InternLM reasoning. 458 if (n.includes('internlm')) return 'internlm'; 459 return null; 460} 461 462/** Detect the right vLLM tool-call-parser based on model name. 463 * Qwen tool-call formats split by generation: 464 * - Qwen3-Coder â qwen3_coder (XML <tool_call> with named params) 465 * - Qwen3 (non-coder) â qwen3_xml (reasoning/instruct, XML wrapper) 466 * - Qwen2.5 / Qwen2 / 1.5 â hermes (Qwen2.5 was trained on Hermes format) 467 * Catching "qwen" first and labelling everything qwen3_xml breaks tool 468 * calls on the Qwen2.5 line (the model emits hermes-style which the 469 * qwen3_xml parser doesn't recognise, so the call leaks through as text). 470 */ 471export function _detectToolParser(modelName) { 472 const n = (modelName || '').toLowerCase(); 473 if (_isStepFunStepModel(modelName)) return 'step3p5'; 474 if (n.includes('qwen3') && n.includes('coder')) return 'qwen3_coder'; 475 if (n.includes('qwen3')) return 'qwen3_xml'; 476 if (n.includes('qwen')) return 'hermes'; // Qwen2.5 / Qwen2 / Qwen1.5 477 if (n.includes('llama-4') || n.includes('llama4')) return 'llama4_json'; 478 if (n.includes('llama') || n.includes('nemotron')) return 'llama3_json'; 479 if (n.includes('mistral') || n.includes('mixtral')) return 'mistral'; 480 if (n.includes('deepseek') && /\bv[-_]?4\b/.test(n)) return 'deepseekv4'; 481 if (n.includes('deepseek-v3')) return 'deepseek_v3'; 482 if (n.includes('deepseek')) return 'deepseek_v3'; 483 if (n.includes('minimax') && /\bm3\b/.test(n)) return 'minimax_m3'; 484 if (n.includes('minimax') && n.includes('m2')) return 'minimax_m2'; 485 if (n.includes('minimax')) return 'minimax'; 486 if (n.includes('gemma')) return 'pythonic'; 487 if (n.includes('glm-4')) return 'glm45'; 488 if (n.includes('internlm')) return 'internlm'; 489 if (n.includes('granite')) return 'granite'; 490 return 'hermes'; // default fallback 491} 492 493// ââ Backend detection ââ 494 495export function _detectBackend(model) { 496 const _ollamaName = String(model?.repo_id || model?.name || model?.id || '').trim(); 497 const _ollamaMeta = `${model?.backend || ''} ${model?.endpoint_kind || ''} ${model?.provider || ''} ${model?.source || ''}`.toLowerCase(); 498 const _looksLikeOllamaTag = /^[A-Za-z0-9][A-Za-z0-9._-]*(?::[A-Za-z0-9][A-Za-z0-9._-]*)$/.test(_ollamaName); 499 if (model?.backend === 'ollama' || model?.is_ollama || _ollamaMeta.includes('ollama') || _looksLikeOllamaTag) { 500 return { backend: 'ollama', label: 'Ollama' }; 501 } 502 const q = (model.quant || '').toUpperCase();
503 const sysBackend = String(_hwfitCache?.system?.backend || '').toLowerCase(); 504 const isRocm = sysBackend === 'rocm'; 505 const isAppleSilicon = ['metal', 'mps', 'apple'].includes(sysBackend); 506 const _nm = `${model.repo_id || ''} ${model.path || ''} ${model.name || ''}`.toLowerCase(); 507 const isImageModel = !!(model.is_image_gen || model.is_diffusion || model._tag === 'image'); 508 // Image gen models â diffusers 509 if (isImageModel) { 510 if (/\bmlx\b|mlx-|_mlx|mlx-community\//i.test(_nm) || q.startsWith('MLX') || model.mlx_only) { 511 return { backend: 'mlx_image', label: 'MLX Image' }; 512 } 513 return { backend: 'diffusers', label: 'Diffusers' }; 514 } 515 if (/\bmlx\b|mlx-|_mlx/i.test(_nm) || q.startsWith('MLX')) { 516 return { backend: 'mlx', label: 'MLX' }; 517 } 518 const isAwqLike = /^AWQ|^GPTQ|^NVFP4/.test(q) || ['FP8', 'FP4', 'MXFP4', 'NF4', 'INT4', 'INT8', 'W4A16', 'W8A8', 'W8A16'].includes(q) || /\b(awq|gptq|fp8|fp4|nvfp4|mxfp4|nf4|int4|int8|w4a16|w8a8|w8a16)\b/i.test(_nm); 519 const hasGgufFile = Array.isArray(model.gguf_files) 520 && model.gguf_files.some(f => f && typeof f.rel_path === 'string' && /\.gguf$/i.test(f.rel_path)); 521 const isGgufLike = model.is_gguf || hasGgufFile || /^Q[2-8]/.test(q) || /^IQ/.test(q) || q === 'GGUF' || _nm.includes('gguf'); 522 523 // AWQ / GPTQ / FP8 are safetensors GPU-serving formats. Never route them 524 // through llama.cpp/Ollama just because the host is Mac/Windows; those engines 525 // need GGUF. The UI will warn/block on Metal where vLLM/SGLang aren't viable.
526 if (isAwqLike) { 527 return { backend: 'vllm', label: 'vLLM' }; 528 } 529 530 // GGUF â llama.cpp/Ollama-compatible. 531 if (isGgufLike) { 532 return { backend: 'llamacpp', label: 'llama.cpp' }; 533 } 534 535 // Windows â default to llama.cpp (no vLLM support on Windows) 536 if (_isWindows()) { 537 return { backend: 'llamacpp', label: 'llama.cpp' }; 538 } 539 540 // Apple Silicon (Metal) â llama.cpp (GGUF). vLLM/SGLang are CUDA/ROCm-only and 541 // don't run on macOS; vLLM-native quantized models are already filtered out 542 // of metal Cookbook results, so llama.cpp is always the right engine here. 543 if (['metal', 'mps', 'apple'].includes(sysBackend)) { 544 return { backend: 'mlx', label: 'MLX' }; 545 } 546 547 // ROCm/AMD machines should not blindly default HF safetensors models to 548 // vLLM. SGLang is the safer OpenAI-compatible default for plain HF text 549 // repos there; llama.cpp still wins above whenever the model is GGUF. 550 if (isRocm) { 551 return { backend: 'sglang', label: 'SGLang' }; 552 } 553 554 // Unquantized / BF16 / F16 â vLLM 555 return { backend: 'vllm', label: 'vLLM' }; 556} 557 558// ââ Command builders ââ 559 560export function _shellQuote(value) { 561 return "'" + String(value ?? '').replace(/'/g, "'\\''") + "'"; 562} 563 564function _listField(value) { 565 return String(value || '') 566 .split(/[\n,]+/) 567 .map(s => s.trim()) 568 .filter(Boolean); 569} 570 571function _numField(value) { 572 const s = String(value || '').trim(); 573 return /^-?\d+(?:\.\d+)?$/.test(s) ? s : ''; 574} 575 576export function _psQuote(value) { 577 return "'" + String(value ?? '').replace(/'/g, "''") + "'"; 578} 579 580// Pick the GPU-pinning env-var name for the detected backend. NVIDIA uses 581// CUDA_VISIBLE_DEVICES; ROCm/HIP uses HIP_VISIBLE_DEVICES; Vulkan and 582// Apple Metal don't take an index env var at all (and CUDA_VISIBLE_DEVICES 583// is a silent no-op on those, which silently hides "wrong backend" config 584// bugs). Returns 'cmd ' style prefix ('CUDA_VISIBLE_DEVICES=0 ') or '' when 585// the backend doesn't support pinning. Pass isWindows=true to get PowerShell 586// `$env:` syntax instead. backend defaults to whatever hwfit detected. 587function _gpuEnvVarName() { 588 // Only emit a pinning env var when we POSITIVELY know the backend AND 589 // the hwfit scan was actually run against the currently-targeted host. 590 // Without the target-match guard, switching the server picker from an 591 // NVIDIA box (cuda) to a local/Vulkan target preserved the stale 592 // `cuda` backend in the cache, leaking `CUDA_VISIBLE_DEVICES=` into 593 // launches that don't have an NVIDIA GPU at all. Default to "" when 594 // unsure â the user sees a clean command and is prompted to scan. 595 const cachedHost = String(_hwfitCache?._scannedHost || ''); 596 const currentHost = String(_envState.remoteHost || ''); 597 if (cachedHost !== currentHost) return ''; 598 const sb = String(_hwfitCache?.system?.backend || '').toLowerCase(); 599 if (sb === 'cuda') return 'CUDA_VISIBLE_DEVICES'; 600 if (sb === 'rocm') return 'HIP_VISIBLE_DEVICES'; 601 return ''; // vulkan / metal / mps / apple / cpu / generic / unknown â no env-var pinning 602} 603function _gpuEnvPrefix(gpuId, isWindows = false) { 604 const id = String(gpuId || '').trim(); 605 if (!id) return ''; 606 const varName = _gpuEnvVarName(); 607 if (!varName) return ''; 608 if (isWindows) return `$env:${varName}="${id}"; `; 609 return `${varName}=${id} `; 610} 611 612export function _buildEnvPrefix() { 613 if (_isWindows()) return _buildEnvPrefixWindows(); 614 let parts = []; 615 if (_envState.env === 'venv' && _envState.envPath) { 616 const p = _envState.envPath; 617 const activate = p.endsWith('/bin/activate') ? p : p + '/bin/activate'; 618 parts.push('source ' + _shellQuote(activate)); 619 } else if (_envState.env === 'conda' && _envState.envPath) { 620 parts.push('eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath)); 621 } 622 let envVars = []; 623 if (_envState.hfToken) envVars.push('export HF_TOKEN=' + _shellQuote(_envState.hfToken)); 624 const _envGpuVar = _gpuEnvVarName(); 625 if (_envState.gpus && _envGpuVar) envVars.push(`export ${_envGpuVar}=` + _shellQuote(_envState.gpus)); 626 if (envVars.length) parts.push(envVars.join(' && ')); 627 if (parts.length === 0) return ''; 628 return parts.join(' && ') + ' &&'; 629} 630 631function _buildEnvPrefixWindows() { 632 let parts = []; 633 if (_envState.env === 'venv' && _envState.envPath) { 634 const p = _envState.envPath; 635 const activate = p.endsWith('\\Scripts\\Activate.ps1') ? p : p + '\\Scripts\\Activate.ps1'; 636 parts.push('& ' + _psQuote(activate)); 637 } else if (_envState.env === 'conda' && _envState.envPath) { 638 parts.push('conda activate ' + _psQuote(_envState.envPath)); 639 } 640 if (_envState.hfToken) parts.push('$env:HF_TOKEN=' + _psQuote(_envState.hfToken)); 641 const _winGpuVar = _gpuEnvVarName(); 642 if (_envState.gpus && _winGpuVar) parts.push(`$env:${_winGpuVar}=` + _psQuote(_envState.gpus)); 643 if (parts.length === 0) return ''; 644 return parts.join('; ') + ';'; 645} 646 647function _venvRootFromPath(path) { 648 let p = (path || '').toString().trim().replace(/\/+$/, ''); 649 if (!p) return ''; 650 p = p.replace(/\/bin\/(?:activate|python(?:3(?:\.\d+)?)?|vllm|pip(?:3)?)$/i, ''); 651 return p; 652} 653 654function _venvLooksWrongForPlatform(path, platform) { 655 const p = String(path || '').trim(); 656 const plat = String(platform || '').toLowerCase(); 657 if (!p || !plat) return false;
658 if ((plat === 'darwin' || plat === 'macos') && /^\/(?:home|usr\/local\/cuda|opt\/conda)\//.test(p)) return true; 659 if ((plat === 'linux' || plat === 'termux') && /^\/(?:Users|opt\/homebrew)\//.test(p)) return true; 660 return false; 661} 662 663function _isDeepSeekV4Model(modelName) { 664 const n = String(modelName || '').toLowerCase(); 665 return n.includes('deepseek') && /\bv[-_]?4\b/.test(n); 666} 667 668function _envHasKey(envText, key) { 669 return String(envText || '').split(/\s+/).some(part => part.startsWith(`${key}=`)); 670} 671 672export function _buildServeCmd(f, modelName, backend) { 673 // When a venv is configured on the chosen server, use the venv's binaries 674 // by absolute path. Bare `vllm` / `python3` relies on PATH, and SSH non- 675 // interactive sessions often leave a user-site install (~/.local/bin/vllm) 676 // ahead of the venv's bin, so the WRONG vllm gets launched even with the 677 // venv activated. Absolute path sidesteps the whole PATH question. 678 let _formVenv = (f.venv ?? '').toString().trim(); 679 if (_venvLooksWrongForPlatform(_formVenv, f.platform)) _formVenv = ''; 680 const _activeVenvPath = _venvRootFromPath(_formVenv || (_envState.env === 'venv' ? (_envState.envPath || '') : '')); 681 const _venvBin = _activeVenvPath ? (_activeVenvPath + '/bin/') : ''; 682 const _vllmBin = _venvBin ? `${_venvBin}vllm` : 'vllm'; 683 const _py3Bin = _venvBin ? `${_venvBin}python3` : 'python3'; 684 let cmd = ''; 685 if (backend === 'vllm') { 686 // GPU list comes from the Row-1 button strip (data-field="gpus") â 687 // the bare "auto" input that used to back gpu_id is gone, and the 688 // button strip is the only source for which devices to pin. 689 const gpuId = (f.gpus || f.gpu_id || '').toString().trim(); 690 cmd += _gpuEnvPrefix(gpuId); 691 if (f.moe_env) { 692 const _opts = _detectModelOptimizations(modelName); 693 if (_opts.envVars.length) { 694 cmd += _opts.envVars.join(' ') + ' '; 695 } else { 696 // Fallback when the user toggles MoE Env on for a model the 697 // family detector didn't classify as MoE â emit the generic 698 // vLLM MoE optimization env vars so the toggle is never a 699 // silent no-op (was the case before the "always show" change). 700 cmd += 'VLLM_USE_DEEP_GEMM=0 VLLM_USE_FLASHINFER_MOE_FP16=1 OMP_NUM_THREADS=4 '; 701 } 702 } 703 // Free-text "Env" field â verbatim KEY=VAL pairs (space-separated). 704 // Collapse any pasted newlines/tabs so the backend allowlist (which 705 // rejects \n / \r) doesn't trip on a multi-line paste from a model card. 706 const _extraEnv = (f.extra_env ?? '').toString().replace(/\s+/g, ' ').trim(); 707 if (_extraEnv) cmd += _extraEnv + ' '; 708 cmd += `${_vllmBin} serve ${modelName}
708 --host 0.0.0.0 --port ${f.port || '8000'}`; 709 const _servedModelName = (f.served_model_name ?? '').toString().trim(); 710 if (_servedModelName) cmd += ` --served-model-name ${_servedModelName}`; 711 // Pinned attention backend (Attention field). Empty = let vLLM pick. 712 const _attn = (f.vllm_attn_backend ?? '').toString().trim(); 713 if (_attn) cmd += ` --attention-backend ${_attn}`; 714 const _gemma4ChatTemplate = _gemma4ThinkingChatTemplateArg(modelName); 715 if (_gemma4ChatTemplate) cmd += ` --chat-template ${_gemma4ChatTemplate}`; 716 cmd += ` --tensor-parallel-size ${f.tp || '1'}`; 717 const _blockSize = (f.vllm_block_size ?? '').toString().trim(); 718 if (/^\d+$/.test(_blockSize)) cmd += ` --block-size ${_blockSize}`; 719 cmd += ` --max-model-len ${f.ctx || '8192'}`; 720 cmd += ` --gpu-memory-utilization ${f.gpu_mem || '0.90'}`; 721 const _swapRaw = (f.swap ?? '').toString().trim().toLowerCase(); 722 if (_swapRaw && !['0', 'off', 'none', 'false'].includes(_swapRaw)) cmd += ` --swap-space ${_swapRaw}`; 723 cmd += ` --dtype ${f.dtype || 'auto'}`; 724 const _kv = (f.vllm_kv_cache_dtype ?? '').toString().trim(); 725 if (_kv === 'fp8') cmd += ' --kv-cache-dtype fp8'; 726 if (f.max_seqs && f.max_seqs.toString().trim()) cmd += ` --max-num-seqs ${f.max_seqs.toString().trim()}`; 727 const _vllmLoraModules = _listField(f.vllm_lora_modules); 728 if (_vllmLoraModules.length) { 729 cmd += ` --enable-lora --lora-modules ${_vllmLoraModules.map(_shellQuote).join(' ')}`; 730 } 731 if (f.enforce_eager) cmd += ' --enforce-eager'; 732 if (f.trust_remote) cmd += ' --trust-remote-code'; 733 if (f.prefix_cache) cmd += ' --enable-prefix-caching'; 734 if (f.auto_tool) cmd += ` --enable-auto-tool-choice --tool-call-parser ${_detectToolParser(modelName)}`; 735 if (f.expert_parallel) cmd += ' --enable-expert-parallel'; 736 if (f.language_model_only) cmd += ' --language-model-only'; 737 if (f.disable_custom_all_reduce) cmd += ' --disable-custom-all-reduce'; 738 if (f.reasoning_parser) { 739 const rp = typeof f.reasoning_parser === 'string' && f.reasoning_parser !== 'true' 740 ? f.reasoning_parser : (f._reasoning_parser_value || _detectReasoningParser(modelName) || ''); 741 if (rp) cmd += ` --reasoning-parser ${rp}`; 742 } 743 if (f.speculative) { 744 const _specMethod = (f.spec_method || 'mtp').trim() || 'mtp'; 745 const _specToksRaw = parseInt(f.spec_tokens, 10); 746 const _specToks = (Number.isFinite(_specToksRaw) && _specToksRaw > 0) ? _specToksRaw : 3; 747 cmd += ` --speculative-config '{"method":"${_specMethod}","num_speculative_tokens":${_specToks}}'`; 748 } 749 } else if (backend === 'sglang') { 750 // GPU list comes from the Row-1 button strip (data-field="gpus") â 751 // the bare "auto" input that used to back gpu_id is gone, and the 752 // button strip is the only source for which devices to pin. 753 const gpuId = (f.gpus || f.gpu_id || '').toString().trim(); 754 cmd += _gpuEnvPrefix(gpuId); 755 const _isDsv4 = _isDeepSeekV4Model(modelName); 756 let _extraEnv = (f.extra_env ?? '').toString().replace(/\s+/g, ' ').trim(); 757 if (_isDsv4 && !_envHasKey(_extraEnv, 'SGLANG_DSV4_COMPRESS_STATE_DTYPE')) { 758 _extraEnv = (`SGLANG_DSV4_COMPRESS_STATE_DTYPE=bf16 ${_extraEnv}`).trim(); 759 } 760 if (_extraEnv) cmd += _extraEnv + ' '; 761 cmd += `${_py3Bin} -m sglang.launch_server --model-path ${modelName}
761 --host 0.0.0.0 --port ${f.port || '30000'}`; 762 const _gemma4ChatTemplate = _gemma4ThinkingChatTemplateArg(modelName); 763 if (_gemma4ChatTemplate) cmd += ` --chat-template ${_gemma4ChatTemplate}`; 764 if (f.tp && f.tp !== '1') cmd += ` --tp ${f.tp}`; 765 if (f.ctx) cmd += ` --context-length ${f.ctx}`; 766 const _memFraction = _isDsv4 && (!f.gpu_mem || f.gpu_mem === '0.90') ? '0.80' : f.gpu_mem; 767 if (_memFraction && _memFraction !== '0.90') cmd += ` --mem-fraction-static ${_memFraction}`; 768 if (f.dtype && f.dtype !== 'auto') cmd += ` --dtype ${f.dtype}`; 769 if (f.max_seqs && f.max_seqs.toString().trim()) cmd += ` --max-running-requests ${f.max_seqs.toString().trim()}`; 770 if (f.trust_remote) cmd += ' --trust-remote-code'; 771 if (f.auto_tool) cmd += ` --enable-auto-tool-choice --tool-call-parser ${_detectToolParser(modelName)}`; 772 if (f.expert_parallel) cmd += ' --enable-expert-parallel'; 773 if (f.reasoning_parser) { 774 const rp = typeof f.reasoning_parser === 'string' && f.reasoning_parser !== 'true' 775 ? f.reasoning_parser : (f._reasoning_parser_value || _detectReasoningParser(modelName) || ''); 776 if (rp) cmd += ` --reasoning-parser ${rp}`; 777 } 778 if (!f.prefix_cache) cmd += ' --disable-radix-cache'; 779 if (f.enforce_eager) cmd += ' --disable-cuda-graph'; 780 const _decodeGraph = String(f.sglang_decode_graph || '').trim(); 781 if (!f.enforce_eager && _decodeGraph === 'disabled') { 782 cmd += ' --cuda-graph-backend-decode disabled'; 783 } else if (!f.enforce_eager && _decodeGraph === 'bs16') { 784 cmd += ' --cuda-graph-max-bs-decode 16'; 785 } else if (!f.enforce_eager && _isDsv4 && !/\s--cuda-graph-max-bs-decode\b/.test(cmd) && !/\s--cuda-graph-backend-decode\b/.test(cmd)) { 786 cmd += ' --cuda-graph-backend-decode disabled'; 787 } 788 } else if (backend === 'llamacpp') { 789 const ggufPath = f._gguf_path || 'model.gguf'; 790 // GPU list â read from gpus (button strip); fall back to gpu_id for 791 // backward-compat with older saved presets that pre-date the removal. 792 const gpuId = (f.gpus || f.gpu_id || '').toString().trim(); 793 const _targetHost = Object.prototype.hasOwnProperty.call(f, 'host') 794 ? String(f.host || '').trim() 795 : String(_envState.remoteHost || '').trim(); 796 const _isWin = _targetHost ? _isWindows(_targetHost) : _isWindows('local'); 797 const _localWindows = _isWin && !_targetHost; 798 const py = _isWin ? 'python' : 'python3'; 799 // CPU-only serve (-ngl 0): drop the GPU-only flags, otherwise the command 800 // mixes "zero GPU layers" with CUDA unified-memory + flash-attn and fails to 801 // start (issue #1291). Only affects the ngl=0 path; GPU serving is unchanged. 802 // The Inference mode pill (GPU/CPU) above gates this â when the user picks 803 // CPU, force ngl=0 here so all downstream flag-suppression fires 804 // consistently regardless of what the (now-hidden) ngl input shows. 805 const _llamaMode = String(f.llama_mode || '').toLowerCase(); 806 if (_llamaMode === 'unified') f.unified_mem = true; 807 if (_llamaMode === 'cpu') { 808 f.ngl = '0'; 809 } else if (['gpu', 'unified'].includes(_llamaMode) && (!f.ngl || String(f.ngl).trim() === '0')) { 810 f.ngl = '99'; 811 } 812 const _cpuOnly = String(f.ngl).trim() === '0'; 813 // GGML_CUDA_* env vars are no-ops on Vulkan/ROCm/Metal/CPU. Only emit 814 // them when the detected backend is actually CUDA AND the hwfit scan 815 // was run against the currently-targeted host, so a saved preset 816 // from a prior NVIDIA target doesn't pollute a non-NVIDIA launch 817 // with misleading prefixes. 818 const _sb = String(_hwfitCache?.system?.backend || '').toLowerCase(); 819 const _hwfitHost = String(_hwfitCache?._scannedHost || ''); 820 const _curHost = _targetHost; 821 const _isCudaTarget = (_sb === 'cuda') && (_hwfitHost === _curHost); 822 const lcPrefix = (() => { 823 let p = ''; 824 if (f.unified_mem && !_cpuOnly && (!_isWin || _localWindows) && _isCudaTarget) p += `GGML_CUDA_ENABLE_UNIFIED_MEMORY=1 `; 825 // No GPU env var in CPU mode - `-ngl 0` already disables offload 826 // so CUDA_VISIBLE_DEVICES / HIP_VISIBLE_DEVICES would be misleading 827 // clutter ("why is CUDA pinned for a CPU run?").
828 if ((!_isWin || _localWindows) && !_cpuOnly) p += _gpuEnvPrefix(gpuId); 829 return p; 830 })(); 831 if (f.unified_mem && !_cpuOnly && _isWin && !_localWindows && _isCudaTarget) cmd += `$env:GGML_CUDA_ENABLE_UNIFIED_MEMORY="1"; `; 832 if (_isWin && !_localWindows && !_cpuOnly) cmd += _gpuEnvPrefix(gpuId, true); 833 const needsGgufPrelude = /^\$\(\{\s*find\s/.test(String(ggufPath || '')); 834 const modelArg = needsGgufPrelude ? '"$MODEL_FILE"' : `"${ggufPath}"`; 835 // Prefer native llama-server. The backend bootstrap resolves/builds the 836 // right binary (Vulkan/HIP/CUDA/Metal/CPU), so keep the generated command 837 // as a validator-safe binary + args with no shell chaining. 838 // Don't suppress stderr â surface real errors (missing file, lib, OOM). 839 // Optional perf/fit flags from a hardware profile (see services/hwfit/ 840 // profiles.py). n_cpu_moe offloads MoE expert layers to CPU when the model 841 // is bigger than VRAM; flash-attn + a quantized KV cache cut KV memory and 842 // speed things up. Only emitted when set, so manual/older flows are unchanged. 843 const _ncm = (f.n_cpu_moe ?? '').toString().trim(); 844 const _kv = (f.cache_type ?? '').toString().trim(); 845 const _llamaNum = (v) => { 846 const s = String(v || '').trim(); 847 return /^\d+$/.test(s) ? s : ''; 848 }; 849 const _llamaCsv = (v) => { 850 const s = String(v || '').replace(/\s+/g, ''); 851 return /^\d+(?:\.\d+)?(?:,\d+(?:\.\d+)?)*$/.test(s) ? s : ''; 852 }; 853 let _lcExtra = ''; 854 let _lcpExtra = ''; 855 if (_ncm !== '' && Number(_ncm) > 0) { 856 _lcExtra += ` --n-cpu-moe ${_ncm}`; 857 _lcpExtra += ` --n_cpu_moe ${_ncm}`; // llama-cpp-python uses underscores 858 } 859 // Flash-attn default = auto: native llama-server picks whether to 860 // enable based on the build/model; explicit ON (the Flash-attn 861 // toggle in the form) forces it. "auto" is a meaningful arg, not 862 // omission â older builds without flash-attn ignore it cleanly, 863 // newer ones get the speedup without the user having to know. 864 if (f.flash_attn && !_cpuOnly) { 865 _lcExtra += ' --flash-attn on'; 866 _lcpExtra += ' --flash_attn true'; 867 } else if (!_cpuOnly) { 868 _lcExtra += ' --flash-attn auto'; 869 } 870 if (_kv) { 871 _lcExtra += ` --cache-type-k ${_kv} --cache-type-v ${_kv}`; 872 // llama-cpp-python exposes these as type_k/type_v; pass through best-effort. 873 _lcpExtra += ` --type_k ${_kv} --type_v ${_kv}`; 874 } 875 const _llamaFit = String(f.llama_fit || '').trim(); 876 if (['on', 'off'].includes(_llamaFit)) _lcExtra += ` --fit ${_llamaFit}`; 877 if (f.llama_no_mmap) _lcExtra += ' --no-mmap'; 878 if (f.llama_no_warmup) _lcExtra += ' --no-warmup'; 879 const _llamaSplitMode = String(f.llama_split_mode || '').trim(); 880 if (['none', 'layer', 'row', 'tensor'].includes(_llamaSplitMode)) _lcExtra += ` --split-mode ${_llamaSplitMode}`; 881 const _llamaTensorSplit = _llamaCsv(f.llama_tensor_split); 882 if (_llamaTensorSplit) _lcExtra += ` --tensor-split ${_llamaTensorSplit}`; 883 const _llamaMainGpu = _llamaNum(f.llama_main_gpu); 884 if (_llamaMainGpu) _lcExtra += ` --main-gpu ${_llamaMainGpu}`; 885 const _llamaParallel = _llamaNum(f.llama_parallel); 886 if (_llamaParallel) _lcExtra += ` --parallel ${_llamaParallel}`; 887 const _llamaBatch = _llamaNum(f.llama_batch_size); 888 if (_llamaBatch) _lcExtra += ` --batch-size ${_llamaBatch}`; 889 const _llamaUBatch = _llamaNum(f.llama_ubatch_size); 890 if (_llamaUBatch) _lcExtra += ` --ubatch-size ${_llamaUBatch}`; 891 if (f.llama_speculative_mtp) { 892 const specTokens = parseInt(f.llama_spec_tokens, 10); 893 const specN = Number.isFinite(specTokens) && specTokens > 0 ? specTokens : 3; 894 _lcExtra += ` --spec-type draft-mtp --spec-draft-n-max ${specN}`; 895 } 896 // Vision: serve the multimodal projector so the model can read images. The 897 // mmproj path is resolved at runtime (find mmproj-*.gguf next to the model); 898 // only emitted when the Vision toggle is on AND a projector was found. 899 if (f.vision && f._mmproj_path) { 900 _lcExtra += ` --mmproj "${f._mmproj_path}" --image-max-tokens 1024`; 901 // llama-cpp-python takes the projector via --clip_model_path. 902 _lcpExtra += ` --clip_model_path "${f._mmproj_path}"`; 903 } 904 const _lcServer = `${lcPrefix}llama-server --model ${modelArg}
904 --host 0.0.0.0 --port ${f.port || '8080'} -ngl ${f.ngl || '99'} -c ${f.ctx || '8192'}${_lcExtra}`; 905 const _lcpServer = `${lcPrefix}${py} -m llama_cpp.server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} --n_gpu_layers ${f.ngl || '99'} --n_ctx ${f.ctx || '8192'}${_lcpExtra}`; 906 if (_localWindows) { 907 // Local Windows serve is launched through Git Bash, so use the native 908 // llama-server shape and let PATH resolve the CUDA Release wrapper. 909 cmd += _lcServer; 910 } else if (_isWin) { 911 cmd += _lcpServer; 912 } else { 913 cmd += _lcServer; 914 } 915 if (needsGgufPrelude) { 916 cmd = `MODEL_FILE=${ggufPath} && { [ -n "$MODEL_FILE" ] && [ -f "$MODEL_FILE" ]; } || { echo "ERROR: No GGUF found on this host"; exit 1; } && ${cmd}`; 917 } 918 } else if (backend === 'ollama') { 919 const ollamaPort = f.port || '11434'; 920 // GGUF + Ollama: delegate to the iGPU-bound ollama-test container via 921 // its /usr/local/bin/ollama-import helper. Plain `ollama serve` errors 922 // 127 on hosts where ollama isn't on PATH (and even when it is, it 923 // doesn't import the GGUF â it just starts the daemon). Args are all 924 // literal so the cookbook validator (which bans &&/||/;/$() ) is 925 // happy: `docker exec ollama-test ollama-import <repo> <name> <ctx> 926 // <file>`. The helper handles the find/Modelfile/preload dance. 927 if (modelName.includes('/') && (f.gguf_file || /-GGUF$/i.test(modelName))) { 928 // HF-GGUF repo â import + preload + tail 929 const _name = (modelName.split('/').pop() || modelName) 930 .replace(/-GGUF$/i, '') 931 .toLowerCase() 932 .replace(/[^a-z0-9._:-]+/g, '-') 933 .replace(/^-+|-+$/g, ''); 934 const _ctx = f.ctx || '8192'; 935 const _file = (f.gguf_file || '').split('/').pop() || ''; 936 // Trailing GGUF_FILE is optional; helper picks the first match if empty. 937 cmd = `docker exec ollama-test ollama-import ${modelName} ${_name} ${_ctx}${_file ? ' ' + _file : ''}`; 938 } else if (!modelName.includes('/') && modelName) { 939 // Already-pulled Ollama tag (e.g. `qwen2.5:7b`). On remote hosts the 940 // runtime is the ROCm Ollama sidecar; this quick command verifies the 941 // tag exists, then the backend auto-registers http://host.docker.internal:11434/v1. 942 cmd = `docker exec ollama-rocm ollama show ${modelName}`; 943 } else { 944 const bindHost = _envState.remoteHost ? '0.0.0.0' : '127.0.0.1'; 945 const hostEnv = ollamaPort !== '11434' ? `OLLAMA_HOST=${bindHost}:${ollamaPort} ` : ''; 946 cmd = `${hostEnv}ollama serve`; 947 } 948 } else if (backend === 'diffusers') { 949 const gpuStr = f.gpus?.trim(); 950 cmd += _gpuEnvPrefix(gpuStr); 951 const diffusersPy = _isWindows() ? 'python' : _py3Bin; 952 const diffHost = f.host ? '0.0.0.0' : '127.0.0.1'; 953 cmd += `${diffusersPy} scripts/diffusion_server.py --model ${modelName} --host ${diffHost} --port ${f.port || '8100'}`; 954 if (f.host) { 955 const allowedHost = String(f.host || '').split('@').pop().split(':')[0].trim(); 956 if (allowedHost) cmd += ` --allowed-host ${allowedHost}`; 957 } 958 if (f.diff_dtype && f.diff_dtype !== 'bfloat16') cmd += ` --dtype ${f.diff_dtype}`; 959 if (f.diff_device_map && f.diff_device_map !== 'balanced') cmd += ` --device-map ${f.diff_device_map}`; 960 if (f.diff_steps) cmd += ` --steps ${f.diff_steps}`; 961 if (f.diff_guidance_scale) cmd += ` --guidance-scale ${_numField(f.diff_guidance_scale) || f.diff_guidance_scale}`; 962 if (String(f.diff_negative_prompt || '').trim()) cmd += ` --negative-prompt ${_shellQuote(String(f.diff_negative_prompt || '').trim())}`; 963 if (f.diff_width) cmd += ` --width ${f.diff_width}`; 964 if (f.diff_height) cmd += ` --height ${f.diff_height}`; 965 const _diffLoras = _listField(f.diff_lora); 966 if (_diffLoras.length) cmd += ` --lora ${_shellQuote(_diffLoras.join(','))}`; 967 const _diffLoraScale = _numField(f.diff_lora_scale); 968 if (_diffLoraScale) cmd += ` --lora-scale ${_diffLoraScale}`; 969 if (f.diff_offload) cmd += ' --cpu-offload'; 970 if (f.diff_attention_slicing) cmd += ' --attention-slicing'; 971 if (f.diff_vae_slicing) cmd += ' --vae-slicing'; 972 if (f.diff_harmonize_gpu) cmd += ` --harmonize-gpu ${f.diff_harmonize_gpu}`; 973 } else if (backend === 'mlx_image') { 974 const mlxPy = _isWindows() ? 'python' : _py3Bin; 975 const mlxHost = f.host ? '0.0.0.0' : '127.0.0.1'; 976 cmd += `${mlxPy} scripts/mlx_image_server.py --model ${_shellQuote(modelName)} --host ${mlxHost} --port ${f.port || '8100'}`; 977 if (f.diff_steps) cmd += ` --steps ${f.diff_steps}`; 978 if (f.diff_width) cmd += ` --width ${f.diff_width}`; 979 if (f.diff_height) cmd += ` --height ${f.diff_height}`; 980 const _mlxBaseModel = String(f.mlx_base_model || '').trim(); 981 if (_mlxBaseModel) cmd += ` --base-model ${_shellQuote(_mlxBaseModel)}`; 982 const _mlxLoraStyle = String(f.mlx_lora_style || '').trim(); 983 if (_mlxLoraStyle) cmd += ` --lora-style ${_shellQuote(_mlxLoraStyle)}`; 984 const _mlxLoraPaths = _listField(f.mlx_lora_paths); 985 if (_mlxLoraPaths.length) cmd += ` --lora-paths ${_mlxLoraPaths.map(_shellQuote).join(' ')}`; 986 const _mlxLoraScales = _listField(f.mlx_lora_scales).filter(s => /^-?\d+(?:\.\d+)?$/.test(s)); 987 if (_mlxLoraScales.length) cmd += ` --lora-scales ${_mlxLoraScales.map(_shellQuote).join(' ')}`; 988 } else if (backend === 'mlx') { 989 const mlxPy = _isWindows() ? 'python' : _py3Bin; 990 const mlxHost = f.host ? '0.0.0.0' : '127.0.0.1'; 991 cmd += `${mlxPy} -m mlx_lm.server --model ${_shellQuote(modelName)} --host ${mlxHost} --port ${f.port || '8080'}`; 992 const mlxMaxTokens = String(f.ctx || '').trim(); 993 if (/minimax|mini-max/i.test(modelName)) { 994 cmd += ` --temp 0.7 --top-p 0.9 --max-tokens ${mlxMaxTokens || '2048'}`; 995 } else if (/^\d+$/.test(mlxMaxTokens)) { 996 // MLX-LM server has no vLLM-style --context-length flag. The closest 997 // server-side request budget it exposes is --max-tokens, so wire the 998 // Cookbook Context/Auto control there for MLX launches. 999 cmd += ` --max-tokens ${mlxMaxTokens}`; 1000 } 1001 } 1002 return cmd; 1003} 1004 1005/** Get inline logo HTML for a model name/repo_id */ 1006export function modelLogo(name) { 1007 const logo = providerLogo(name); 1008 const svg = logo || '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="4"/></svg>'; 1009 return `<span style="width:12px;height:12px;display:inline-flex;align-items:center;vertical-align:-2px;margin-right:3px;opacity:${logo ? '0.5' : '0.2'};">${svg}</span>`;
1010} 1011 1012// Use shared esc() from ui module 1013export const esc = uiModule.esc; 1014 1015// ââ Clipboard ââ 1016 1017export function _copyText(text) { 1018 if (navigator.clipboard && navigator.clipboard.writeText) { 1019 return navigator.clipboard.writeText(text).catch(() => _fallbackCopy(text)); 1020 } 1021 return _fallbackCopy(text); 1022} 1023 1024function _fallbackCopy(text) { 1025 const ta = document.createElement('textarea'); 1026 ta.value = text; 1027 ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px'; 1028 document.body.appendChild(ta); 1029 ta.select(); 1030 try { document.execCommand('copy'); } catch (_) {} 1031 document.body.removeChild(ta); 1032 return Promise.resolve(); 1033} 1034 1035// ââ Presets (server-synced; localStorage is offline cache) ââ 1036// Presets sync to/from cookbook_state.json via _syncToServer / _syncFromServer. 1037// _loadPresets reads the cache (which gets refreshed at app boot and on modal open). 1038 1039export function _loadPresets() { 1040 try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; } 1041 catch { return []; } 1042} 1043 1044export function _savePresets(presets) { 1045 localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); 1046 // Trigger sync to server (via running module's _syncToServer debounce) 1047 _saveTasks(_loadTasks()); 1048} 1049 1050function _envStateForStorage() { 1051 const { hfToken, ...safeState } = _envState; 1052 return safeState; 1053} 1054 1055function _readStoredEnvState() { 1056 const stored = JSON.parse(localStorage.getItem(LAST_STATE_KEY) || '{}'); 1057 delete stored.hfToken; 1058 return stored; 1059} 1060 1061export function _persistEnvState() { 1062 try { localStorage.setItem(LAST_STATE_KEY, JSON.stringify(_envStateForStorage())); } 1063 catch (_) {} 1064 _saveTasks(_loadTasks()); 1065} 1066 1067// ââ Dependencies ââ 1068 1069// Category colors removed â using theme CSS classes instead 1070 1071async function _fetchDependencies() { 1072 const list = document.getElementById('cookbook-deps-list'); 1073 if (!list) return; 1074 // Use the shared whirlpool spinner so the user sees the request is in 1075 // flight (the package list takes a few seconds to enumerate on slow links). 1076 list.innerHTML = ''; 1077 let _spin = null; 1078 try { 1079 const sp = (await import('./spinner.js')).default; 1080 _spin = sp.createWhirlpool(22); 1081 _spin.element.classList.add('cookbook-section-loading-wp'); 1082 _spin.element.style.cssText = 'margin:24px auto 0;display:block;width:22px;height:22px;'; 1083 list.appendChild(_spin.element); 1084 const label = document.createElement('div'); 1085 label.className = 'hwfit-loading'; 1086 label.textContent = 'Loading packagesâ¦'; 1087 label.style.cssText = 'text-align:center;opacity:0.5;font-size:11px;margin-top:6px;'; 1088 list.appendChild(label); 1089 } catch { 1090 list.innerHTML = '<div class="hwfit-loading">Loading packages...</div>'; 1091 } 1092 try { 1093 // Resolve the target server from the deps dropdown so remote-target 1094 // packages are checked on THAT server's venv (not just the local host). 1095 let _depHost = '', _depPort = '', _depVenv = '', _depPlatform = ''; 1096 const _dsel = document.getElementById('hwfit-deps-server'); 1097 const _depSrv = _dsel && _dsel.value !== 'local' ? _serverByVal(_dsel.value) : null; 1098 if (_depSrv) { 1099 _depHost = _depSrv.host || ''; _depPort = _depSrv.port || ''; _depVenv = _depSrv.envPath || ''; _depPlatform = _depSrv.platform || ''; 1100 } else if (_envState.remoteHost) { 1101 _depHost = _envState.remoteHost; _depPort = _getPort(_envState.remoteHost) || ''; _depVenv = _envState.envPath || ''; _depPlatform = _envState.platform || ''; 1102 } 1103 const _pkgParams = new URLSearchParams(); 1104 if (_depHost) { 1105 _pkgParams.set('host', _depHost); 1106 if (_depPort) _pkgParams.set('ssh_port', _depPort); 1107 if (_depVenv) _pkgParams.set('venv', _depVenv); 1108 if (_depPlatform) _pkgParams.set('platform', _depPlatform); 1109 } 1110 // Pass the detected backend so the server can build a single 1111 // OS+backend-aware install command per row (e.g. add nvidia-cuda-toolkit 1112 // on a CUDA-Debian box, vulkan-headers on a Vulkan-Arch box, etc.) 1113 // instead of dumping every distro's syntax as a hint. 1114 const _depBackend = String(_hwfitCache?.system?.backend || '').toLowerCase(); 1115 if (_depBackend && _hwfitCache?._scannedHost === _depHost) { 1116 _pkgParams.set('backend', _depBackend); 1117 } 1118 if (_cachedModelIds && _cachedModelIds.size) { 1119 const _hint = Array.from(_cachedModelIds) 1120 .filter(id => /krea/i.test(String(id || ''))) 1121 .slice(0, 20) 1122 .join(','); 1123 if (_hint) _pkgParams.set('model_hint', _hint); 1124 } 1125 const resp = await fetch('/api/cookbook/packages' + (_pkgParams.toString() ? '?' + _pkgParams.toString() : '')); 1126 const data = await resp.json(); 1127 const pkgs = data.packages || []; 1128 if (!pkgs.length) { list.innerHTML = '<div class="hwfit-loading">No packages found</div>'; return; } 1129 const _winUnsupported = new Set(['hf_transfer', 'vllm', 'rembg', 'gfpgan']); 1130 const _systemInstallable = new Set(['tmux']); 1131 1132 const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => { 1133 if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">
1133N/A</span>`; 1134 if (pkg.installed && isSystemDep) return `<span class="cookbook-dep-tag cookbook-dep-installed" title="Found on selected server">Installed</span>`; 1135 if (pkg.installed && pkg.pip_update_available === false && pkg.name !== 'llama_cpp') { 1136 const tip = esc(pkg.update_note || pkg.status_note || 'Found externally; update outside Odysseus.'); 1137 return `<span class="cookbook-dep-tag cookbook-dep-installed" title="${tip}">Installed</span>`; 1138 } 1139 if (pkg.installed) return `<button class="cookbook-dep-tag cookbook-dep-installed cookbook-dep-installed-btn" title="Installed â click for actions"><span class="cookbook-dep-installed-label">Installed</span><span class="cookbook-dep-caret">▾</span></button>`; 1140 if (isSystemDep) { 1141 const depTip = esc(pkg.install_hint || 'Install this OS package on the selected server.'); 1142 if (pkg.applicable !== false && _systemInstallable.has(pkg.name)) { 1143 return `<button type="button" class="cookbook-dep-tag cookbook-dep-install cookbook-dep-install-sysdeps" data-dep-sysdeps="${esc(pkg.name)}" data-dep-target="${isLocal ? 'local' : 'remote'}" title="${depTip}">Install</button>`; 1144 } 1145 const depLabel = pkg.applicable === false ? 'N/A ?' : 'Missing'; 1146 const depStyle = pkg.name === 'docker' ? ' style="width:87.7px;justify-content:center;"' : ''; 1147 return `<span class="cookbook-dep-tag cookbook-dep-na" title="${depTip}"${depStyle}>${depLabel}</span>`; 1148 } 1149 return `<button class="cookbook-dep-tag cookbook-dep-install" data-dep-pip="${esc(pkg.pip)}" data-dep-target="${isLocal ? 'local' : 'remote'}">Install</button>`; 1150 }; 1151 1152 // Per-package inline glyphs â same accent-coloured marks used in the 1153 // Backend picker on the Run page, so the Dependencies row visually 1154 // matches the engine you're configuring. Unknown packages get no 1155 // icon (the name alone is fine for librosa, hf_transfer, etc.). 1156 const _DEP_GLYPHS = { 1157 vllm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>', 1158 sglang: '<span aria-hidden="true" style="display:block;width:13px;height:13px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>', 1159 mlx_lm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"/><path d="M16 6v12"/><path d="M20 6v12"/></svg>', 1160 mflux: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>', 1161 boogu_image_mlx: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 17c2.5-4 4.5-4 7 0"/><circle cx="9" cy="9" r="1"/><circle cx="15" cy="9" r="1"/></svg>', 1162 llama_cpp: '<svg width="13" height="13" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"/><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"/></svg>', 1163 ollama: '<img src="/static/icons/ollama-mark-crop.png" alt="" aria-hidden="true" width="13" height="13" style="display:block;width:13px;height:13px;object-fit:contain;" />', 1164 diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>', 1165 krea_diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19V5"/><path d="M4 12h4"/><path d="M12 5l-7 7 7 7"/><path d="M14 19l3-14 3 14"/><path d="M15.3 13h3.4"/></svg>', 1166 sam_mask: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>', 1167 }; 1168 const _depGlyphHtml = (name) => { 1169 const g = _DEP_GLYPHS[name]; 1170 return g ? `<span class="cookbook-dep-glyph" aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:14px;
1170height:14px;color:var(--accent, var(--red));margin-right:5px;vertical-align:-2px;">${g}</span>` : ''; 1171 }; 1172 1173 const _depRow = (pkg) => { 1174 const isLocal = pkg.target === 'local'; 1175 const isSystemDep = pkg.kind === 'system'; 1176 const winBlocked = !isLocal && _isWindows() && _winUnsupported.has(pkg.name); 1177 const note = pkg.status_note ? `<div class="memory-item-meta" style="font-size:10px;opacity:0.65;margin-top:3px;">${esc(pkg.status_note)}</div>` : ''; 1178 const updateNote = pkg.installed && pkg.pip_update_available === false && pkg.update_note ? `<div class="memory-item-meta" style="font-size:10px;opacity:0.55;margin-top:3px;">${esc(pkg.update_note)}</div>` : ''; 1179 // Inline rebuild/reinstall tag. Styled as a .cookbook-dep-tag so it 1180 // matches the LLM category tag's pill look, and lives to the LEFT of the 1181 // category tag. llama_cpp uses the /api/cookbook/rebuild-engine flow 1182 // (clear cached binary so next serve recompiles); vllm/sglang use the 1183 // diagnosis-style `_launchServeTask` with `pip install --force-reinstall` 1184 // so the user can watch the pip install in the Running tab. 1185 let _rebuildBtn = ''; 1186 if (pkg.name === 'vllm' && pkg.installed) { 1187 _rebuildBtn = `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild cookbook-dep-reinstall" data-reinstall-pkg="vllm" title="Force-reinstall vLLM (pulls a matching torch). Runs as a tmux task in the Running tab.">Reinstall</button>`; 1188 } else if (pkg.name === 'sglang' && pkg.installed) { 1189 _rebuildBtn = `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild cookbook-dep-reinstall" data-reinstall-pkg="sglang" title="Force-reinstall SGLang (pulls a matching torch). Runs as a tmux task in the Running tab.">Reinstall</button>`; 1190 } 1191 // For backends with a recipe catalog (vllm / sglang / llama_cpp), 1192 // append a caret button that toggles a per-row recipe panel below. 1193 const hasRecipe = RECIPE_BACKENDS.has(pkg.name); 1194 // Standalone recipe-caret button removed â the "Pick install 1195 // command" action lives inside the Installed â¾ dropdown menu 1196 // (see _showDepMenu) so each row only has ONE caret to click. 1197 // Kept the variable so downstream concat code stays the same. 1198 const recipeCaret = ''; 1199 const recipePanel = hasRecipe ? _recipePanelHtml(pkg.name) : ''; 1200 // When llama_cpp (or any future engine) reports build_deps_missing 1201 // from its system_prereqs probe, surface a one-tap install button 1202 // that fires the OS package manager on the target via 1203 // /api/cookbook/install-system-deps. Keeps the user inside Cookbook 1204 // instead of forcing them out to a shell to apt/pacman/dnf. 1205 const _bdm = Array.isArray(pkg.build_deps_missing) ? pkg.build_deps_missing : []; 1206 const _buildDepsBtn = _bdm.length 1207 ? `<button type="button" class="cookbook-dep-tag cookbook-dep-install cookbook-dep-install-sysdeps" data-dep-sysdeps="${esc(_bdm.join(','))}" data-dep-target="${isLocal ? 'local' : 'remote'}" title="Install ${esc(_bdm.join(', '))} via the OS package manager on this target (requires passwordless sudo or root).">Install build deps</button>` 1208 : ''; 1209 // Partial-state row (replaces the cryptic yellow "Partial â¾" tag). 1210 // Renders inline as a yellow banner with two clear actions: one-tap 1211 // Install (runs the reinstall in cookbook) or Copy command (paste 1212 // into a terminal). Same content surfaces whether the user solves 1213 // it from inside Cookbook or from a shell. 1214 const _gpuWheelCmd = 'CMAKE_ARGS="-DGGML_CUDA=on" python3 -m pip install --user --break-system-packages --force-reinstall --no-cache-dir "llama-cpp-python[server]" --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124'; 1215 const _gpuUpgradeBox = (pkg.partial && pkg.partial_action === 'reinstall_llama_cpp_cuda') 1216 ? `<div class="cookbook-dep-gpu-upgrade" style="margin-top:6px;font-size:11px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;background:color-mix(in srgb, var(--yellow, #f1fa8c) 14%, transparent);border:1px solid color-mix(in srgb, var(--yellow, #f1fa8c) 40%, var(--border));padding:6px 8px;border-radius:6px;">` 1217 + `<span style="flex:1;min-width:160px;">Installed CPU-only â GPU detected on this target. Upgrade for ~10Ã faster inference.</span>` 1218 + `<button type="button" class="cookbook-dep-tag cookbook-dep-install c
1218ookbook-dep-install-gpu-wheel" data-dep-target="${isLocal ? 'local' : 'remote'}" data-dep-gpu-cmd="${esc(_gpuWheelCmd)}" style="font-weight:600;">Install GPU wheel</button>` 1219 + `<button type="button" class="cookbook-dep-tag cookbook-dep-cmd-copy" data-dep-cmd-copy="${esc(_gpuWheelCmd)}" title="Copy command to clipboard">Copy command</button>` 1220 + `</div>` 1221 : ''; 1222 return `<div class="cookbook-dep-row${winBlocked ? ' cookbook-dep-blocked' : ''}" data-pkg-name="${esc(pkg.name)}" data-dep-pip="${esc(pkg.pip || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}" data-dep-kind="${esc(pkg.kind || 'python')}">` 1223 + `<div class="cookbook-dep-info">` 1224 + `<div class="memory-item-title">${_depGlyphHtml(pkg.name)}${esc(pkg.name)}</div>` 1225 + `<div class="memory-item-meta" style="font-size:10px;opacity:0.5;margin-top:2px;">${esc(pkg.desc)}</div>` 1226 + note 1227 + updateNote 1228 + `</div>` 1229 + _rebuildBtn 1230 + _buildDepsBtn 1231 + `<span class="cookbook-dep-tag cookbook-dep-cat">${esc(pkg.category)}</span>` 1232 + _statusTag(pkg, isLocal, isSystemDep, winBlocked) 1233 + recipeCaret 1234 + `</div>` 1235 + recipePanel; 1236 }; 1237 1238 // Prepend the configured venv's activate line (pip variant only) so 1239 // the user sees a paste-ready sequence; Run keeps using env_prefix to 1240 // activate the same venv before the pip command. Docker variant skips 1241 // the activate line â `docker pull` doesn't need a venv. 1242 function _recipeRuntimeCommands(commands, variant) { 1243 if (variant === 'docker') return commands; 1244 const envPath = (_envState.envPath || '').replace(/\/+$/, ''); 1245 if (_envState.env !== 'venv' || !envPath) return commands; 1246 const py = _shellQuote(`${envPath}/bin/python3`); 1247 return commands.map(cmd => String(cmd || '').replace(/^python(\s+-m\s+pip\b)/, `${py}$1`)); 1248 } 1249 function _recipeDisplayText(commands, variant) { 1250 const runtimeCommands = _recipeRuntimeCommands(commands, variant); 1251 if (variant === 'docker') return commands.join('\n'); 1252 const envPath = (_envState.envPath || '').replace(/\/+$/, ''); 1253 const activate = envPath 1254 ? `source ${envPath}${envPath.endsWith('/bin/activate') ? '' : '/bin/activate'}` 1255 : '# (activate your venv first)'; 1256 return [activate, ...runtimeCommands].join('\n'); 1257 } 1258 1259 // Per-backend recipe panel (model picker + commands + Copy/Run). 1260 // Lives directly below the row it expands and starts collapsed. 1261 // The model picker lists every downloaded model from _cachedModelIds 1262 // (the same set the Launch tab uses); pickRecipe() then finds the 1263 // best-matching recipe for whatever the user selects, with the 1264 // backend's generic entry as the fallback. 1265 function _recipePanelHtml(backend) { 1266 const candidates = recipesForBackend(backend); 1267 if (!candidates.length) return ''; 1268 const downloadedIds = _cachedModelIds ? Array.from(_cachedModelIds).sort() : []; 1269 const modelOptions = downloadedIds.length 1270 ? downloadedIds.map(id => `<option value="${esc(id)}">${esc(id)}</option>`).join('') 1271 : ''; 1272 // "Other" entry: user types/pastes an id, OR uses the generic fallback 1273 // when no models have been downloaded yet. 1274 const otherOpt = `<option value="">Other (generic ${esc(backend)} install)</option>`; 1275 const opts = modelOptions + otherOpt; 1276 // Initial recipe: the generic fallback (matches first time, no model id). 1277 const initial = pickRecipe(backend, '') || candidates[0]; 1278 const initialVariant = RECIPE_DEFAULT_VARIANT; 1279 const initialCmds = recipeCommands(initial, initialVariant); 1280 const initialRuntimeCmds = _recipeRuntimeCommands(initialCmds, initialVariant); 1281 const rightActive = initialVariant === 'docker' ? ' mode-right' : ''; 1282 return `<div class="cookbook-dep-recipe-panel" data-dep-recipe-panel="${esc(backend)}" data-dep-recipe-active-variant="${esc(initialVariant)}" style="display:none;margin:-4px 0 8px;padding:8px 12px 10px;background:rgba(0,0,0,0.04);border:1px solid var(--border);border-top:none;border-radius:0 0 6px 6px;"> 1283 <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
1284 <span style="font-size:11px;opacity:0.75;flex-shrink:0;">Serving which model?</span> 1285 <select class="settings-select cookbook-dep-recipe-pick" data-dep-recipe-pick="${esc(backend)}" style="flex:1;font-size:11px;padding:3px 6px;">${opts}</select> 1286 <div class="mode-toggle${rightActive}" data-dep-recipe-variants="${esc(backend)}" style="flex-shrink:0;"> 1287 <button type="button" class="mode-toggle-btn${initialVariant === 'pip' ? ' active' : ''}" data-dep-recipe-variant="${esc(backend)}" data-variant="pip" aria-pressed="${initialVariant === 'pip'}">Pip</button> 1288 <button type="button" class="mode-toggle-btn${initialVariant === 'docker' ? ' active' : ''}" data-dep-recipe-variant="${esc(backend)}" data-variant="docker" aria-pressed="${initialVariant === 'docker'}">Docker</button> 1289 </div> 1290 </div> 1291 <div style="position:relative;"> 1292 <pre class="cookbook-dep-recipe-cmds" data-dep-recipe-cmds="${esc(backend)}" data-dep-recipe-install="${esc(initialRuntimeCmds.join('\n'))}" style="margin:0;padding:8px 36px 8px 10px;background:rgba(0,0,0,0.08);border-radius:4px;font-size:11px;line-height:1.5;overflow-x:auto;white-space:pre;">${esc(_recipeDisplayText(initialCmds, initialVariant))}</pre> 1293 <button type="button" id="recipe-copy-${esc(backend)}" class="cookbook-dep-recipe-copy" data-dep-recipe-copy="${esc(backend)}" title="Copy" aria-label="Copy" style="position:absolute;top:6px;right:6px;padding:3px 5px;background:none;border:none;color:inherit;opacity:0.7;cursor:pointer;display:inline-flex;align-items:center;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button> 1294 </div> 1295 <div style="display:flex;gap:6px;justify-content:flex-end;margin-top:6px;"> 1296 <button type="button" class="cookbook-dep-tag cookbook-dep-install cookbook-dep-recipe-run" data-dep-recipe-run="${esc(backend)}" style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;">
1296<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>Run</button> 1297 </div> 1298 </div>`; 1299 } 1300 1301 const _rowsHtml = (items) => items.map(_depRow).join(''); 1302 const _sectionHeader = (title, note) => 1303 `<div class="cookbook-dep-section"><span class="cookbook-dep-section-title">${title}</span><span class="cookbook-dep-section-note">${note}</span></div>`; 1304 const _section = (title, note, items) => 1305 items.length ? _sectionHeader(title, note) + _rowsHtml(items) : ''; 1306 const _pkgOrder = { 1307 System: ['tmux', 'docker'], 1308 Tools: ['hf_transfer'], 1309 LLM: ['llama_cpp', 'sglang', 'vllm', 'mlx_lm'], 1310 Image: ['diffusers', 'krea_diffusers', 'transformers', 'sam_mask', 'mflux', 'boogu_image_mlx', 'mlx_vlm'], 1311 }; 1312 const _sortDeps = (items, category) => { 1313 const order = _pkgOrder[category] || []; 1314 return [...items].sort((a, b) => { 1315 const ai = order.indexOf(a.name); 1316 const bi = order.indexOf(b.name); 1317 const ar = ai === -1 ? 999 : ai; 1318 const br = bi === -1 ? 999 : bi; 1319 return ar - br || String(a.name || '').localeCompare(String(b.name || '')); 1320 }); 1321 }; 1322 const _serverDepsHtml = (items) => { 1323 const byCat = new Map(); 1324 for (const item of items) { 1325 const cat = item.category || 'Other'; 1326 if (!byCat.has(cat)) byCat.set(cat, []); 1327 byCat.get(cat).push(item); 1328 } 1329 const parts = []; 1330 const order = ['System', 'Tools', 'Image', 'LLM', 'Audio', 'Other']; 1331 for (const cat of order) { 1332 const catItems = _sortDeps(byCat.get(cat) || [], cat); 1333 if (!catItems.length) continue; 1334 if (cat === 'Image') { 1335 const mlxNames = new Set(['mflux', 'boogu_image_mlx', 'mlx_vlm']); 1336 const general = catItems.filter(p => !mlxNames.has(p.name)); 1337 const mlx = catItems.filter(p => mlxNames.has(p.name)); 1338 parts.push(_sectionHeader('Image', 'Diffusers and shared image tooling.')); 1339 if (general.length) parts.push(_rowsHtml(general)); 1340 if (mlx.length) { 1341 parts.push( 1342 `<div class="cookbook-dep-subgroup">` 1343 + `<div class="cookbook-dep-subgroup-title"><span>MLX image runtimes</span><em>Apple Silicon only</em></div>` 1344 + _rowsHtml(mlx) 1345 + `</div>` 1346 ); 1347 } 1348 continue; 1349 } 1350 const note = cat === 'System' 1351 ? 'OS tools needed for background tasks.' 1352 : cat === 'LLM' 1353 ? 'Text model serving engines and download helpers.' 1354 : cat === 'Tools' 1355 ? 'Browser and assistant utilities.' 1356 : ''; 1357 parts.push(_section(cat, note, catItems)); 1358 } 1359 return parts.join(''); 1360 }; 1361 const _appDepsHtml = (items) => { 1362 if (!items.length) return ''; 1363 const byCat = new Map(); 1364 for (const item of items) { 1365 const cat = item.category || 'Other'; 1366 if (!byCat.has(cat)) byCat.set(cat, []); 1367 byCat.get(cat).push(item); 1368 } 1369 const parts = [_sectionHeader('Odysseus app', 'Run inside the Odysseus app itself.')]; 1370 const order = ['System', 'Tools', 'Image', 'LLM', 'Audio', 'Other']; 1371 for (const cat of order) { 1372 const catItems = _sortDeps(byCat.get(cat) || [], cat); 1373 if (!catItems.length) continue; 1374 const note = cat === 'LLM' 1375 ? 'Local app model helpers.' 1376 : cat === 'Image' 1377 ? 'Editor image tools.' 1378 : cat === 'Tools' 1379 ? 'Browser and assistant utilities.' 1380 : ''; 1381 parts.push(_section(cat, note, catItems)); 1382 } 1383 return parts.join(''); 1384 }; 1385 1386 const _viewingRemote = !!(_dsel && _dsel.value && _dsel.value !== 'local'); 1387 const _visibleDep = (p) => p.applicable !== false || p.installed || (p.kind === 'system' && p.name !== 'APFEL'); 1388 const _appDeps = pkgs.filter(p => p.target === 'local' && _visibleDep(p)); 1389 const _serverDeps = pkgs.filter(p => p.target !== 'local' && _visibleDep(p)); 1390 1391 list.innerHTML = [ 1392 _viewingRemote ? '' : _appDepsHtml(_appDeps), 1393 _serverDepsHtml(_serverDeps), 1394 ].join(''); 1395 1396 // Shared install/update routine â used by the Install button and the 1397 // "Update" item in an installed package's â® menu. `upgrade` adds pip -U; 1398 // `statusEl`, when given, shows "Installingâ¦/Updatingâ¦" and is disabled. 1399 async function _installDep(pipName, pkgName, isLocalOnly, upgrade, statusEl) { 1400 let targetServer = null; 1401 if (isLocalOnly) { 1402 _envState.remoteHost = ''; 1403 _envState.env = 'none'; 1404 _envState.envPath = ''; 1405 } else { 1406 const depsServerSel = document.getElementById('hwfit-deps-server'); 1407 if (depsServerSel) { 1408 targetServer = _serverByVal(depsServerSel.value); 1409 _applyServerSelection(depsServerSel.value); 1410 } 1411 }
1412 const targetHost = isLocalOnly ? 'this server' : ((targetServer?.host || _envState.remoteHost) || 'local'); 1413 let targetEnv = isLocalOnly ? 'none' : (targetServer?.env || _envState.env || 'none'); 1414 const targetEnvPath = isLocalOnly ? '' : (targetServer?.envPath || _envState.envPath || ''); 1415 if (!isLocalOnly && targetEnvPath && (!targetEnv || targetEnv === 'none')) { 1416 targetEnv = /(?:^|\/)(?:\.?venv|env)(?:\/|$)|\/bin\/activate$/i.test(targetEnvPath) ? 'venv' : targetEnv; 1417 } 1418 const targetPlatform = isLocalOnly ? (_envState.hostPlatform || _envState.platform || '') : (targetServer?.platform || _envState.platform || ''); 1419 const targetRemoteHost = isLocalOnly ? '' : (targetServer?.host || _envState.remoteHost || ''); 1420 // Always go through `python -m pip` so the leading token is `python` 1421 // â matches the /api/model/serve allow-list (bare `pip` is blocked). 1422 // Inside a venv/conda env, `--user` is invalid (pip refuses), so we 1423 // only add `--user --break-system-packages` when there's no env â 1424 // for PEP-668-locked system pythons (Arch, newer Debian). 1425 const _inEnv = targetEnv === 'venv' || targetEnv === 'conda'; 1426 const _platform = String(targetPlatform || '').toLowerCase(); 1427 const _isAppleTarget = _platform === 'darwin' || _platform === 'macos' || _platform.includes('mac os'); 1428 const _pipFlags = (!_isWindows() && !_inEnv) ? (_isAppleTarget ? ' --user' : ' --user --break-system-packages') : ''; 1429 // Use the venv's python3 by absolute path when configured. Even with the 1430 // env_prefix sourcing activate, SSH non-interactive sessions sometimes 1431 // pick a `python3` ahead of the venv's bin on PATH, so the install 1432 // silently lands in the wrong site-packages. 1433 let _py; 1434 if (_isWindows()) { 1435 _py = 'python'; 1436 } else if (targetEnv === 'venv' && targetEnvPath) { 1437 _py = `${targetEnvPath.replace(/\/+$/, '')}/bin/python3`; 1438 } else { 1439 _py = 'python3'; 1440 } 1441 const pipArgs = String(pipName || '') 1442 .trim() 1443 .split(/\s+/) 1444 .filter(Boolean) 1445 .map(_shellQuote) 1446 .join(' '); 1447 const depTaskId = String(pkgName || pipName || 'dependency').trim().replace(/\s+/g, '_'); 1448 const cmd = `${_py} -m pip install${upgrade ? ' -U' : ''}${_pipFlags} ${pipArgs}`; 1449 let envPrefix = ''; 1450 if (_isWindows()) { 1451 if (targetEnv === 'venv' && targetEnvPath) { 1452 envPrefix = '& ' + _psQuote(targetEnvPath.endsWith('\\Scripts\\Activate.ps1') ? targetEnvPath : targetEnvPath + '\\Scripts\\Activate.ps1'); 1453 } else if (targetEnv === 'conda' && targetEnvPath) { 1454 envPrefix = 'conda activate ' + _psQuote(targetEnvPath); 1455 } 1456 } else { 1457 if (targetEnv === 'venv' && targetEnvPath) { 1458 const p = targetEnvPath; 1459 envPrefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate'); 1460 } else if (targetEnv === 'conda' && targetEnvPath) { 1461 envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(targetEnvPath); 1462 } 1463 } 1464 try { 1465 const reqBody = { 1466 repo_id: depTaskId, 1467 cmd: cmd, 1468 remote_host: targetRemoteHost || undefined, 1469 ssh_port: _getPort(targetRemoteHost) || undefined, 1470 env_prefix: envPrefix || undefined, 1471 platform: targetPlatform || undefined, 1472 }; 1473 const res = await fetch('/api/model/serve', { 1474 method: 'POST', credentials: 'same-origin', 1475 headers: { 'Content-Type': 'application/json' }, 1476 body: JSON.stringify(reqBody), 1477 }); 1478 const data = await res.json().catch(() => ({})); 1479 if (!res.ok || !data.ok) { 1480 // FastAPI HTTPException returns {detail: â¦}; the route's own 1481 // path returns {ok:false, error:â¦}. Surface whichever we get. 1482 // Long duration + an OK button â the default 1.2s toast was 1483 // disappearing before the user could read multi-clause errors 1484 // like "tmux missing on remote". 1485 const reason = data.detail || data.error || `HTTP ${res.status}`; 1486 uiModule.showToast('Install failed: ' + String(reason).slice(0, 400), { 1487 duration: 20000, 1488 action: 'OK', 1489 onAction: () => {}, 1490 }); 1491 return; 1492 }
1493 // _dep flags this as a pip dependency/driver install (not a servable 1494 // model) so the running-task card doesn't offer a "Serve â" button. 1495 const payload = { repo_id: depTaskId, _cmd: cmd, remote_host: targetRemoteHost || '', _dep: true, env_path: targetEnvPath || '', platform: targetPlatform || '' }; 1496 _addTask(data.session_id, 'pip ' + pkgName, 'download', payload); 1497 if (statusEl) { statusEl.textContent = upgrade ? 'Updating...' : 'Installing...'; statusEl.disabled = true; } 1498 uiModule.showToast(`${upgrade ? 'Updating' : 'Installing'} ${pkgName} on ${targetHost}...`); 1499 } catch (err) { 1500 uiModule.showToast('Install failed: ' + err.message, { 1501 duration: 20000, 1502 action: 'OK', 1503 onAction: () => {}, 1504 }); 1505 } 1506 } 1507 1508 // Wire install buttons (not-installed packages) 1509 list.querySelectorAll('.cookbook-dep-install:not(.cookbook-dep-recipe-run):not(.cookbook-dep-install-sysdeps)').forEach(btn => { 1510 btn.addEventListener('click', async (e) => { 1511 e.stopPropagation(); 1512 const pipName = btn.dataset.depPip; 1513 const pkgName = btn.closest('.cookbook-dep-row')?.querySelector('.memory-item-title')?.textContent || pipName; 1514 await _installDep(pipName, pkgName, btn.dataset.depTarget === 'local', !!btn.dataset.upgrade, btn); 1515 }); 1516 }); 1517 1518 // Wire "Install build deps" buttons â surfaced on rows whose 1519 // system_prereqs are missing (e.g. llama_cpp with no cmake on the 1520 // target). One-tap call to /api/cookbook/install-system-deps; the 1521 // route enforces a per-package allowlist and uses passwordless 1522 // sudo only, so it can never silently hang or stretch beyond the 1523 // build-toolchain set the catalog declares. 1524 // "Partial â¾" upgrade tag: clicking it fires the action-specific 1525 // install routine (currently only `reinstall_llama_cpp_cuda` â 1526 // forces pip install with the abetlen CUDA wheel index to add GPU 1527 // offload). Same install flow used at launch-time auto-fix, but 1528 // user-initiated here so they don't have to launch + wait + retry. 1529 list.querySelectorAll('.cookbook-dep-partial').forEach(btn => { 1530 btn.addEventListener('click', async (e) => { 1531 e.stopPropagation(); 1532 const action = btn.dataset.depPartialAction || ''; 1533 if (action !== 'reinstall_llama_cpp_cuda') return; 1534 const isLocal = btn.dataset.depTarget === 'local'; 1535 if (!isLocal) { 1536 const depsServerSel = document.getElementById('hwfit-deps-server'); 1537 if (depsServerSel) _applyServerSelection(depsServerSel.value); 1538 } 1539 const targetLabel = isLocal ? 'this server' : (_envState.remoteHost || 'remote'); 1540 const cmd = 'CMAKE_ARGS="-DGGML_CUDA=on" python3 -m pip install --user --break-system-packages --force-reinstall --no-cache-dir "llama-cpp-python[server]" --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124'; 1541 try { 1542 const reqBody = { 1543 repo_id: 'llama-cpp-python-cuda', 1544 cmd, 1545 remote_host: _envState.remoteHost || undefined, 1546 ssh_port: _getPort(_envState.remoteHost) || undefined, 1547 platform: _envState.platform || undefined, 1548 }; 1549 const res = await fetch('/api/model/serve', { 1550 method: 'POST', credentials: 'same-origin', 1551 headers: { 'Content-Type': 'application/json' }, 1552 body: JSON.stringify(reqBody), 1553 }); 1554 const data = await res.json().catch(() => ({})); 1555 if (res.ok && data.ok) { 1556 const payload = { repo_id: 'pip llama-cpp-python[CUDA]', _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true }; 1557 _addTask(data.session_id, 'pip llama-cpp-python[CUDA]', 'download', payload); 1558 uiModule.showToast(`Reinstalling llama-cpp-python with CUDA wheels on ${targetLabel} (~1-3 min)â¦`, 4000); 1559 } else { 1560 uiModule.showToast('Upgrade failed: ' + String(data.detail || data.error || `HTTP ${res.status}`).slice(0, 300), { 1561 duration: 20000, action: 'OK', onAction: () => {}, 1562 }); 1563 } 1564 } catch (err) { 1565 uiModule.showToast('Upgrade request failed: ' + err.message, { duration: 20000, action: 'OK', onAction: () => {} }); 1566 } 1567 }); 1568 }); 1569 1570 // Inline command "Copy" buttons, currently used by targeted recipe 1571 // repair panels such as the llama.cpp CUDA wheel reinstall. 1572 list.querySelectorAll('.cookbook-dep-cmd-copy').forEach(btn => { 1573 btn.addEventListener('click', async (e) => { 1574 e.stopPropagation(); 1575 const cmd = btn.dataset.depCmdCopy || ''; 1576 if (!cmd) return; 1577 try { await navigator.clipboard.writeText(cmd); } 1578 catch { /* fall through */ } 1579 const orig = btn.textContent; 1580 btn.textContent = 'Copied'; 1581 setTimeout(() => { if (btn.isConnected) btn.textContent = orig; }, 1200); 1582 }); 1583 }); 1584 list.querySelectorAll('.cookbook-dep-install-sysdeps').forEach(btn => { 1585 btn.addEventListener('click', async (e) => { 1586 e.stopPropagation(); 1587 const names = (btn.dataset.depSysdeps || '').split(',').map(s =>
1587 s.trim()).filter(Boolean); 1588 if (!names.length) return; 1589 const isLocal = btn.dataset.depTarget === 'local'; 1590 // Mirror _installDep: the Dependencies tab has its own server 1591 // picker that can override _envState. Apply it before reading 1592 // remoteHost, otherwise the install silently runs on the wrong 1593 // target (container ends up with the packages, the real remote 1594 // host stays broken, success toast misleads the user). 1595 if (!isLocal) { 1596 const depsServerSel = document.getElementById('hwfit-deps-server'); 1597 if (depsServerSel) _applyServerSelection(depsServerSel.value); 1598 } 1599 const targetLabel = isLocal ? 'this server' : (_envState.remoteHost || 'remote'); 1600 const origText = btn.textContent; 1601 btn.textContent = 'Installingâ¦'; 1602 btn.disabled = true; 1603 try { 1604 const body = { packages: names }; 1605 if (!isLocal && _envState.remoteHost) { 1606 body.remote_host = _envState.remoteHost; 1607 const _p = _getPort(_envState.remoteHost); 1608 if (_p) body.ssh_port = _p; 1609 } 1610 const res = await fetch('/api/cookbook/install-system-deps', { 1611 method: 'POST', credentials: 'same-origin', 1612 headers: { 'Content-Type': 'application/json' }, 1613 body: JSON.stringify(body), 1614 }); 1615 const data = await res.json().catch(() => ({})); 1616 if (res.ok && data.ok) { 1617 uiModule.showToast(`Installed ${names.join(', ')} on ${targetLabel}. Refreshingâ¦`, 4000); 1618 // Refresh the deps panel so the row updates (prereqs now present). 1619 try { await _fetchDependencies(); } catch {} 1620 } else { 1621 const reason = data.error || data.detail || `HTTP ${res.status}`; 1622 uiModule.showToast('System dependency install failed: ' + String(reason).slice(0, 300), { 1623 duration: 25000, 1624 action: 'OK', 1625 onAction: () => {}, 1626 }); 1627 btn.textContent = origText; 1628 btn.disabled = false; 1629 } 1630 } catch (err) { 1631 uiModule.showToast('Install request failed: ' + err.message, { 1632 duration: 20000, action: 'OK', onAction: () => {}, 1633 }); 1634 btn.textContent = origText; 1635 btn.disabled = false; 1636 } 1637 }); 1638 }); 1639 1640 // ââ Recipe panel wiring (per-backend dropdown with model + commands) ââ 1641 // Caret toggle: shows/hides the panel directly below the backend row. 1642 list.querySelectorAll('[data-dep-recipe-toggle]').forEach(btn => { 1643 btn.addEventListener('click', (e) => { 1644 e.stopPropagation(); 1645 const backend = btn.dataset.depRecipeToggle; 1646 const panel = list.querySelector(`[data-dep-recipe-panel="${CSS.escape(backend)}"]`); 1647 if (!panel) return; 1648 const open = panel.style.display === 'none' || !panel.style.display; 1649 panel.style.display = open ? 'block' : 'none'; 1650 btn.setAttribute('aria-expanded', open ? 'true' : 'false'); 1651 const caret = btn.querySelector('svg'); 1652 if (caret) caret.style.transform = open ? 'rotate(180deg)' : ''; 1653 }); 1654 }); 1655 // Re-render the <pre> for a backend using the currently-active variant 1656 // (pip / docker) and the currently-picked model. Used by every input 1657 // that changes which install sequence we should show. 1658 function _refreshRecipePre(backend) { 1659 const panel = list.querySelector(`[data-dep-recipe-panel="${CSS.escape(backend)}"]`); 1660 if (!panel) return; 1661 const variant = panel.dataset.depRecipeActiveVariant || RECIPE_DEFAULT_VARIANT; 1662 const sel = panel.querySelector('[data-dep-recipe-pick]'); 1663 const recipe = pickRecipe(backend, (sel && sel.value) || ''); 1664 const cmds = recipeCommands(recipe, variant); 1665 const runtimeCmds = _recipeRuntimeCommands(cmds, variant); 1666 const pre = panel.querySelector('[data-dep-recipe-cmds]'); 1667 if (pre) { 1668 pre.textContent = _recipeDisplayText(cmds, variant); 1669 pre.dataset.depRecipeInstall = runtimeCmds.join('\n'); 1670 } 1671 } 1672 // Model select: pickRecipe matches the model id against the catalog. 1673 list.querySelectorAll('[data-dep-recipe-pick]').forEach(sel => { 1674 sel.addEventListener('change', () => _refreshRecipePre(sel.dataset.depRecipePick)); 1675 }); 1676 // Variant toggle (Pip vs Docker): mirrors the agent/chat mode-toggle 1677 // pattern â buttons get .active, container gets .mode-right when the 1678 // right slot is selected so the sliding pill animates over. 1679 list.querySelectorAll('[data-dep-recipe-variant]').forEach(btn => { 1680 btn.addEventListener('click', (e) => { 1681 e.stopPropagation(); 1682 const backend = btn.dataset.depRecipeVariant; 1683 const variant = btn.dataset.variant; 1684 const panel = list.querySelector(`[data-dep-recipe-panel="${
1684CSS.escape(backend)}"]`); 1685 if (!panel) return; 1686 panel.dataset.depRecipeActiveVariant = variant; 1687 const container = panel.querySelector('.mode-toggle[data-dep-recipe-variants]'); 1688 if (container) container.classList.toggle('mode-right', variant === 'docker'); 1689 panel.querySelectorAll('[data-dep-recipe-variant]').forEach(b => { 1690 const on = b.dataset.variant === variant; 1691 b.classList.toggle('active', on); 1692 b.setAttribute('aria-pressed', on ? 'true' : 'false'); 1693 }); 1694 _refreshRecipePre(backend); 1695 }); 1696 }); 1697 // Copy: drop the visible command block on the clipboard. 1698 list.querySelectorAll('[data-dep-recipe-copy]').forEach(btn => { 1699 btn.addEventListener('click', async (e) => { 1700 e.stopPropagation(); 1701 const backend = btn.dataset.depRecipeCopy; 1702 const pre = list.querySelector(`[data-dep-recipe-cmds="${CSS.escape(backend)}"]`); 1703 if (!pre) return; 1704 try { 1705 await navigator.clipboard.writeText(pre.textContent); 1706 uiModule.showToast('Copied'); 1707 } catch { 1708 // Fallback for non-secure contexts: select the pre's text so 1709 // the user can Ctrl+C themselves. 1710 const sel = window.getSelection(); const range = document.createRange(); 1711 range.selectNodeContents(pre); sel.removeAllRanges(); sel.addRange(range); 1712 } 1713 }); 1714 }); 1715 // Run: launch the install command(s) as a tmux task on the currently- 1716 // selected deps server. Activation comes from env_prefix (same plumbing 1717 // the Install button uses) so the install lands in the configured venv 1718 // instead of a fresh .venv in some random CWD. 1719 list.querySelectorAll('[data-dep-recipe-run]').forEach(btn => { 1720 btn.addEventListener('click', async (e) => { 1721 e.stopPropagation(); 1722 const backend = btn.dataset.depRecipeRun; 1723 const pre = list.querySelector(`[data-dep-recipe-cmds="${CSS.escape(backend)}"]`); 1724 if (!pre) return; 1725 // Use the install-only command list (no activate line) â the 1726 // displayed source line is for the user's reading; env_prefix 1727 // handles it for the actual run. 1728 const installRaw = pre.dataset.depRecipeInstall || pre.textContent; 1729 const depsSel = document.getElementById('hwfit-deps-server'); 1730 if (depsSel) _applyServerSelection(depsSel.value); 1731 const targetHost = _envState.remoteHost || 'local'; 1732 const inferredVenv = _envState.envPath && (!_envState.env || _envState.env === 'none') 1733 && /(?:^|\/)(?:\.?venv|env)(?:\/|$)|\/bin\/activate$/i.test(_envState.envPath); 1734 const recipeEnv = inferredVenv ? 'venv' : _envState.env; 1735 const recipePy = (recipeEnv === 'venv' && _envState.envPath) 1736 ? `${_envState.envPath.replace(/\/+$/, '').replace(/\/bin\/activate$/i, '')}/bin/python3` 1737 : ''; 1738 const cmd = installRaw.split('\n').map(s => { 1739 let line = s.trim(); 1740 if (recipePy) line = line.replace(/^python(?:3)?\s+-m\s+pip\b/, `${recipePy} -m pip`); 1741 return line; 1742 }).filter(Boolean).join(' && '); 1743 // Build env_prefix from the configured envPath (matches _installDep). 1744 let envPrefix = ''; 1745 if (recipeEnv === 'venv' && _envState.envPath) { 1746 const p = _envState.envPath; 1747 envPrefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate'); 1748 } else if (recipeEnv === 'conda' && _envState.envPath) { 1749 envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath); 1750 } 1751 const reqBody = { 1752 repo_id: `${backend} setup`, 1753 cmd: cmd, 1754 remote_host: _envState.remoteHost || undefined, 1755 ssh_port: _getPort(_envState.remoteHost) || undefined, 1756 env_prefix: envPrefix || undefined, 1757 platform: _envState.platform || undefined, 1758 }; 1759 try { 1760 const res = await fetch('/api/model/serve', { 1761 method: 'POST', credentials: 'same-origin', 1762 headers: { 'Content-Type': 'application/json' }, 1763 body: JSON.stringify(reqBody), 1764 }); 1765 const data = await res.json().catch(() => ({})); 1766 if (!res.ok || !data.ok) { 1767 uiModule.showToast('Run failed: ' + String(data.detail || data.error || `HTTP ${res.status}`).slice(0, 200)); 1768 return; 1769 } 1770 const payload = { repo_id: `${backend} setup`, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true }; 1771 _addTask(data.session_id, `${backend} setup`, 'download', payload); 1772 uiModule.showToast(`Running ${backend} setup on ${targetHost}â¦`); 1773 } catch (err) { 1774 uiModule.showToast('Run failed: ' + err.message); 1775 } 1776 }); 1777 }); 1778 1779 1780 async function _rebuildLlamaCpp(updateSource = false, statusEl = null) { 1781 const sel = document.getElementById('hwfit-deps-server'); 1782 if (sel) _applyServerSelection(sel.value); 1783 const host = _envState.remoteHost || '';
1784 const where = host || 'this server'; 1785 const action = updateSource ? 'Update llama.cpp source and rebuild' : 'Rebuild llama.cpp engine'; 1786 const detail = updateSource 1787 ? 'This fast-forwards the Cookbook-managed ~/llama.cpp checkout when possible, then clears the cached llama-server build. The next launch recompiles or installs the latest matching prebuilt.' 1788 : 'This clears the cached llama-server build. The next launch recompiles or installs a matching prebuilt.'; 1789 if (!confirm(`${action} on ${where}?\n\n${detail}`)) return; 1790 const oldText = statusEl?.textContent; 1791 if (statusEl) { 1792 statusEl.disabled = true; 1793 statusEl.textContent = updateSource ? 'Updating...' : 'Clearing...'; 1794 } 1795 try { 1796 const res = await fetch('/api/cookbook/rebuild-engine', { 1797 method: 'POST', credentials: 'same-origin', 1798 headers: { 'Content-Type': 'application/json' }, 1799 body: JSON.stringify({ 1800 engine: 'llamacpp', 1801 remote_host: host || undefined, 1802 ssh_port: _getPort(host) || undefined, 1803 update_source: !!updateSource, 1804 }), 1805 }); 1806 const data = await res.json().catch(() => ({})); 1807 if (!res.ok || !data.ok) { 1808 const reason = data.detail || data.error || `HTTP ${res.status}`; 1809 uiModule.showToast(`${updateSource ? 'Update' : 'Rebuild'} failed: ` + String(reason).slice(0, 300), { 1810 duration: 20000, action: 'OK', onAction: () => {}, 1811 }); 1812 } else { 1813 uiModule.showToast(`${updateSource ? 'Updated source and cleared' : 'Cleared'} llama.cpp build on ${where}. Re-launch the serve task to rebuild.`); 1814 } 1815 } catch (err) { 1816 uiModule.showToast(`${updateSource ? 'Update' : 'Rebuild'} failed: ` + err.message); 1817 } finally { 1818 if (statusEl) { 1819 statusEl.disabled = false; 1820 statusEl.textContent = oldText; 1821 } 1822 } 1823 } 1824 window._cookbookRebuildLlamaCpp = _rebuildLlamaCpp; 1825 1826 // Wire the installed-package menu. 1827 function _showDepMenu(anchor) { 1828 document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove); 1829 const row = anchor.closest('.cookbook-dep-row'); 1830 if (!row) return; 1831 const pipName = row.dataset.depPip; 1832 const rowPkgName = row.dataset.pkgName || ''; 1833 const pkgName = row.querySelector('.memory-item-title')?.textContent || pipName; 1834 const isLocalOnly = row.dataset.depTarget === 'local'; 1835 const dropdown = document.createElement('div'); 1836 dropdown.className = 'dropdown cookbook-dep-menu'; 1837 const rect = anchor.getBoundingClientRect(); 1838 const minW = 150; 1839 let left = Math.min(rect.right - minW, window.innerWidth - minW - 8); 1840 left = Math.max(8, left); 1841 dropdown.style.cssText = `position:fixed;display:block;z-index:${topPortalZ()};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;`; 1842 const upIco = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>'; 1843 const it = document.createElement('div'); 1844 it.className = 'dropdown-item-compact'; 1845 it.innerHTML = `<span class="dropdown-icon">${upIco}</span><span>Update</span>`; 1846 it.title = `Update ${pkgName} to the latest version (pip install -U)`; 1847 it.addEventListener('click', async (e) => { 1848 e.stopPropagation(); 1849 close(); 1850 await _installDep(pipName, pkgName, isLocalOnly, true, null); 1851 }); 1852 dropdown.appendChild(it); 1853 if (rowPkgName === 'llama_cpp') { 1854 const rebuildIco = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>'; 1855 const rebuild = document.createElement('div'); 1856 rebuild.className = 'dropdown-item-compact'; 1857 rebuild.innerHTML = `<span class="dropdown-icon">${rebuildIco}</span><span>Rebuild</span>`;
1858 rebuild.title = 'Clear the cached llama-server build so the next launch rebuilds it.'; 1859 rebuild.addEventListener('click', async (e) => { 1860 e.stopPropagation(); 1861 dropdown.remove(); 1862 await _rebuildLlamaCpp(false, null); 1863 }); 1864 dropdown.appendChild(rebuild); 1865 const source = document.createElement('div'); 1866 source.className = 'dropdown-item-compact'; 1867 source.innerHTML = `<span class="dropdown-icon">${upIco}</span><span>Update source + rebuild</span>`; 1868 source.title = 'Fast-forward ~/llama.cpp when possible, then clear the cached build.'; 1869 source.addEventListener('click', async (e) => { 1870 e.stopPropagation(); 1871 dropdown.remove(); 1872 await _rebuildLlamaCpp(true, null); 1873 }); 1874 dropdown.appendChild(source); 1875 } 1876 document.body.appendChild(dropdown); 1877 const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => 1878 !dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)); 1879 } 1880 list.querySelectorAll('.cookbook-dep-installed-btn').forEach(btn => { 1881 btn.addEventListener('click', (e) => { 1882 e.stopPropagation(); 1883 if (document.querySelector('.cookbook-dep-menu')) { 1884 document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove); 1885 return; 1886 } 1887 _showDepMenu(btn); 1888 }); 1889 }); 1890 } catch (err) { 1891 list.innerHTML = `<div class="hwfit-loading">Error loading packages: ${esc(err.message)}</div>`; 1892 } 1893} 1894 1895// ââ Tab wiring ââ 1896 1897function _applyServerSelection(val) { 1898 if (val === 'local') { 1899 _envState.remoteHost = ''; 1900 _envState.remoteServerKey = ''; 1901 _envState.env = 'none'; 1902 _envState.envPath = ''; 1903 _envState.platform = ''; 1904 } else { 1905 const s = _serverByVal(val); 1906 if (s) { 1907 _envState.remoteHost = s.host; 1908 _envState.remoteServerKey = _serverKey(s); 1909 _envState.env = s.env || 'none'; 1910 _envState.envPath = s.envPath || ''; 1911 _envState.platform = s.platform || ''; 1912 } 1913 } 1914 // Persist + keep every server dropdown in sync, so the choice sticks across 1915 // re-renders and the scan/download all target the SAME host (this was the 1916 // bug: the Download/Cache/Deps dropdowns set the host but never saved it, so 1917 // it silently reverted and downloads/scans hit the wrong server). 1918 _persistEnvState(); 1919 const _want = _currentServerValue(); 1920 document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => { 1921 if (!sel || sel.tagName !== 'SELECT') return; 1922 // Option values are host strings now ('local' for the local box). 1923 sel.value = _want; 1924 // If the host isn't among this select's current options (stale options after 1925 // the server list changed), the browser leaves the box BLANK/grey even though 1926 // the value is "set". Rebuild the options so the chosen host has an entry, then 1927 // re-apply; fall back to 'local' only if it's genuinely gone. 1928 if (sel.selectedIndex < 0) { 1929 sel.innerHTML = _buildServerOpts(sel.id === 'hwfit-dl-server'); 1930 sel.value = _want; 1931 if (sel.selectedIndex < 0) sel.value = 'local'; 1932 } 1933 _applyServerSelectColor(sel); 1934 }); 1935} 1936 1937async function _refreshScanDownloadTarget() { 1938 const btn = document.getElementById('hwfit-hw-refresh-btn'); 1939 if (btn && btn.disabled) return; 1940 const selectedVal = document.getElementById('hwfit-server-select')?.value || _currentServerValue(); 1941 if (btn) { 1942 btn.disabled = true; 1943 btn.style.opacity = '0.55'; 1944 btn.style.cursor = 'wait'; 1945 } 1946 try { 1947 if (selectedVal) _applyServerSelection(selectedVal); 1948 const ok = await _syncFromServer().catch((e) => { 1949 console.warn('[cookbook] explicit server sync failed', e); 1950 return false; 1951 }); 1952 if (ok) { 1953 try { Object.assign(_envState, _readStoredEnvState()); } catch {} 1954 if (selectedVal) _applyServerSelection(selectedVal); 1955 } 1956 _resetGpuToggleState(); 1957 await Promise.allSettled([ 1958 _hwfitFetch(true), 1959 _fetchCachedModels(true),
1960 ]); 1961 if (uiModule?.showToast) uiModule.showToast('Refreshed selected server'); 1962 } catch (e) { 1963 console.warn('[cookbook] scan/download refresh failed', e); 1964 if (uiModule?.showError) uiModule.showError('Refresh failed: ' + (e?.message || e)); 1965 } finally { 1966 if (btn) { 1967 btn.disabled = false; 1968 btn.style.opacity = ''; 1969 btn.style.cursor = ''; 1970 } 1971 } 1972} 1973 1974function _wireTabEvents(body) { 1975 // Tab switching 1976 body.querySelectorAll('.cookbook-tab').forEach(tab => { 1977 tab.addEventListener('click', () => { 1978 body.querySelectorAll('.cookbook-tab').forEach(t => t.classList.remove('active')); 1979 tab.classList.add('active'); 1980 const backend = tab.dataset.backend; 1981 body.querySelectorAll('.cookbook-group').forEach(g => { 1982 g.classList.toggle('hidden', g.dataset.backendGroup !== backend); 1983 }); 1984 if (backend === 'Search') { 1985 _hwfitInit(); 1986 _hwfitFetch(false, { allowNetwork: false }); 1987 } 1988 if (backend === 'Serve') { 1989 _fetchCachedModels(false, { allowNetwork: false }); 1990 } 1991 if (backend === 'Dependencies') { 1992 _fetchDependencies(); 1993 } 1994 }); 1995 }); 1996 1997 // Mobile: swipe left/right anywhere in the body to move to the next/previous 1998 // tab. Guarded so it ignores vertical scrolls, tiny moves, and form fields. 1999 if (!body._swipeWired) { 2000 body._swipeWired = true; 2001 let _sx = null, _sy = null; 2002 body.addEventListener('touchstart', (e) => { 2003 // Ignore swipes that start in a horizontally-scrollable tag row â those 2004 // should scroll the chips, not flip the tab. 2005 if (window.innerWidth > 768 || e.touches.length !== 1 2006 || e.target.closest('input, textarea, select, .doclib-lang-chips')) { _sx = null; return; } 2007 _sx = e.touches[0].clientX; _sy = e.touches[0].clientY; 2008 }, { passive: true }); 2009 body.addEventListener('touchend', (e) => { 2010 if (_sx === null) return; 2011 const dx = e.changedTouches[0].clientX - _sx; 2012 const dy = e.changedTouches[0].clientY - _sy; 2013 _sx = null; 2014 // Require a clear horizontal swipe (>60px and mostly horizontal). 2015 if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return; 2016 const tabs = [...body.querySelectorAll('.cookbook-tab')]; 2017 const idx = tabs.findIndex(t => t.classList.contains('active')); 2018 if (idx < 0) return; 2019 const next = dx < 0 ? idx + 1 : idx - 1; // swipe left â next tab 2020 if (next >= 0 && next < tabs.length) tabs[next].click(); 2021 }, { passive: true }); 2022 } 2023 2024 // Sync server form DOM â _envState.servers 2025 function _syncServers() { 2026 const entries = document.querySelectorAll('.cookbook-server-entry'); 2027 const servers = []; 2028 entries.forEach(entry => { 2029 const name = entry.querySelector('.cookbook-srv-name')?.value?.trim() || ''; 2030 const host = entry.querySelector('.cookbook-srv-host')?.value?.trim() || ''; 2031 const port = entry.querySelector('.cookbook-srv-port')?.value?.trim() || ''; 2032 const env = entry.querySelector('.cookbook-srv-env')?.value || 'none'; 2033 const envPath = entry.querySelector('.cookbook-srv-path')?.value?.trim() || ''; 2034 const color = _serverColorValue(entry.querySelector('.cookbook-srv-color')?.value || ''); 2035 const platform = entry.dataset.platform || ''; 2036 const dirs = []; 2037 entry.querySelectorAll('.cookbook-modeldir-tag').forEach(tag => { 2038 // Read from data attribute (authoritative) â never parse displayed text 2039 const d = _normalizeCookbookModelDir(tag.dataset.dir || ''); 2040 if (d) dirs.push(d); 2041 }); 2042 // Directory flagged as the download target ('' = default HF cache). 2043 const dlEl = entry.querySelector('.cookbook-modeldir-dl.active'); 2044 const downloadDir = dlEl ? (dlEl.dataset.dlDir || '') : ''; 2045 servers.push({ name, host, port, env, envPath, color, modelDirs: dirs, downloadDir, platform }); 2046 }); 2047 _envState.servers = servers; 2048 // Auto-default: when the user has configured EXACTLY ONE remote server 2049 // and hasn't picked one yet, select it. Without this, the dropdown 2050 // stays on "Local" so the eventual serve/scan/launch resolves to no 2051 // remote host and the backend rejects the call with 403 (Forbidden), 2052 // which read to the user as a permission bug. 2053 if (!_envState.remoteHost) { 2054 const remotes = servers.filter(s => !_isLocalEntry(s)); 2055 if (remotes.length === 1) { 2056 _envState.remoteHost = remotes[0].host; 2057 _envState.env = remotes[0].env || 'none'; 2058 _envState.envPath = remotes[0].envPath || ''; 2059 } 2060 } 2061 const activeSrv = servers.find(s => s.host === _envState.remoteHost); 2062 _envState.platform = activeSrv?.platform || ''; 2063 localStorage.setItem('cookbook-last-state', JSON.stringify(_envStateForStorage())); 2064 _saveTasks(_loadTasks()); 2065 // Reflect the auto-default selection into every server dropdown so the 2066 // UI matches the resolved host. Done in a microtask so the dropdowns
2067 // exist by the time we set their .value. 2068 Promise.resolve().then(() => { 2069 const _want = _currentServerValue(); 2070 document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => { 2071 if (sel && sel.tagName === 'SELECT') { 2072 sel.value = _want; 2073 _applyServerSelectColor(sel); 2074 } 2075 }); 2076 }); 2077 } 2078 2079 // Wire server form inputs 2080 document.querySelectorAll('.cookbook-srv-name, .cookbook-srv-color, .cookbook-srv-host, .cookbook-srv-port, .cookbook-srv-path').forEach(el => { 2081 el.addEventListener('change', _syncServers); 2082 }); 2083 document.querySelectorAll('.cookbook-srv-env').forEach(el => { 2084 el.addEventListener('change', _syncServers); 2085 }); 2086 2087 // Server selector â the server is global, so switching it here re-scans the 2088 // main Scan/Download list (#hwfit-list) for the new server's hardware too. 2089 // (The trending sublist reloads via its own handler in the HF-latest wiring.) 2090 const dlServer = document.getElementById('hwfit-dl-server'); 2091 if (dlServer) { 2092 dlServer.addEventListener('change', () => { 2093 _applyServerSelection(dlServer.value); 2094 // Reset toggle state (no flicker) so the new server's hardware re-renders. 2095 _resetGpuToggleState(); 2096 _hwfitFetch(false, { allowNetwork: false }); 2097 }); 2098 } 2099 2100 // Add server link â switch to Settings tab 2101 const addServerLink = document.querySelector('.cookbook-dl-add-server'); 2102 if (addServerLink) { 2103 addServerLink.addEventListener('click', () => { 2104 const settingsTab = body.querySelector('.cookbook-tab[data-backend="Settings"]'); 2105 if (settingsTab) settingsTab.click(); 2106 }); 2107 } 2108 2109 // Cache server selector 2110 const cacheServer = document.getElementById('hwfit-cache-server'); 2111 const cacheDirEl = document.getElementById('hwfit-cache-dir'); 2112 if (cacheServer) { 2113 cacheServer.addEventListener('change', () => { 2114 _applyServerSelection(cacheServer.value); 2115 const val = cacheServer.value; 2116 let srv; 2117 if (val === 'local') { 2118 srv = _envState.servers.find(_isLocalEntry) || _envState.servers[0] || {}; 2119 } else { 2120 srv = _serverByVal(val) || {}; 2121 } 2122 if (cacheDirEl) cacheDirEl.value = srv.modelDir || '~/.cache/huggingface/hub'; 2123 const dirsEl = document.querySelector('.cookbook-serve-dirs'); 2124 if (dirsEl) { 2125 const dirs = (Array.isArray(srv.modelDirs) ? srv.modelDirs : [srv.modelDir || '~/.cache/huggingface/hub']).map(d => _normalizeCookbookModelDir(d)).filter(Boolean); 2126 dirsEl.innerHTML = dirs.map(d => `<span class="cookbook-serve-dir-pill">${esc(d)}</span>`).join('') + 2127 '<span class="cookbook-serve-dir-edit" title="Edit in Settings">edit</span>'; 2128 dirsEl.querySelector('.cookbook-serve-dir-edit')?.addEventListener('click', () => { 2129 const settingsTab = body.querySelector('.cookbook-tab[data-backend="Settings"]'); 2130 if (settingsTab) settingsTab.click(); 2131 }); 2132 } 2133 _fetchCachedModels(false, { allowNetwork: false }); 2134 }); 2135 } 2136 2137 const scanBtn = document.getElementById('hwfit-cache-scan'); 2138 if (scanBtn) { 2139 scanBtn.addEventListener('click', async () => { 2140 if (scanBtn.disabled) return; 2141 scanBtn.disabled = true; 2142 scanBtn.classList.add('spinning'); 2143 try { 2144 await _fetchCachedModels(true); 2145 } finally { 2146 scanBtn.disabled = false; 2147 scanBtn.classList.remove('spinning'); 2148 } 2149 }); 2150 } 2151 2152 const hwRefreshBtn = document.getElementById('hwfit-hw-refresh-btn'); 2153 if (hwRefreshBtn) { 2154 hwRefreshBtn.addEventListener('click', _refreshScanDownloadTarget); 2155 } 2156 2157 const hwAdvancedBtn = document.getElementById('hwfit-advanced-btn'); 2158 const hwAdvancedPanel = document.getElementById('hwfit-advanced-panel'); 2159 if (hwAdvancedBtn && hwAdvancedPanel && !hwAdvancedBtn.dataset.bound) { 2160 hwAdvancedBtn.dataset.bound = '1'; 2161 const setAdvancedOpen = (open) => { 2162 hwAdvancedPanel.classList.toggle('hidden', !open); 2163 hwAdvancedBtn.classList.toggle('active', open); 2164 hwAdvancedBtn.setAttribute('aria-expanded', open ? 'true' : 'false'); 2165 }; 2166 hwAdvancedBtn.addEventListener('click', (ev) => { 2167 ev.preventDefault(); 2168 ev.stopPropagation(); 2169 setAdvancedOpen(hwAdvancedPanel.classList.contains('hidden')); 2170 }); 2171 hwAdvancedPanel.addEventListener('click', (ev) => ev.stopPropagation()); 2172 document.addEventListener('click', () => setAdvancedOpen(false)); 2173 document.addEventListener('keydown', (ev) => { 2174 if (ev.key === 'Escape') setAdvancedOpen(false); 2175 }); 2176 } 2177 2178 const editDirsLink = document.querySelector('.cookbook-serve-dir-edit'); 2179 if (editDirsLink) { 2180 editDirsLink.addEventListener('click', () => { 2181 const settingsTab = body.querySelector('.cookbook-tab[data-backend="Settings"]'); 2182 if (settingsTab) settingsTab.click(); 2183 }); 2184 } 2185 2186 const depsServer = document.getElementById('hwfit-deps-server'); 2187 if (depsServer) { 2188 depsServer.addEventListener('change', () => { 2189 _applyServerSelection(depsServer.value); 2190 // Re-fetch the package list for the newly selected server â the installed 2191 // status is per-server, so the list must refresh on a server switch. 2192 _fetchDependencies(); 2193 }); 2194 } 2195 _syncServerSelectColors(body); 2196 2197 // "Rebuild llama.cpp" clears the cached build so the next serve recompiles. 2198 // The serve bootstrap only builds llama-server when it is missing from PATH, 2199 // so a host that first built CPU-only (no nvcc at build time) keeps reusing 2200 // that binary forever; this is the lever to force a fresh GPU build after a 2201 // CUDA/ROCm toolkit is installed. 2202 const rebuildBtn = document.getElementById('cookbook-rebuild-engine'); 2203 if (rebuildBtn && !rebuildBtn._wired) { 2204 rebuildBtn._wired = true; 2205 rebuildBtn.addEventListener('click', async () => { 2206 // Match _installDep: honor the Dependencies server selector so the clear 2207 // runs on the same host the build runs on. 2208 const sel = document.getElementById('hwfit-deps-server'); 2209 if (sel) _applyServerSelection(sel.value); 2210 const host = _envState.remoteHost || '';
2211 const where = host || 'this server'; 2212 if (window._cookbookRebuildLlamaCpp) await window._cookbookRebuildLlamaCpp(false, rebuildBtn); 2213 }); 2214 } 2215 2216 // "Reinstall" buttons for pip-based serving stacks (vllm, sglang). The 2217 // deps list renders ASYNCHRONOUSLY after _fetchDependencies resolves, so 2218 // attaching listeners directly here would miss buttons that don't exist 2219 // yet. Use document-level delegation instead â the click always finds the 2220 // right .cookbook-dep-reinstall button no matter when it was painted. 2221 if (!document._cookbookReinstallWired) { 2222 document._cookbookReinstallWired = true; 2223 document.addEventListener('click', async (ev) => { 2224 const btn = ev.target.closest?.('.cookbook-dep-reinstall'); 2225 if (!btn) return; 2226 const pkg = btn.dataset.reinstallPkg || ''; 2227 if (!pkg) return; 2228 ev.preventDefault(); 2229 ev.stopPropagation(); 2230 const sel = document.getElementById('hwfit-deps-server'); 2231 if (sel) _applyServerSelection(sel.value); 2232 const host = _envState.remoteHost || ''; 2233 const where = host || 'this server'; 2234 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; 2235 const _venvPy = (_envState.env === 'venv' && _envState.envPath) 2236 ? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` 2237 : 'python3'; 2238 _launchServeTask(`reinstall-${pkg}`, 'pip-reinstall', `${_venvPy} -m pip install --force-reinstall --no-deps ${pkg}`); 2239 }, true); 2240 } 2241 2242 // Serve sort 2243 const serveSort = document.getElementById('serve-sort'); 2244 if (serveSort) { 2245 serveSort.addEventListener('change', () => { 2246 if (_cachedAllModels.length) _rerenderCachedModels(); 2247 }); 2248 } 2249 2250 // Serve search 2251 const serveSearch = document.getElementById('serve-search'); 2252 if (serveSearch) { 2253 let _srvDebounce = null; 2254 serveSearch.addEventListener('input', () => { 2255 clearTimeout(_srvDebounce); 2256 _srvDebounce = setTimeout(() => _filterCachedList(), 200); 2257 }); 2258 } 2259 2260 // Select mode â bulk actions 2261 const selectBtn = document.getElementById('hwfit-cache-select'); 2262 const bulkBar = document.getElementById('serve-bulk-bar'); 2263 if (selectBtn && bulkBar) { 2264 selectBtn.addEventListener('click', () => { 2265 const active = selectBtn.classList.toggle('active'); 2266 selectBtn.textContent = active ? 'Cancel' : 'Select'; 2267 bulkBar.classList.toggle('hidden', !active); 2268 document.querySelectorAll('.serve-select-cb').forEach(dot => { 2269 dot.style.display = active ? '' : 'none'; 2270 dot.classList.remove('selected'); 2271 }); 2272 _updateBulkCount(); 2273 }); 2274 2275 document.getElementById('hwfit-cached-list')?.addEventListener('click', (e) => { 2276 if (!selectBtn.classList.contains('active')) return; 2277 const item = e.target.closest('.memory-item[data-repo]'); 2278 if (!item) return; 2279 if (e.target.closest('a, .hwfit-cached-menu-btn, .memory-item-btn, .hwfit-serve-panel')) return; 2280 const dot = item.querySelector('.serve-select-cb'); 2281 if (dot) { 2282 dot.classList.toggle('selected'); 2283 _updateBulkCount(); 2284 } 2285 }); 2286 2287 function _updateBulkCount() { 2288 const count = document.querySelectorAll('.serve-select-cb.selected').length; 2289 const countEl = document.getElementById('serve-bulk-count'); 2290 if (countEl) countEl.textContent = count + ' selected'; 2291 } 2292 2293 document.getElementById('serve-bulk-cancel')?.addEventListener('click', () => { 2294 selectBtn.classList.remove('active'); 2295 selectBtn.textContent = 'Select'; // reset label so the button doesn't stay reading "Cancel" after exit 2296 bulkBar.classList.add('hidden'); 2297 document.querySelectorAll('.serve-select-cb').forEach(dot => { dot.style.display = 'none'; dot.classList.remove('selected'); }); 2298 }); 2299 2300 document.getElementById('serve-bulk-delete')?.addEventListener('click', async () => { 2301 const checked = document.querySelectorAll('.serve-select-cb.selected'); 2302 if (!checked.length) return; 2303 const repos = []; 2304 checked.forEach(dot => { 2305 const item = dot.closest('.memory-item[data-repo]'); 2306 if (item?.dataset.repo) repos.push(item.dataset.repo); 2307 }); 2308 if (!(await uiModule.styledConfirm(`Delete ${repos.length} model(s)? This removes cached files.`, { confirmText: 'Delete', danger: true }))) return; 2309 for (const repo of repos) { 2310 const item = document.querySelector(`.memory-item[data-repo="${repo}"]`);
2311 if (item) await _deleteCachedModel(repo, item, true); 2312 } 2313 selectBtn.classList.remove('active'); 2314 selectBtn.textContent = 'Select'; // same reset as bulk-cancel 2315 bulkBar.classList.add('hidden'); 2316 document.querySelectorAll('.serve-select-cb').forEach(dot => { dot.style.display = 'none'; dot.classList.remove('selected'); }); 2317 }); 2318 } 2319 2320 // Download input 2321 const dlBtn = document.getElementById('cookbook-dl-btn'); 2322 const dlInput = document.getElementById('cookbook-dl-repo'); 2323 const dlGgufRow = document.getElementById('cookbook-dl-gguf-row'); 2324 const dlGgufQuant = document.getElementById('cookbook-dl-gguf-quant'); 2325 const dlGgufNote = document.getElementById('cookbook-dl-gguf-note'); 2326 const dlCardToggle = document.getElementById('cookbook-download-card-toggle'); 2327 const dlCardBody = document.getElementById('cookbook-download-card-body'); 2328 const dlCardArrow = document.getElementById('cookbook-download-card-arrow'); 2329 if (dlCardToggle && dlCardBody) { 2330 dlCardToggle.addEventListener('click', () => { 2331 const isOpen = dlCardBody.style.display !== 'none'; 2332 dlCardBody.style.display = isOpen ? 'none' : 'block'; 2333 if (dlCardArrow) dlCardArrow.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)'; 2334 }); 2335 } 2336 if (dlBtn && dlInput) { 2337 function _stripHfUrl(input) { 2338 let repo = input.trim(); 2339 // Strip a leading `hf download` / `hf-cli download` / `huggingface-cli 2340 // download` wrapper so a paste from CLI docs Just Works. Drop the 2341 // command prefix; the rest is parsed by the existing strippers. 2342 repo = repo.replace(/^(?:huggingface-cli|hf-cli|hf)\s+(?:download|d)\s+/i, ''); 2343 // Strip the `hf://` (and `huggingface://`) scheme â the HF CLI 2344 // accepts it as an alias and users naturally copy it. Same effect 2345 // as the bare `org/repo[/file.gguf]` form after the strip. 2346 repo = repo.replace(/^(?:hf|huggingface):\/\//i, ''); 2347 // Strip Ollama-style "hf.co/" prefix if present (e.g. hf.co/unsloth/...:tag) 2348 repo = repo.replace(/^hf\.co\//, ''); 2349 // Full HF blob/resolve URL â turn into `org/repo/path/to/file` so 2350 // the downstream `_splitRepoFile` can pick the file out. 2351 // Matches: https://huggingface.co/org/repo/blob/branch/path/to/file.gguf 2352 // https://huggingface.co/org/repo/resolve/branch/path/to/file.gguf 2353 const hfBlob = repo.match(/^https?:\/\/huggingface\.co\/([^/]+\/[^/?#]+)\/(?:blob|resolve)\/[^/?#]+\/([^?#]+)/); 2354 if (hfBlob) return `${hfBlob[1]}/${hfBlob[2]}`; 2355 const hfMatch = repo.match(/^https?:\/\/huggingface\.co\/([^/]+\/[^/?#]+(?::[^/?#\s]+)?)/); 2356 if (hfMatch) repo = hfMatch[1]; 2357 return repo; 2358 } 2359 function _ggufQuantFromPath(path) { 2360 const clean = String(path || '').split('?')[0]; 2361 const parts = clean.split('/').filter(Boolean); 2362 const dir = parts.length > 1 ? parts[0] : ''; 2363 const file = parts[parts.length - 1] || clean; 2364 const dirQuant = dir.match(/^(?:I?Q\d(?:_[A-Z0-9]+){0,3}|UD-[A-Z0-9_]+)$/i); 2365 if (dirQuant) return dirQuant[0].toUpperCase(); 2366 const fileQuant = file.match(/(?:^|[-_.\/])((?:I?Q\d(?:_[A-Z0-9]+){0,3})|(?:UD-[A-Z0-9_]+))(?=(?:[-_.]|\.gguf|$))/i); 2367 return fileQuant ? fileQuant[1].toUpperCase() : ''; 2368 } 2369 function _ggufIncludeForQuant(files, quant) { 2370 const matches = files.filter(f => _ggufQuantFromPath(f) === quant); 2371 if (!matches.length) return ''; 2372 const dirs = Array.from(new Set(matches.map(f => f.includes('/') ? f.split('/').slice(0, -1).join('/') : ''))); 2373 if (dirs.length === 1) { 2374 const prefix = dirs[0] ? `${dirs[0]}/` : ''; 2375 return `${prefix}*${quant}*.gguf`; 2376 } 2377 return `*${quant}*.gguf`; 2378 } 2379 function _hideGgufPicker(message = '') { 2380 if (dlGgufRow) dlGgufRow.style.display = 'none'; 2381 if (dlGgufQuant) { 2382 dlGgufQuant.innerHTML = ''; 2383 dlGgufQuant.dataset.repo = ''; 2384 } 2385 if (dlGgufNote) dlGgufNote.textContent = message; 2386 } 2387 async function _scanGgufRepo(rawValue) { 2388 if (!dlGgufRow || !dlGgufQuant || !dlGgufNote) return false;
2389 const rawRepo = _stripHfUrl(rawValue || ''); 2390 const ollamaName = _ollamaName(rawRepo); 2391 const fileSplit = !ollamaName ? _splitRepoFile(rawRepo) : null; 2392 const split = ollamaName ? { repo: ollamaName, include: null } : (fileSplit || _splitRepoTag(rawRepo)); 2393 const repo = split.repo || ''; 2394 if (ollamaName || split.include || !/^[^\s/]+\/[^\s/]+$/.test(repo)) { 2395 _hideGgufPicker(); 2396 return false; 2397 } 2398 dlGgufRow.style.display = 'flex'; 2399 dlGgufQuant.innerHTML = '<option value="">Scanning...</option>'; 2400 dlGgufQuant.dataset.repo = repo; 2401 dlGgufNote.textContent = ''; 2402 try { 2403 const res = await fetch(`/api/cookbook/hf-gguf-files?repo_id=${encodeURIComponent(repo)}`, { credentials: 'same-origin' }); 2404 if (!res.ok) throw new Error(`HTTP ${res.status}`); 2405 const data = await res.json(); 2406 if (!data.ok) throw new Error(data.error || 'scan failed'); 2407 if (dlGgufQuant.dataset.repo !== repo) return false; 2408 const files = (data.files || []) 2409 .map(s => String(s || '')) 2410 .filter(name => /\.gguf$/i.test(name)); 2411 const byQuant = new Map(); 2412 files.forEach(name => { 2413 const quant = _ggufQuantFromPath(name); 2414 if (!quant) return; 2415 if (!byQuant.has(quant)) byQuant.set(quant, []); 2416 byQuant.get(quant).push(name); 2417 }); 2418 if (!byQuant.size) { 2419 _hideGgufPicker('No GGUF quants found'); 2420 return false; 2421 } 2422 const quantRank = q => { 2423 const m = q.match(/^I?Q(\d)/i); 2424 return m ? Number(m[1]) : 99; 2425 }; 2426 const quants = Array.from(byQuant.keys()).sort((a, b) => quantRank(a) - quantRank(b) || a.localeCompare(b)); 2427 dlGgufQuant.innerHTML = quants.map(q => { 2428 const include = _ggufIncludeForQuant(files, q); 2429 const count = byQuant.get(q).length; 2430 return `<option value="${esc(include)}">${esc(q)} (${count})</option>`; 2431 }).join(''); 2432 const first = dlGgufQuant.options[0]; 2433 dlGgufNote.textContent = first ? first.value : ''; 2434 return !!(first && first.value); 2435 } catch (err) { 2436 _hideGgufPicker(`GGUF scan failed: ${err.message || err}`); 2437 return false; 2438 } 2439 } 2440 // Split `org/repo:tag` (Ollama/llama.cpp style) into repo + include-glob. 2441 // The `:tag` picks a specific GGUF quantization file from the repo. 2442 function _splitRepoTag(raw) { 2443 const m = raw.match(/^([^\s/:]+\/[^\s/:]+):([^\s/]+)$/); 2444 if (!m) return { repo: raw, include: null }; 2445 return { repo: m[1], include: `*${m[2]}*` }; 2446 } 2447 // Split `org/repo/path/to/file.gguf` (or `.safetensors`/`.bin`) into 2448 // repo + exact file include. Lets the user paste a path straight out 2449 // of a HuggingFace "Files and versions" page or a copied filename 2450 // without needing to peel the repo/file apart by hand. Returns null 2451 // when the input doesn't look like a deep file path. 2452 function _splitRepoFile(raw) { 2453 // Must have at least 3 slash-separated segments AND end in a 2454 // model-file extension to avoid eating Ollama tags or repo-only 2455 // inputs like `org/repo`. 2456 const parts = raw.split('/'); 2457 if (parts.length < 3) return null; 2458 const fname = parts[parts.length - 1]; 2459 if (!/\.(gguf|safetensors|bin|pt|pth|onnx|mlx)(\?[^?]*)?$/i.test(fname)) return null; 2460 const repo = parts.slice(0, 2).join('/'); 2461 return { repo, include: fname.replace(/\?.*$/, '') }; 2462 } 2463 // Ollama-library name. Matches `qwen2.5:14b`, `llama3:latest`, and the 2464 // (rare) `library/<name>:<tag>` form which we normalize by stripping the 2465 // namespace. The backend's _is_ollama_download check expects the same 2466 // shape (no slash + has a colon). 2467 function _ollamaName(raw) { 2468 const stripped = raw.replace(/^library\//, ''); 2469 if (/^[A-Za-z0-9][A-Za-z0-9._-]{0,200}:[A-Za-z0-9][A-Za-z0-9._-]{0,200}$/.test(stripped)) { 2470 return stripped; 2471 } 2472 return null; 2473 } 2474 const triggerDownload = async () => { 2475 const rawRepo = _stripHfUrl(dlInput.value); 2476 if (!rawRepo) return; 2477 const ollamaName = _ollamaName(rawRepo); 2478 // Prefer the deep-file split (org/repo/file.gguf â repo + exact 2479 // include) over the tag split (org/repo:tag â glob include), and 2480 // both over the plain repo case. Ollama names still take priority 2481 // since they go through a different backend. 2482 const _fileSplit = !ollamaName ? _splitRepoFile(rawRepo) : null; 2483 const { repo, include: autoInclude } = ollamaName 2484 ? { repo: ollamaName, include: null } 2485 : (_fileSplit || _splitRepoTag(rawRepo)); 2486 let pickerInclude = (!ollamaName && !_fileSplit && !autoInclude && dlGgufQuant?.dataset.repo === repo) 2487 ? (dlGgufQuant.value || '') 2488 : ''; 2489 // HuggingFace repo IDs must be `org/model`. A bare model name would 404 2490 // at snapshot_download time with a raw traceback, so reject it up front. 2491 // Ollama names (single-segment with a tag) skip this check â they go 2492 // through `ollama pull` server-side, not snapshot_download. 2493 if (!ollamaName && !/^[^\s/]+\/[^\s/]+$/.test(repo)) { 2494 uiModule.showToast('Enter a full HuggingFace repo ID like "org/model-name", or an Ollama name like "qwen2.5:14b".'); 2495 dlInput.focus(); 2496 return; 2497 } 2498 const looksGgufRepo = !ollamaName && !_fileSplit && !autoInclude && /\bgguf\b/i.test(repo); 2499 if (looksGgufRepo && !pickerInclude) { 2500 const oldText = dlBtn.textContent; 2501 dlBtn.disabled = true; 2502 dlBtn.textContent = 'Scanning...'; 2503 try { 2504 const found = await _scanGgufRepo(rawRepo); 2505 pickerInclude = (found && dlGgufQuant?.dataset.repo === repo) ? (dlGgufQuant.value || '') : ''; 2506 } finally { 2507 dlBtn.disabled = false;
2508 dlBtn.textContent = oldText; 2509 } 2510 if (!pickerInclude) { 2511 uiModule.showToast('Pick a GGUF quant first. Odysseus will not download the whole GGUF repo without an include pattern.'); 2512 return; 2513 } 2514 uiModule.showToast('Pick the GGUF quant, then press Download again.'); 2515 return; 2516 } 2517 // Resolve the host straight from THIS window's server dropdown, by index 2518 // into the (consistent) servers list. We deliberately don't use 2519 // _envState.remoteHost â there can be multiple copies of the cookbook 2520 // state in memory and they disagree on the active host, which is what sent 2521 // downloads to the wrong server. The dropdown the user sees is the truth. 2522 const dlSrv = document.getElementById('hwfit-dl-server'); 2523 const srvVal = dlSrv ? dlSrv.value : 'local'; 2524 let host = ''; 2525 if (srvVal !== 'local') { 2526 host = _serverByVal(srvVal)?.host || ''; 2527 } 2528 const _hsrv = _envState.servers.find(sv => sv.host === host) || {}; 2529 let env = host ? (_hsrv.env || 'none') : _envState.env; 2530 let envPath = host ? (_hsrv.envPath || '') : _envState.envPath; 2531 const payload = { repo_id: repo }; 2532 if (ollamaName) payload.backend = 'ollama'; 2533 if (autoInclude || pickerInclude) payload.include = autoInclude || pickerInclude; 2534 if (_envState.hfToken && !ollamaName) payload.hf_token = _envState.hfToken; 2535 if (host) { payload.remote_host = host; const _sp3 = _getPort(host); if (_sp3) payload.ssh_port = _sp3; } 2536 const srvPlatform = _getPlatform(host); 2537 if (srvPlatform) payload.platform = srvPlatform; 2538 if (srvPlatform === 'windows') { 2539 if (env === 'venv' && envPath) { 2540 payload.env_prefix = '& ' + _psQuote(envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1'); 2541 } else if (env === 'conda' && envPath) { 2542 payload.env_prefix = 'conda activate ' + _psQuote(envPath); 2543 } 2544 } else { 2545 if (env === 'venv' && envPath) { 2546 const p = envPath; 2547 payload.env_prefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate'); 2548 } else if (env === 'conda' && envPath) { 2549 payload.env_prefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(envPath); 2550 } 2551 } 2552 const shortName = repo.split('/').pop(); 2553 const displayName = payload.include 2554 ? `${shortName} · ${_ggufQuantFromPath(String(payload.include).replace(/\*/g, '')) || String(payload.include).replace(/\*/g, '').replace(/\.gguf$/i, '')}` 2555 : shortName; 2556 _retryDownload(displayName, payload); 2557 dlInput.value = ''; 2558 }; 2559 dlBtn.addEventListener('click', triggerDownload); 2560 dlInput.addEventListener('keydown', (e) => { 2561 if (e.key === 'Enter') triggerDownload(); 2562 }); 2563 let _ggufScanTimer = null; 2564 const _scheduleGgufScan = () => { 2565 clearTimeout(_ggufScanTimer); 2566 _ggufScanTimer = setTimeout(() => _scanGgufRepo(dlInput.value), 350); 2567 }; 2568 dlInput.addEventListener('input', _scheduleGgufScan); 2569 dlInput.addEventListener('blur', () => _scanGgufRepo(dlInput.value)); 2570 dlGgufQuant?.addEventListener('change', () => { 2571 if (dlGgufNote) dlGgufNote.textContent = dlGgufQuant.value || ''; 2572 }); 2573 } 2574 2575 // Latest HF models that fit â collapsible card list 2576 // Foldable Download admin-card â h2 "Download" doubles as the chevron 2577 // toggle; collapses the entire card body (description + input + HF list). 2578 // State persisted to localStorage so the fold sticks across reloads. 2579 const dlFold = document.getElementById('cookbook-dl-tab-fold'); 2580 const dlFoldBody = document.getElementById('cookbook-dl-tab-fold-body'); 2581 const dlFoldChevron = document.getElementById('cookbook-dl-tab-chevron'); 2582 if (dlFold && dlFoldBody && dlFoldChevron) { 2583 const _setFolded = (folded, persist = true) => { 2584 // Toggle via class so CSS transition animates the height/opacity 2585 // â display:none was an instant on/off and felt jarring. 2586 dlFoldBody.classList.toggle('is-folded', folded); 2587 dlFoldChevron.textContent = folded ? 'â¸' : 'â¾'; 2588 dlFold.classList.toggle('is-folded', folded); 2589 if (persist) { 2590 try { localStorage.setItem('cookbook_dl_tab_folded_v1', folded ? '1' : '0'); } catch {} 2591 } 2592 }; 2593 dlFold.addEventListener('click', () => { 2594 const folded = dlFoldBody.classList.contains('is-folded'); 2595 _setFolded(!folded); 2596 }); 2597 // Auto-fold on any downward scroll inside the cookbook modal. Do not 2598 // auto-expand on upward/top scroll â once the user collapses Download, 2599 // it should stay collapsed until the header is clicked again. 2600 const _maybeFold = () => { 2601 if (dlFoldBody.classList.contains('is-folded')) return; 2602 _setFolded(true, /* persist */ false); 2603 }; 2604 // Capture phase so scrolls on nested scrollers (.hwfit-list,
2605 // .cookbook-body, .modal-content) all hit us. 2606 const _modal = dlFold.closest('#cookbook-modal') || document; 2607 const _lastY = new WeakMap(); 2608 _modal.addEventListener('scroll', (e) => { 2609 const tgt = e.target; 2610 if (!tgt || typeof tgt.scrollTop !== 'number') return; 2611 // Ignore scrolls that originate INSIDE the Direct Download body 2612 // (e.g. the Trending models list) â those are local to the 2613 // section and shouldn't auto-fold the section that owns them. 2614 if (dlFoldBody.contains && (tgt === dlFoldBody || dlFoldBody.contains(tgt))) return; 2615 const y = tgt.scrollTop; 2616 const prev = _lastY.get(tgt) || 0; 2617 if (y > prev) _maybeFold(); 2618 _lastY.set(tgt, y); 2619 }, true); 2620 } 2621 const hfToggle = document.getElementById('cookbook-hf-latest-toggle'); 2622 const hfArrow = document.getElementById('cookbook-hf-latest-arrow'); 2623 const hfList = document.getElementById('cookbook-hf-latest-list'); 2624 const hfRefresh = document.getElementById('cookbook-hf-latest-refresh'); 2625 if (hfToggle && hfList) { 2626 let _loaded = false; 2627 // Per-server VRAM cache so we don't re-probe on every expand 2628 const _hwCache = {}; 2629 function _hfModelLooksAwqLike(m) { 2630 const text = `${m?.repo_id || ''} ${(m?.tags || []).join(' ')}`.toLowerCase(); 2631 return /\b(awq|gptq|fp8|4bit|int4)\b/.test(text); 2632 } 2633 async function _getSelectedServerHw() { 2634 // Prefer the "What Fits" dropdown (the main control that shows hardware); 2635 // fall back to the download dropdown. This is the server the list ranks for. 2636 const dlSrv = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server'); 2637 const val = dlSrv?.value || 'local'; 2638 let host = ''; 2639 let sshPort = ''; 2640 let platform = ''; 2641 if (val !== 'local') { 2642 const s = _serverByVal(val); 2643 if (s) { 2644 host = s.host || ''; 2645 sshPort = s.port || ''; 2646 platform = s.platform || ''; 2647 } 2648 } 2649 const cacheKey = host || 'local'; 2650 if (_hwCache[cacheKey]) return _hwCache[cacheKey]; 2651 // Fetch system info for this server from hwfit 2652 try { 2653 const qp = new URLSearchParams(); 2654 if (host) qp.set('host', host); 2655 if (sshPort) qp.set('ssh_port', sshPort); 2656 if (platform) qp.set('platform', platform); 2657 const r = await fetch(`/api/hwfit/system?${qp}`); 2658 if (r.ok) { 2659 const sys = await r.json(); 2660 const hw = { vram: sys?.gpu_vram_gb || 0, backend: String(sys?.backend || '').toLowerCase() }; 2661 _hwCache[cacheKey] = hw; 2662 return hw; 2663 } 2664 } catch {} 2665 _hwCache[cacheKey] = { vram: 0, backend: '' }; 2666 return _hwCache[cacheKey]; 2667 } 2668 async function _loadLatest() { 2669 // Match the Dependencies loader: whirlpool spinner + text label so the 2670 // user gets immediate feedback while the scan runs. 2671 hfList.innerHTML = ''; 2672 try { 2673 const sp = (await import('./spinner.js')).default; 2674 const _spin = sp.createWhirlpool(22); 2675 _spin.element.classList.add('cookbook-section-loading-wp'); 2676 _spin.element.style.cssText = 'margin:24px auto 0;display:block;width:22px;height:22px;'; 2677 hfList.appendChild(_spin.element); 2678 const lbl = document.createElement('div'); 2679 lbl.className = 'hwfit-loading'; 2680 lbl.textContent = 'Scanning modelsâ¦'; 2681 lbl.style.cssText = 'text-align:center;opacity:0.5;font-size:11px;margin-top:6px;'; 2682 hfList.appendChild(lbl); 2683 } catch { 2684 hfList.innerHTML = '<div class="hwfit-loading">Scanning modelsâ¦</div>'; 2685 } 2686 const hwInfo = await _getSelectedServerHw(); 2687 const vram = hwInfo.vram || 0; 2688 try { 2689 let lastErr = ''; 2690 const _fetchLatest = async (v) => { 2691 const res = await fetch(`/api/cookbook/hf-latest?vram_gb=${v}&limit=10`); 2692 const data = await res.json(); 2693 if (data.error) lastErr = data.error; // HF API timeout/rate-limit etc. 2694 return data.models || []; 2695 }; 2696 let models = await _fetchLatest(vram); 2697 if (['rocm', 'metal', 'mps', 'apple', 'generic', 'cpu'].includes(hwInfo.backend)) { 2698 models = models.filter(m => !_hfModelLooksAwqLike(m)); 2699 } 2700 if (!models.length) { 2701 // Distinguish "the HF API failed" from "nothing matched" so an outage 2702 // doesn't masquerade as no-fitting-models. 2703 const msg = lastErr 2704 ? `Couldn't load trending models (${esc(lastErr)})` 2705 : 'No trending models found'; 2706 hfList.innerHTML = `<div class="hwfit-loading">${msg}</div>`; 2707 return; 2708 } 2709 let html = ''; 2710 for (const m of models) { 2711 const shortName = m.repo_id.split('/').pop() || m.repo_id; 2712 const org = m.repo_id.includes('/') ? m.repo_id.split('/')[0] : ''; 2713 const meta = []; 2714 if (org) meta.push(esc(org)); 2715 if (m.needed_vram_gb) meta.push(`~${m.needed_vram_gb}GB`);
2716 if (m.downloads) meta.push(`${m.downloads.toLocaleString()} downloads`); 2717 const date = m.createdAt ? new Date(m.createdAt).toISOString().slice(0, 10) : ''; 2718 if (date) meta.push(date); 2719 html += `<div class="doclib-card memory-item cookbook-hf-latest-card" data-repo="${esc(m.repo_id)}" style="cursor:pointer;">`; 2720 html += `<div style="flex:1;min-width:0;">`; 2721 html += `<div class="memory-item-title">${esc(shortName)} <a href="https://huggingface.co/${esc(m.repo_id)}" target="_blank" rel="noopener" class="cookbook-hf-link">HF \u2197</a></div>`; 2722 html += `<div class="memory-item-meta" style="font-size:10px;opacity:0.5;margin-top:2px;">${meta.join(' \u00b7 ')}</div>`; 2723 html += `</div>`; 2724 html += `</div>`; 2725 } 2726 hfList.innerHTML = html; 2727 // Wire card clicks â fill download input 2728 hfList.querySelectorAll('.cookbook-hf-latest-card').forEach(card => { 2729 card.addEventListener('click', (e) => { 2730 if (e.target.closest('a')) return; 2731 if (dlInput) { 2732 dlInput.value = card.dataset.repo; 2733 dlInput.focus(); 2734 } 2735 }); 2736 }); 2737 } catch (e) { 2738 hfList.innerHTML = '<div class="hwfit-loading">Failed to load</div>'; 2739 } 2740 } 2741 hfToggle.addEventListener('click', () => { 2742 const isOpen = hfList.style.display !== 'none'; 2743 hfList.style.display = isOpen ? 'none' : 'flex'; 2744 if (hfArrow) hfArrow.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)'; 2745 if (!isOpen && !_loaded) { 2746 _loaded = true; 2747 _loadLatest(); 2748 } 2749 }); 2750 if (hfRefresh) hfRefresh.addEventListener('click', (e) => { 2751 e.stopPropagation(); 2752 _loaded = true; 2753 _loadLatest(); 2754 // If list is hidden, open it 2755 if (hfList.style.display === 'none') { 2756 hfList.style.display = 'flex'; 2757 if (hfArrow) hfArrow.style.transform = 'rotate(90deg)'; 2758 } 2759 }); 2760 // Re-fetch when a server dropdown changes â different server = different 2761 // hardware/VRAM. Mark the list stale so it reloads for the new server even 2762 // if it's currently collapsed (otherwise reopening showed the old server's 2763 // models); reload immediately when it's open. 2764 const _onServerChange = () => { 2765 _loaded = false; 2766 if (hfList.style.display !== 'none') { _loaded = true; _loadLatest(); } 2767 }; 2768 document.getElementById('hwfit-dl-server')?.addEventListener('change', _onServerChange); 2769 document.getElementById('hwfit-server-select')?.addEventListener('change', _onServerChange); 2770 } 2771 2772 // Browse Ollama library popup removed â Engine = Ollama in the 2773 // Scan / Download filter covers this use case. The handler below is a 2774 // no-op now because the elements no longer exist. 2775 const olToggle = document.getElementById('cookbook-ollama-toggle'); 2776 const olArrow = document.getElementById('cookbook-ollama-arrow'); 2777 const olList = document.getElementById('cookbook-ollama-list'); 2778 const olRefresh = document.getElementById('cookbook-ollama-refresh'); 2779 if (olToggle && olList) { 2780 let _olLoaded = false; 2781 async function _loadOllama(refresh = false) { 2782 olList.innerHTML = '<div class="hwfit-loading" style="opacity:0.5;font-size:11px;text-align:center;padding:12px;">Loadingâ¦</div>'; 2783 try { 2784 const res = await fetch(`/api/cookbook/ollama/library${refresh ? '?refresh=1' : ''}`); 2785 const data = await res.json(); 2786 const models = data.models || []; 2787 if (!models.length) { 2788 olList.innerHTML = '<div class="hwfit-loading">No models</div>'; 2789 return; 2790 } 2791 let html = ''; 2792 for (const m of models) { 2793 const sizes = Array.isArray(m.sizes) && m.sizes.length ? m.sizes : ['latest']; 2794 const sizeChips = sizes.map(s => `<button type="button" class="memory-toolbar-btn cookbook-ol-size" data-name="${esc(m.name)}" data-size="${esc(s)}" style="height:20px;padding:0 6px;font-size:10px;border-radius:3px;">${esc(s)}</button>`).join(''); 2795 html += `<div class="doclib-card memory-item cookbook-ollama-card" data-name="${esc(m.name)}">`; 2796 html += `<div style="flex:1;min-width:0;">`; 2797 html += `<div class="memory-item-title">${esc(m.name)} <a href="https://ollama.com/library/${esc(m.name)}" target="_blank" rel="noopener" class="cookbook-hf-link">ollama â</a></div>`; 2798 if (m.description) html += `<div class="memory-item-meta" style="font-size:10px;opacity:0.55;margin-top:2px;">${esc(m.description)}</div>`; 2799 html += `<div style="display:flex;flex-wrap:wrap;gap:3px;margin-top:4px;">${sizeChips}</div>`; 2800 html += `</div></div>`; 2801 } 2802 olList.innerHTML = html; 2803 olList.querySelectorAll('.cookbook-ol-size').forEach(btn => { 2804 btn.addEventListener('click', (e) => { 2805 e.stopPropagation(); 2806 const name = btn.dataset.name; 2807 const size = btn.dataset.size; 2808 if (dlInput) { 2809 dlInput.value = `${name}:${size}`; 2810 dlInput.focus(); 2811 } 2812 }); 2813 }); 2814 // Clicking the card body (not a size chip / link) â default to first size 2815 olList.querySelectorAll('.cookbook-ollama-card').forEach(card => { 2816 card.addEventListener('click', (e) => { 2817 if (e.target.closest('a') || e.target.closest('.cookbook-ol-size')) return; 2818 const name = card.dataset.name; 2819 const firstSize = card.querySelector('.cookbook-ol-size')?.dataset.size || 'latest'; 2820 if (dlInput) { 2821 dlInput.value = `${name}:${firstSize}`; 2822 dlInput.focus(); 2823 } 2824 }); 2825 }); 2826 } catch (e) { 2827 olList.innerHTML = '<div class="hwfit-loading">Failed to load</div>'; 2828 } 2829 } 2830 olToggle.addEventListener('click', () => { 2831 const isOpen = olList.style.display !== 'none'; 2832 olList.style.display = isOpen ? 'none' : 'flex'; 2833 if (olArrow) olArrow.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)'; 2834 if (!isOpen && !_olLoaded) { 2835 _olLoaded = true; 2836 _loadOllama(false); 2837 } 2838 }); 2839 if (olRefresh) olRefresh.addEventListener('click', (e) => { 2840 e.stopPropagation(); 2841 _olLoaded = true; 2842 _loadOllama(true); 2843 if (olList.style.display === 'none') { 2844 olList.style.display = 'flex'; 2845 if (olArrow) olArrow.style.transform = 'rotate(90deg)'; 2846 } 2847 }); 2848 } 2849 2850 // Server add button, row removal, model-dir add/remove, and per-row wiring 2851 // are ALL owned by cookbook-hwfit.js's _hwfitInit / _wireServerEntry. 2852 // A duplicate add handler used to live here and fired alongside the hwfit 2853 // one, appending two rows per click â removed. 2854 2855 2856 // HF token â save on change 2857 const hfInput = document.getElementById('hwfit-hftoken'); 2858 if (hfInput) { 2859 hfInput.addEventListener('change', async () => { 2860 const val = hfInput.value.trim(); 2861 _envState.hfToken = val;
2862 try { await _persistEnvState(); } catch {} 2863 if (val) { 2864 _envState.hfTokenConfigured = true; 2865 const masked = val.length > 6 ? val.slice(0, 3) + 'â¦' + val.slice(-3) : 'â¢â¢â¢â¢'; 2866 _envState.hfTokenMasked = masked; 2867 hfInput.placeholder = `Stored (${masked}) - enter a new token to replace`; 2868 hfInput.value = ''; 2869 let check = hfInput.parentNode.querySelector('.hwfit-hf-check'); 2870 if (!check) { 2871 check = document.createElement('span'); 2872 check.className = 'hwfit-hf-check'; 2873 check.title = 'Token stored'; 2874 check.textContent = 'â'; 2875 check.style.cssText = 'font-weight:800;color:var(--green,#50fa7b);font-size:15px;line-height:1;flex-shrink:0;position:relative;top:2px;'; 2876 hfInput.parentNode.insertBefore(check, hfInput); 2877 } 2878 const flash = document.createElement('span'); 2879 flash.textContent = 'Saved'; 2880 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;'; 2881 hfInput.parentNode.appendChild(flash); 2882 requestAnimationFrame(() => { flash.style.opacity = '1'; }); 2883 setTimeout(() => { flash.style.opacity = '0'; setTimeout(() => flash.remove(), 220); }, 1400); 2884 } 2885 }); 2886 } 2887} 2888 2889// ââ Main render ââ 2890 2891// Build one server entry's HTML â shared by the Settings render loop AND the 2892// "+ Add server" handler, so a freshly-added server has the IDENTICAL layout 2893// (Model Directory header, default-server checkmark, trash delete, platform icon). 2894// forceRemote renders an editable remote entry even before a host is typed 2895// (a new server's host is empty, which would otherwise read as "Local"). 2896export function _serverDefaultHtml(active) { 2897 const check = active ? '<span class="hwfit-hf-check cookbook-srv-default-check" title="Default server" style="font-weight:800;color:var(--green,#50fa7b);font-size:15px;line-height:1;flex-shrink:0;position:relative;top:2px;">â</span>' : ''; 2898 return `${check}<span class="cookbook-srv-default-label">default</span>`; 2899} 2900 2901export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) { 2902 const isLocal = (forceRemote || isNew) ? false : (!s.host || s.host === 'local'); 2903 const envOpts = [['none', 'None'], ['venv', 'venv'], ['conda', 'conda']].map(([value, label]) => `<option value="${value}"${s.env === value ? ' selected' : ''}>${label}</option>`).join(''); 2904 const srvColor = _serverColor(s); 2905 const resolvedSrvColor = _resolvedServerColor(s, i); 2906 const colorOpts = _SERVER_COLOR_CHOICES.map(([value, label]) => { 2907 const displayLabel = label; 2908 return `<option value="${esc(value)}"${_serverColorOptionStyle(value)}${srvColor === value ? ' selected' : ''}>${esc(displayLabel)}</option>`; 2909 }).join(''); 2910 const selectedColorLabel = srvColor ? _serverColorLabel(srvColor) : `Auto · ${_serverColorLabel(resolvedSrvColor)}`; 2911 const colorMenu = _SERVER_COLOR_CHOICES.map(([value, label]) => { 2912 const active = value === srvColor; 2913 const swatchColor = value || resolvedSrvColor; 2914 const rowLabel = value ? label : `Auto · ${_serverColorLabel(resolvedSrvColor)}`; 2915 const swatch = swatchColor ? ` style="--swatch-color:${esc(swatchColor)};"` : ''; 2916 return `<button type="button" class="cookbook-srv-color-item${active ? ' active' : ''}" data-color="${esc(value)}"${swatch}><span class="cookbook-srv-color-item-dot"></span><span>${esc(rowLabel)}</span></button>`; 2917 }).join(''); 2918 let html = ''; 2919 html += `<div class="cookbook-server-entry" data-idx="${i}" data-platform="${esc(s.platform || '')}"${resolvedSrvColor ? ` style="--cookbook-server-color:${esc(resolvedSrvColor)};"` : ''}>`; 2920 const _srvTitle = s.name || (isLocal ? 'Local' : (s.host || `Server ${i + 1}`)); 2921 const _srvKey = isLocal ? 'local' : (s.host || ''); 2922 const _isDefaultSrv = (defaultServer || '') === _srvKey; 2923 const _pIco = _platformIcon(s.platform); 2924 const _keyBtn = `<button class="cookbook-server-key-btn" title="Set up SSH key for this server" style="height:22px;box-sizing:border-box;display:inline-flex;align-items:center;position:relative;top:-2px;">
2924<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><circle cx="7.5" cy="15.5" r="5.5"/><path d="M12 11l8-8"/><path d="M17 6l3 3"/></svg>Key</button>`; 2925 const _checkBtn = `<button class="cookbook-server-check-btn" title="Check SSH connection" style="height:22px;box-sizing:border-box;display:inline-flex;align-items:center;position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><polyline points="20 6 9 17 4 12"/></svg>Check</button>`; 2926 html += `<span class="cookbook-server-title" style="display:flex;align-items:center;gap:6px;width:100%;font-size:13px;font-weight:600;margin-bottom:4px;">`; 2927 html += `${esc(_srvTitle)}`; 2928 html += _pIco ? `<span class="cookbook-srv-platform" title="${esc(s.platform || '')}" style="display:inline-flex;align-items:center;opacity:0.55;">${_pIco}</span>` : ''; 2929 html += `<span class="cookbook-srv-test-msg" style="font-size:10px;font-weight:400;opacity:0.55;max-width:160px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;position:relative;top:1px;"></span>`; 2930 if (isNew) { 2931 // New server: Cancel (discard) sits top-right; the default toggle only makes 2932 // sense once the server is saved. 2933 html += `<span style="margin-left:auto;display:inline-flex;gap:4px;align-items:center;">${_checkBtn}${_keyBtn}<button class="cookbook-server-cancel-btn" title="Discard this new server" style="height:22px;box-sizing:border-box;display:inline-flex;align-items:center;position:relative;top:-2px;">
2933<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>Cancel</button></span>`; 2934 } else { 2935 html += `<span style="margin-left:auto;display:inline-flex;gap:4px;align-items:center;">${!isLocal ? _checkBtn + _keyBtn : ''}<span class="cookbook-srv-default${_isDefaultSrv ? ' active' : ''}" title="${_isDefaultSrv ? 'Default server â Cookbook opens here' : 'Make this the default server'}" data-srv-key="${esc(_srvKey)}">${_serverDefaultHtml(_isDefaultSrv)}</span></span>`; 2936 } 2937 html += `</span>`; 2938 html += `<div class="cookbook-server-row">`; 2939 html += `<input type="text" class="hwfit-sf cookbook-srv-name" value="${esc(s.name || (isLocal ? 'Local' : ''))}" placeholder="Name (optional)" style="width:92px;flex-shrink:0;" />`; 2940 html += `<span class="cookbook-srv-color-wrap has-color" title="Server color"><select class="hwfit-sf cookbook-srv-color" aria-hidden="true" tabindex="-1">${colorOpts}</select><button type="button" class="hwfit-sf cookbook-srv-color-btn" aria-haspopup="listbox" aria-expanded="false"><span class="cookbook-srv-color-dot" aria-hidden="true"></span><span class="cookbook-srv-color-label">${esc(selectedColorLabel)}</span><svg class="cookbook-srv-color-caret" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button><div class="cookbook-srv-color-menu hidden" role="listbox">${colorMenu}</div></span>`; 2941 html += `<input type="text" class="hwfit-sf cookbook-srv-host" value="${isLocal ? '' : esc(s.host || '')}" placeholder="e.g. user@ip" style="width:184px;flex-shrink:0;box-sizing:border-box;" ${isLocal ? 'readonly' : ''} />`; 2942 html += `<input type="text" class="hwfit-sf cookbook-srv-port" value="${esc(s.port || '')}" placeholder="Port" title="SSH port (default 22)" style="width:48px;flex-shrink:0;" ${isLocal ? 'readonly' : ''} />`; 2943 html += `<select class="hwfit-sf cookbook-srv-env">${envOpts}</select>`; 2944 html += `<input type="text" class="hwfit-sf cookbook-srv-path" value="${esc(s.envPath || '')}" placeholder="${s.platform === 'windows' ? 'venv/conda env' : '~/venv or conda-env'}" />`; 2945 html += `<span class="cookbook-dep-tag cookbook-dep-target" style="font-size:8px;flex-shrink:0;min-width:46px;text-align:center;visibility:hidden;">placeholder</span>`; 2946 html += `<span class="cookbook-srv-actions" style="display:inline-flex;gap:4px;align-items:center;width:78px;flex-shrink:0;justify-content:flex-end;"></span>`; 2947 html += `</div>`; 2948 const modelDirs = Array.isArray(s.modelDirs) && s.modelDirs.length ? s.modelDirs : ['~/.cache/huggingface/hub']; 2949 const activeDlDir = s.downloadDir || ''; 2950 html += `<div class="cookbook-modeldirs" style="margin:2px 0 0 0;display:flex;flex-wrap:wrap;gap:4px;align-items:center;">`; 2951 html += `<span style="width:100%;font-size:13px;font-weight:600;margin-bottom:3px;">Model Directory <span style="font-weight:400;opacity:0.5;font-size:11px;">â check the one downloads should go to</span></span>`; 2952 for (let j = 0; j < modelDirs.length; j++) { 2953 const isDefault = modelDirs[j] === '~/.cache/huggingface/hub'; 2954 const dirVal = isDefault ? '' : modelDirs[j]; 2955 const isTarget = activeDlDir === dirVal; 2956 const dlBtn = `<span class="cookbook-modeldir-dl${isTarget ? ' active' : ''}" title="${isTarget ? 'Downloads go here' : 'Send downloads here'}" data-dl-dir="${esc(dirVal)}">
2956${isTarget ? _MODELDIR_CHECK_ON : _MODELDIR_CHECK_OFF}</span>`; 2957 const rmBtn = isDefault ? '' : ' <span class="cookbook-modeldir-rm" title="Remove">â</span>'; 2958 html += `<span class="cookbook-modeldir-tag${isDefault ? ' cookbook-modeldir-default' : ''}${isTarget ? ' cookbook-modeldir-target' : ''}" data-dir-idx="${j}" data-dir="${esc(modelDirs[j])}">${dlBtn} ${esc(modelDirs[j])}${rmBtn}</span>`; 2959 } 2960 html += `<button class="cookbook-modeldir-add" title="Add model directory">+ Add</button>`; 2961 const _btnBaseStyle = 'position:relative;top:-2px;height:22px;box-sizing:border-box;display:inline-flex;align-items:center;'; 2962 const _btnPushStyle = `margin-left:auto;${_btnBaseStyle}`; 2963 if (isNew) { 2964 // A brand-new server: Save (confirm) sits where Delete would be; Cancel is 2965 // top-right in the title. Save confirms with a checkmark (auto-saves on edit too). 2966 html += `<button class="cookbook-server-save-btn" title="Save this server" style="${_btnPushStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>`; 2967 } else if (!isLocal) { 2968 html += `<button class="cookbook-server-rm cookbook-server-rm-btn" title="Delete this server" style="${_btnPushStyle}">
2968<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>Delete</button>`; 2969 html += `<button class="cookbook-server-save-btn" title="Save server changes" style="${_btnBaseStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>`; 2970 } 2971 html += `</div>`; 2972 if (!isLocal) { 2973 html += `<div class="cookbook-server-key-panel hidden" style="margin-top:6px;flex-direction:column;gap:5px;">`; 2974 html += `<div style="display:flex;gap:4px;align-items:center;">`; 2975 html += `<button type="button" class="memory-toolbar-btn cookbook-server-key-gen" style="height:23px;">Generate key</button>`; 2976 html += `<button type="button" class="memory-toolbar-btn cookbook-server-key-copy" style="height:23px;" disabled>Copy command</button>`; 2977 html += `<span style="font-size:10px;opacity:0.55;line-height:1.25;">Docker: run this command in your terminal once.</span>`; 2978 html += `</div>`; 2979 html += `<textarea class="memory-search-input cookbook-server-key-command" readonly rows="3" style="min-height:58px;resize:vertical;font-family:var(--mono,monospace);font-size:10px;line-height:1.35;">Enter user@host, then generate the key.</textarea>`; 2980 html += `</div>`; 2981 } 2982 html += `</div>`; 2983 return html; 2984} 2985 2986function _renderRecipes() { 2987 const body = document.querySelector('#cookbook-modal .cookbook-body'); 2988 if (!body) return; 2989 2990 const presets = _loadPresets(); 2991 const hasSaved = presets.length > 0; 2992 2993 let html = ''; 2994 2995 // Tabs 2996 html += '<div class="cookbook-tabs">'; 2997 html += '<button class="cookbook-tab" data-backend="Serve"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none" style="vertical-align:-1px;margin-right:3px;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Launch</button>'; 2998 html += '<button class="cookbook-tab active" data-backend="Search"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="7 14 12 19 17 14"/><line x1="12" y1="19" x2="12" y2="5"/><line x1="5" y1="21" x2="19" y2="21"/></svg>Download</button>'; 2999 html += '<button class="cookbook-tab" data-backend="Dependencies"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:3px;"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>Dependencies</button>'; 3000 html += '<button class="cookbook-tab" data-backend="Settings"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:3px;"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Settings</button>'; 3001 html += '</div>'; 3002 3003 // Search group 3004 html += '<div class="cookbook-group" data-backend-group="Search" style="flex:0 0 auto;">'; 3005 html += '<div class="admin-card" style="display:flex;flex-direction:column;overflow:hidden;">'; 3006 // Foldable Download admin-card: clicking the h2 header collapses the 3007 // entire card body (description + download input + HF latest section). 3008 // State persisted to localStorage so the fold survives reloads.
3009 const _dlTabFolded = (() => { try { return localStorage.getItem('cookbook_dl_tab_folded_v1') === '1'; } catch { return false; } })(); 3010 html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">'; 3011 html += `<h2 id="cookbook-dl-tab-fold" class="${_dlTabFolded ? 'is-folded' : ''}" style="margin:0;padding:0;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:space-between;user-select:none;flex:1;">Direct Download<span id="cookbook-dl-tab-chevron" style="display:inline-block;transition:transform 0.15s;font-size:1.1em;margin-left:8px;opacity:0.85;">${_dlTabFolded ? 'â¸' : 'â¾'}</span></h2>`; 3012 html += '</div>'; 3013 html += `<div id="cookbook-dl-tab-fold-body" class="${_dlTabFolded ? 'is-folded' : ''}">`; 3014 html += '<p class="memory-desc doclib-desc" style="margin-top:6px;">Download from <a href="https://huggingface.co/models" target="_blank" rel="noopener" style="color:var(--accent,var(--red));text-decoration:none;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:1px;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>HuggingFace</a> by pasting model link, or download directly in the Scan section below.</p>'; 3015 html += '<div class="hwfit-container" id="hwfit-container">'; 3016 3017 // Section 1: Settings 3018 const _es = _envState; 3019 if (!_es.servers) _es.servers = []; 3020 let _localSeen = false; 3021 _es.servers = _es.servers.filter(s => { 3022 const isLocal = !s.host || s.host.toLowerCase() === 'local'; 3023 if (isLocal) { 3024 s.host = ''; 3025 s.platform = _envState.hostPlatform || ''; 3026 if (_localSeen) return false; 3027 _localSeen = true; 3028 } 3029 return true; 3030 }); 3031 if (!_localSeen) { 3032 _es.servers.unshift({ host: '', env: _es.env || 'none', envPath: _es.envPath || '', modelDir: '~/.cache/huggingface/hub', platform: _envState.hostPlatform || '' }); 3033 } 3034 if (_es.remoteHost && !_es.servers.some(s => s.host === _es.remoteHost)) { 3035 _es.servers.push({ host: _es.remoteHost, env: _es.env || 'none', envPath: _es.envPath || '', modelDir: '~/.cache/huggingface/hub' }); 3036 _persistEnvState(); 3037 } 3038 // NOTE: deliberately do NOT auto-pick the first remote server when no host is 3039 // selected. That fallback turned any momentarily-empty remoteHost (a clobber, 3040 // a render before the user's pick registered) into the first saved server, 3041 // silently sending downloads to the wrong server. An empty selection means Local; the user 3042 // chooses a remote server explicitly via the dropdown. 3043 3044 // Manual download input â server picker on the same row as the repo input, 3045 // on the left. The standalone "add server" button is gone (use Settings). 3046 html += `<div class="cookbook-dl-input" style="margin-top:7px;display:flex;gap:4px;align-items:center;">`; 3047 if (_es.servers.length > 1) { 3048 html += `<select class="cookbook-field-input hwfit-dl-server" id="hwfit-dl-server" style="height:28px;flex-shrink:0;">`; 3049 html += _buildServerOpts(true); 3050 html += `</select>`; 3051 } else { 3052 html += `<input type="hidden" id="hwfit-dl-server" value="local" />`; 3053 } 3054 html += `<input type="text" class="cookbook-dl-repo" id="cookbook-dl-repo" placeholder="org/model-name, qwen2.5:14b, or HF URL" style="flex:1;min-width:0;" />`; 3055 html += `<button class="cookbook-btn cookbook-dl-btn" id="cookbook-dl-btn">Download</button>`; 3056 html += `</div>`; 3057 html += `<div id="cookbook-dl-gguf-row" class="cookbook-dl-gguf-row" style="display:none;">`; 3058 html += `<span class="cookbook-dl-gguf-label">GGUF</span>`; 3059 html += `<select class="cookbook-field-input" id="cookbook-dl-gguf-quant"></select>`; 3060 html += `<span id="cookbook-dl-gguf-note"></span>`; 3061 html += `</div>`; 3062 // Ollama-library browse used to live here as its own collapsible dropdown, 3063 // but that duplicated the Engine filter (which already has Ollama). The 3064 // standalone UI is gone â to find Ollama models, set Engine = Ollama in 3065 // the Scan / Download section below. 3066 // Latest HF models that fit â collapsible card list 3067 html += `<div style="margin-top:5px;position:relative;top:-11px;">`; 3068 html += `<div style="display:flex;gap:4px;align-items:center;">`; 3069 html += `<button type="button" class="memory-toolbar-btn" id="cookbook-hf-latest-toggle" style="flex:1;text-align:left;height:28px;font-size:11px;display:flex;align-items:center;gap:6px;border-radius:5px;">`; 3070 // Trending-up icon (accent) so the section reads as "what's hot". 3071 html += `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--accent, var(--red))" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;pointer-events:none;"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>`; 3072 html += `<span style="pointer-events:none;flex:1;">Trending models that fit your hardware</span>`;
3073 // Chevron moved to the RIGHT \u2014 collapsed = pointing right, expanded 3074 // = rotated 90deg into a down chevron (handled by existing toggle CSS). 3075 html += `<span id="cookbook-hf-latest-arrow" style="display:inline-block;transition:transform 0.15s;pointer-events:none;opacity:0.6;font-size:11px;">\u25B8</span>`; 3076 html += `</button>`; 3077 html += `</div>`; 3078 html += `<div id="cookbook-hf-latest-list" style="display:none;margin-top:4px;max-height:320px;overflow-y:auto;overscroll-behavior:contain;flex-direction:column;gap:4px;"></div>`; 3079 html += `</div>`; 3080 html += `</div>`; // /#cookbook-dl-tab-fold-body (whole Download card body) 3081 3082 // Search section 3083 html += '</div></div></div></div>'; 3084 html += '<div class="cookbook-group" data-backend-group="Search">'; 3085 html += '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">'; 3086 html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">'; 3087 html += '<h2 style="margin:0;padding:0;line-height:1;">Scan / Download</h2>'; 3088 html += '</div>'; 3089 html += '<p class="memory-desc doclib-desc" style="margin-top:6px;">Scans your hardware for what models you can run. Hardware is cached; hit the scan button to re-probe after changing GPUs.</p>'; 3090 html += '<div class="hwfit-toolbar" style="margin-top:9px;">'; 3091 html += '<select class="cookbook-field-input hwfit-server-select" id="hwfit-server-select" style="height:28px;min-width:88px;position:relative;top:0px;">'; 3092 html += _buildServerOpts(false); 3093 html += '</select>'; 3094 // Keep the main scan toolbar light: server + free-text search. Advanced 3095 // levers (Engine / Quant / Context) live behind the cog beside Refresh. 3096 html += '<input type="text" class="cookbook-field-input hwfit-search" id="hwfit-search" placeholder="Search models..." style="flex:1;" />'; 3097 html += '</div>'; 3098 html += '<div class="hwfit-toolbar" style="margin-top:7px;">'; 3099 html += '<span class="hwfit-usecase-wrap">'; 3100 html += '<select class="cookbook-field-input hwfit-usecase" id="hwfit-usecase" style="display:none;height:28px;">'; 3101 html += '<option value="general" selected>Standard</option>'; 3102 html += '<option value="multimodal">Vision</option>'; 3103 html += '<option value="image_gen">Image</option></select>'; 3104 html += '<button type="button" class="cookbook-field-input hwfit-usecase-btn" data-hwfit-usecase-btn aria-haspopup="listbox" aria-expanded="false" title="Model type">'; 3105 html += '<span class="hwfit-usecase-btn-icon" data-hwfit-usecase-icon aria-hidden="true"></span>'; 3106 html += '<span class="hwfit-usecase-btn-label" data-hwfit-usecase-label>Standard</span>'; 3107 html += '<svg class="hwfit-usecase-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>'; 3108 html += '</button>'; 3109 html += '<div class="hwfit-usecase-menu" data-hwfit-usecase-menu role="listbox" hidden></div>'; 3110 html += '</span>'; 3111 html += '<div class="hwfit-gpu-toggles" id="hwfit-gpu-toggles"></div>'; 3112 html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-manual-btn" id="hwfit-hw-manual-btn" title="Set hardware manually" style="flex-shrink:0;position:relative;top:-3px;left:-1px;display:inline-flex;align-items:center;gap:3px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>EDIT</button>'; 3113 html += '<button type="button" class="hwfit-gpu-btn hwfit-advanced-btn" id="hwfit-advanced-btn" title="Scan settings" aria-label="Scan settings" aria-expanded="false" style="flex-shrink:0;position:relative;top:-3px;left:-3px;width:26px;height:26px;padding:0;display:inline-flex;align-items:center;justify-content:center;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"/>
3113<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 8.92 4a1.65 1.65 0 0 0 1-1.51V2a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg></button>'; 3114 html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-refresh-btn" id="hwfit-hw-refresh-btn" title="Refresh selected server hardware and cached models" aria-label="Refresh selected server hardware and cached models" style="flex-shrink:0;position:relative;top:-3px;left:-5px;width:26px;height:26px;padding:0;display:inline-flex;align-items:center;justify-content:center;">
3114<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10"/><path d="M3.51 15a9 9 0 0 0 14.85 3.36L23 14"/></svg></button>'; 3115 // Sort state â the clickable column headers read/write this (pewds' original 3116 // sort paradigm). Newest is reachable by clicking the Model column header. 3117 html += '<select class="cookbook-field-input hwfit-sort" id="hwfit-sort" style="display:none">'; 3118 html += '<option value="newest" selected>Latest</option>'; 3119 html += '<option value="fit">Fit</option><option value="score">Score</option><option value="vram">VRAM</option>'; 3120 html += '<option value="speed">Speed</option><option value="params">Params</option>'; 3121 html += '<option value="context">Context</option></select>'; 3122 html += '</div>'; 3123 html += '<div class="hwfit-advanced-panel hidden" id="hwfit-advanced-panel" aria-label="Scan settings">'; 3124 html += '<span class="hwfit-engine-wrap">'; 3125 html += '<select class="cookbook-field-input hwfit-engine" id="hwfit-engine" style="display:none;" title="Filter by serving engine">'; 3126 html += '<option value="">Engine</option>'; 3127 html += '<option value="llamacpp">llama.cpp</option>'; 3128 html += '<option value="ollama">Ollama</option>'; 3129 html += '<option value="mlx">MLX</option>'; 3130 html += '<option value="vllm">vLLM</option>'; 3131 html += '<option value="sglang">SGLang</option>'; 3132 html += '<option value="diffusers">Diffusers</option>'; 3133 html += '</select>'; 3134 html += '<button type="button" class="cookbook-field-input hwfit-engine-btn" data-hwfit-engine-btn aria-haspopup="listbox" aria-expanded="false" title="Filter by serving engine">'; 3135 html += '<span class="hwfit-engine-btn-icon" data-hwfit-engine-icon aria-hidden="true"></span>'; 3136 html += '<span class="hwfit-engine-btn-label" data-hwfit-engine-label>Engine</span>'; 3137 html += '<svg class="hwfit-engine-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>'; 3138 html += '</button>'; 3139 html += '<div class="hwfit-engine-menu" data-hwfit-engine-menu role="listbox" hidden></div>'; 3140 html += '<span class="hwfit-help-chip hwfit-help-chip-inline hwfit-engine-help" title="Rule of thumb: GGUF on single GPU / CPU+RAM â llama.cpp (or Ollama). Safetensors on multi-GPU NVIDIA â vLLM. SGLang is a vLLM-class alternative, sometimes faster on big-MoE / long-context.">?</span>'; 3141 html += '</span>'; 3142 // Quant (Q4/Q8/â¦). Default is "All" so the list shows the best-scoring 3143 // quant for every model instead of silently filtering to Q4. 3144 html += '<span class="hwfit-quant-wrap">'; 3145 html += '<select class="cookbook-field-input hwfit-quant" id="hwfit-quant" style="height:28px;">'; 3146 html += '<option value="" selected>Quant</option>'; 3147 html += '<option value="Q4_K_M">Q4 / AWQ</option><option value="Q8_0">Q8</option>'; 3148 html += '<option value="Q6_K">Q6</option><option value="Q5_K_M">Q5</option>'; 3149 html += '<option value="Q3_K_M">Q3</option><option value="Q2_K">Q2</option>'; 3150 html += '<option value="AWQ-4bit">AWQ</option><option value="FP8">FP8</option><option value="FP4">FP4</option><option value="NVFP4">NVFP4</option></select>'; 3151 html += '<span class="hwfit-help-chip hwfit-help-chip-inline hwfit-quant-help" title="Lower quant tiers (Q2/Q3/Q4 / AWQ-4bit) are smaller, faster, and cheaper to run, at some quality loss. Higher tiers (Q8 / FP8 / FP16 / BF16) preserve more quality but need more VRAM. âAllâ shows the best-scoring quant per model â pick a specific one to filter.">?</span>'; 3152 html += '</span>'; 3153 // Ctx slider â lets you target a context length for fit estimates; the 3154 // hwfit ranking uses _ctxValue() to factor that into VRAM math, so 3155 // dragging this re-sorts the list toward models that fit your chosen ctx. 3156 html += '<label class="hwfit-ctx-control" title="Context length for fit estimates. Lower it to find more models that could fit your hardware.">'; 3157 html += '<span>Context</span><span class="hwfit-help-chip hwfit-help-chip-inline" title="Context length. Lower it to find more models that could fit your hardware; raise it when you need longer chats or documents.">?</span><input type="range" id="hwfit-context" min="0" max="5" step="1" value="3" />'; 3158 html += '<output id="hwfit-context-label">50k</output></label>'; 3159 html += '</div>'; 3160 html += '<div class="hwfit-manual-panel hidden" id="hwfit-manual-panel">'; 3161 html += '<span class="hwfit-manual-note" style="font-size:10px;opacity:0.6;width:100%;margin-bottom:2px;">Simulator â these values REPLACE detected hardware.</span>'; 3162 html += '<select class="hwfit-manual-mode"><option value="gpu">GPU</option><option value="ram">RAM</option></select>'; 3163 html += '<label>GPUs<input class="hwfit-manual-gpus" type="text" inputmode="numeric" placeholder="1"></label>'; 3164 html += '<label>VRAM per GPU<input class="hwfit-manual-vram" type="text" inputmode="decimal" placeholder="8 GB"></label>'; 3165 html += '<label>Total RAM<input class="hwfit-manual-ram" type="text" inputmode="decimal" placeholder="32 GB"></label>'; 3166 html += '<select class="hwfit-manual-backend"><option value="cuda">CUDA</option><option value="rocm">ROCm</option></select>'; 3167 html += '<button type="button" class="hwfit-hw-manual-save">â Apply</button>'; 3168 html += '<button type="button" class="hwfit-hw-manual-clear">à Clear</button>'; 3169 html += '</div>'; 3170 html += '<div id="hwfit-hw-row" style="display:none;align-items:center;gap:4px;margin-top:3px;padding-top:2px;"><span style="font-size:10px;padding:2px 8px;border-radius:10px;background:color-mix(in srgb, var(--fg) 8%, transparent);color:var(--fg);opacity:0.7;white-space:nowrap;flex-shrink:0;position:relative;top:-1px;">Detected hardware</span><div class="hwfit-hw" id="hwfit-hw" style="flex:1;"></div></div>'; 3171 html += '<div class="hwfit-list" id="hwfit-list"></div>';
3172 // Footer: link to the public discussion where users can request additions 3173 // to the curated model list. Sits below the list so it reads as a callout 3174 // after browsing, not a header. 3175 html += '<div class="hwfit-list-footer" style="display:none;">' 3176 + 'Don\'t see a model? ' 3177 + '<a href="https://github.com/odysseus-dev/odysseus/discussions/1962" target="_blank" rel="noopener" style="color:var(--accent,var(--red));text-decoration:none;display:inline-flex;align-items:center;gap:4px;vertical-align:middle;position:relative;top:-1px;">' 3178 + 'Request it â' 3179 + '<svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style="flex-shrink:0;"><path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>' 3180 + '</a>' 3181 + '</div>'; 3182 3183 html += '</div></div>'; 3184 3185 // Serve group 3186 html += '<div class="cookbook-group hidden" data-backend-group="Serve">'; 3187 html += '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">'; 3188 html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">'; 3189 html += '<h2 style="margin:0;padding:0;line-height:1;">Serve <span id="serve-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>'; 3190 html += '</div>'; 3191 const _selSrv = _es.servers.find(s => s.host === _es.remoteHost) || _es.servers[0] || {}; 3192 const _srvDirs = (Array.isArray(_selSrv.modelDirs) ? _selSrv.modelDirs : [_selSrv.modelDir || '~/.cache/huggingface/hub']).map(d => _normalizeCookbookModelDir(d)).filter(Boolean); 3193 html += '<div class="cookbook-serve-dirs" style="margin-top:6px;">'; 3194 html += _srvDirs.map(d => `<span class="cookbook-serve-dir-pill">${esc(d)}</span>`).join(''); 3195 html += '<span class="cookbook-serve-dir-edit" title="Edit in Settings">edit</span>'; 3196 html += '</div>'; 3197 html += '<div style="display:flex;gap:4px;align-items:center;margin-top:4px;">'; 3198 html += '<select class="memory-sort-select" id="hwfit-cache-server" style="height:24px;">' + _buildServerOpts(true) + '</select>'; 3199 html += '<select class="memory-sort-select" id="serve-sort" style="height:24px;">'; 3200 html += '<option value="name">Name</option><option value="size-desc">Size \u2193</option><option value="size-asc">Size \u2191</option><option value="recent">Recent</option>'; 3201 html += '</select>'; 3202 html += '<button type="button" class="hwfit-gpu-btn" id="hwfit-cache-scan" title="Refresh cached models on selected server" aria-label="Refresh cached models on selected server">
3202<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10"/><path d="M3.51 15a9 9 0 0 0 14.85 3.36L23 14"/></svg></button>'; 3203 html += '</div>'; 3204 html += '<div class="memory-toolbar" style="margin-top:8px;">'; 3205 html += '<div class="memory-category-filters">'; 3206 html += '<input type="text" class="memory-search-input" id="serve-search" placeholder="Search cached models\u2026" style="flex:1;min-width:120px;" />'; 3207 html += '<button class="memory-toolbar-btn" id="hwfit-cache-select">Select</button>'; 3208 html += '</div>'; 3209 html += '<div class="doclib-lang-chips" id="serve-tags"></div>'; 3210 html += '</div>'; 3211 3212 html += '<div class="memory-bulk-bar hidden" id="serve-bulk-bar">'; 3213 html += '<label class="memory-bulk-check-all"><input type="checkbox" id="serve-select-all"> All</label>'; 3214 html += '<span id="serve-bulk-count" style="font-size:10px;opacity:0.5;">0 selected</span>'; 3215 html += '<button class="memory-toolbar-btn danger" id="serve-bulk-delete" style="position:relative;top:-3px;">
3215<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Delete</button>'; 3216 html += '<button class="memory-toolbar-btn" id="serve-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;position:relative;top:-7px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>'; 3217 html += '</div>'; 3218 3219 html += '<div class="doclib-grid hwfit-cached-list" id="hwfit-cached-list"></div>'; 3220 html += '</div></div>'; 3221 3222 // Dependencies tab 3223 html += '<div class="cookbook-group hidden" data-backend-group="Dependencies">'; 3224 html += '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">'; 3225 html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">'; 3226 html += '<h2 style="margin:0;padding:0;line-height:1;">Dependencies</h2>'; 3227 // Rebuild llama.cpp button moved into the llama_cpp dep row (see _depRow); 3228 // having it in the title polluted the section header. 3229 html += '<span style="font-size:10px;opacity:0.5;margin-left:auto;">Server</span>'; 3230 html += '<select class="cookbook-field-input" id="hwfit-deps-server" style="height:28px;min-width:70px;">'; 3231 html += _buildServerOpts(false); 3232 html += '</select>'; 3233 html += '</div>'; 3234 html += '<p class="memory-desc doclib-desc">Optional packages that extend Odysseus capabilities.</p>'; 3235 html += '<div class="doclib-grid" id="cookbook-deps-list"></div>'; 3236 html += '</div></div>'; 3237 3238 // Settings tab 3239 // Settings tab â split into two separate `.admin-card` blocks so the 3240 // HF Token and Server config look like distinct panels (matches the 3241 // Download tab's block-per-section layout). 3242 html += '<div class="cookbook-group hidden cookbook-settings-stack" data-backend-group="Settings">'; 3243 3244 // ââ HuggingFace Token block âââââââââââââââââââââââââââââââââââââââââ 3245 html += '<div class="admin-card" style="flex:0 0 auto;display:flex;flex-direction:column;">'; 3246 html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">'; 3247 html += '<h2 style="margin:0;padding:0;line-height:1;">HuggingFace Token</h2>'; 3248 html += '</div>'; 3249 html += '<p class="memory-desc doclib-desc">Personal access token for downloading gated and private models.</p>'; 3250 html += '<div class="memory-toolbar">'; 3251 html += `<div style="display:flex;gap:4px;align-items:center;">`; 3252 // Bold green check shown when a token is stored (a placeholder can't style a 3253 // single glyph, so it's its own element next to the input). 3254 if (_es.hfTokenConfigured) { 3255 html += `<span class="hwfit-hf-check" title="Token stored" style="font-weight:800;color:var(--green,#50fa7b);font-size:15px;line-height:1;flex-shrink:0;position:relative;top:2px;">â</span>`; 3256 } 3257 const hfPlaceholder = _es.hfTokenConfigured 3258 ? `Stored (${esc(_es.hfTokenMasked || 'configured')}) - enter a new token to replace` 3259 : 'hf_...'; 3260 html += `<input type="password" class="memory-search-input" id="hwfit-hftoken" value="${esc(_es.hfToken || '')}" placeholder="${hfPlaceholder}" style="flex:1;" />`; 3261 html += `</div>`; 3262 html += '</div>'; 3263 html += '</div>'; 3264 3265 // ââ Servers block âââââââââââââââââââââââââââââââââââââââââââââââââââ 3266 html += '<div class="admin-card" style="flex:0 0 auto;display:flex;flex-direction:column;">'; 3267 html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;margin-top:-4px;">'; 3268 html += '<h2 style="margin:0;padding:0;line-height:1;">Servers</h2>'; 3269 // Reuse the calendar +New pill: spinning plus, label fades in idea uses 3270 // the same `.cal-add-btn-text` rules, so styling stays consistent. 3271 html += '<button class="cal-add-btn cal-add-btn-text" id="cookbook-server-add" title="Add server" style="margin-left:auto;"><span class="cal-add-plus">+</span><span class="cal-add-label">Add</span></button>'; 3272 html += '</div>'; 3273 html += '<p class="memory-desc doclib-desc">Configure SSH servers, install Odysseus keys, choose model directories, and set the default server. Local is this machine.</p>'; 3274 html += '<div class="memory-toolbar cookbook-servers-toolbar" style="margin-top:4px;">'; 3275 html += `<div id="cookbook-servers-list">`; 3276 for (let i = 0; i < _es.servers.length; i++) { 3277 html += _serverEntryHtml(_es.servers[i], i, _es.defaultServer || '', false); 3278 } 3279 html += `</div>`; 3280 html += '</div>'; 3281 3282 html += '</div></div>'; 3283 3284 body.innerHTML = html; 3285 _wireTabEvents(body); 3286 3287 // Auto-init What Fits 3288 _hwfitInit(); 3289 _hwfitFetch(false, { allowNetwork: false }); 3290} 3291 3292// ââ Public API ââ 3293 3294import * as Modals from './modalManager.js'; 3295 3296let _rendered = false;
3297 3298let _closeGen = 0; 3299 3300// ESC while a Serve card is expanded should collapse just that card, not 3301// close the whole Cookbook modal. Capture-phase so we run before the 3302// modal manager's global ESC-to-close handler and can stop it. 3303if (typeof window !== 'undefined' && !window._cookbookServeEscBound) { 3304 window._cookbookServeEscBound = true; 3305 document.addEventListener('keydown', (e) => { 3306 if (e.key !== 'Escape') return; 3307 const modal = document.getElementById('cookbook-modal'); 3308 if (!modal || modal.classList.contains('hidden')) return; 3309 // Layer 1: a model row in the scan/download list is highlighted â 3310 // deselect it before doing anything else. 3311 const activeRow = modal.querySelector('.hwfit-row-active'); 3312 if (activeRow) { 3313 e.stopImmediatePropagation(); 3314 e.preventDefault(); 3315 activeRow.classList.remove('hwfit-row-active'); 3316 return; 3317 } 3318 const expanded = modal.querySelector('.memory-item.doclib-card-expanded'); 3319 if (!expanded) return; // nothing expanded â let the modal close normally 3320 e.stopImmediatePropagation(); 3321 e.preventDefault(); 3322 // Collapse the card (mirror the toggle-close path in cookbookServe.js). 3323 expanded.querySelector('.hwfit-serve-panel')?.remove(); 3324 expanded.classList.remove('doclib-card-expanded'); 3325 expanded.style.flexDirection = ''; 3326 expanded.style.alignItems = ''; 3327 const list = expanded.closest('.hwfit-cached-list') || document.getElementById('hwfit-cached-list'); 3328 if (list) { list.style.minHeight = ''; list.style.maxHeight = ''; } 3329 }, true); // capture 3330} 3331 3332export async function open(opts) { 3333 const modal = document.getElementById('cookbook-modal'); 3334 if (!modal) return; 3335 // Run any post-open intent (switch tab, prefill search, etc) after the 3336 // current render pass so the target elements exist. 3337 const _applyIntent = () => { 3338 if (!opts) return; 3339 if (opts.tab) { 3340 const t = modal.querySelector(`.cookbook-tab[data-backend="${opts.tab}"]`); 3341 if (t && !t.classList.contains('active')) t.click(); 3342 } 3343 if (opts.usecase) { 3344 const u = document.getElementById('hwfit-usecase'); 3345 if (u && u.value !== opts.usecase) { u.value = opts.usecase; u.dispatchEvent(new Event('change', { bubbles: true })); } 3346 } 3347 if (opts.serveSearch) { 3348 const s = document.getElementById('serve-search'); 3349 if (s) { s.value = opts.serveSearch; s.dispatchEvent(new Event('input', { bubbles: true })); } 3350 } 3351 }; 3352 // If minimized, restore in place â preserve all state 3353 if (Modals.isMinimized('cookbook-modal')) { 3354 Modals.restore('cookbook-modal'); 3355 _renderRunningTab(); 3356 setTimeout(_applyIntent, 0); 3357 return; 3358 } 3359 // If already visible, no-op (but still honour the intent) 3360 if (!modal.classList.contains('hidden')) { 3361 setTimeout(_applyIntent, 0); 3362 return; 3363 } 3364 _setCookbookOpening(true); 3365 try { 3366 // Invalidate any pending close() animation handlers so they won't re-hide us 3367 _closeGen++; 3368 // Clear any leftover inline styles from a previous swipe-dismiss or close animation 3369 const _content = modal.querySelector('.modal-content'); 3370 if (_content) { 3371 _content.classList.remove('modal-closing', 'sheet-ready', 'cookbook-modal-entering'); 3372 _content.style.transform = ''; 3373 _content.style.transition = ''; 3374 _content.style.animation = ''; 3375 _content.style.opacity = ''; 3376 } 3377 modal.style.display = ''; 3378 Modals.register('cookbook-modal', { 3379 railBtnId: 'rail-cookbook', 3380 sidebarBtnId: 'tool-cookbook-btn', 3381 closeFn: () => _doClose(), 3382 restoreFn: () => { _renderRunningTab(); }, 3383 }); 3384 _wireCookbookDrag(modal); 3385 await _syncFromServer(); 3386 // `_syncFromServer` lives in cookbookRunning.js and populates *its* _envState 3387 // (a different object reference than this module's), then mirrors the merged 3388 // state to localStorage. So ALWAYS hydrate our _envState from that mirror â 3389 // on a successful sync it holds the freshly-fetched servers; on failure it 3390 // holds the last-known state. Gating this on `!synced` left the render's 3391 // _envState empty whenever sync succeeded â "servers don't show". 3392 try { Object.assign(_envState, _readStoredEnvState()); } catch {} 3393 // Honour a user-set default server: always land on it when Cookbook opens, so 3394 // every dropdown (scan/download/serve/cache/deps) starts on the same machine. 3395 if (_envState.defaultServer) { 3396 const _dk = _envState.defaultServer; 3397 if (_dk === 'local') { 3398 _envState.remoteHost = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = ''; 3399 } else { 3400 const _ds = (_envState.servers || []).find(s => s.host === _dk); 3401 if (_ds) { _envState.remoteHost = _ds.host; _envState.env = _ds.env || 'none'; _envState.envPath = _ds.envPath || ''; _envState.platform = _ds.platform || ''; } 3402 } 3403 } 3404 // Re-render on every open AFTER sync so the freshly-fetched state (servers, 3405 // HF token, presets) is always reflected. Gating this to once-per-page used 3406 // to freeze a stale/empty servers list whenever the first sync raced or 3407 // returned before hydration â and since close/reopen doesn't reset the page, 3408 // only a full reload recovered it. Re-rendering is cheap and the in-progress 3409 // Running tab is rendered separately just below.
3410 // Guard the render passes: a single broken task card must not throw out of 3411 // open() and leave the modal stuck hidden (it has no catch, so the panel 3412 // would silently never appear). Show the window regardless; log and move on. 3413 try { _renderRecipes(); } catch (e) { console.error('[cookbook] renderRecipes failed', e); } 3414 _rendered = true; 3415 _clearCookbookNotif(); 3416 try { _renderRunningTab(); } catch (e) { console.error('[cookbook] renderRunningTab failed', e); } 3417 // Self-heal: revive any download tasks whose tmux session is still alive 3418 // but were persisted as done/error (covers the "restarted server while a 3419 // big multi-shard download was in flight" case â the task survived in 3420 // tmux, the cookbook just lost track of it). 3421 try { _selfHealStaleTasks({ oneShot: true }); } catch {} 3422 if (_content) { 3423 // Put the panel in its entering state before it becomes visible. On 3424 // mobile, showing first and adding the class a frame later can paint the 3425 // sheet at its final position, which makes the slide-up look like a snap. 3426 _content.classList.add('cookbook-modal-entering'); 3427 } 3428 modal.classList.remove('hidden'); 3429 if (_content) { 3430 void _content.offsetWidth; 3431 _content.addEventListener('animationend', () => { 3432 _content.classList.remove('cookbook-modal-entering'); 3433 }, { once: true }); 3434 } 3435 setTimeout(_applyIntent, 0); 3436 } finally { 3437 _setCookbookOpening(false); 3438 } 3439} 3440 3441// Make the Cookbook modal draggable (it had no drag wiring at all). We do 3442// NOT supply a fsClass fullscreen here â that would cover the whole viewport 3443// incl. the sidebar. Instead tileManager.js handles maximize/tiling (its 3444// safe-rect sits the window NEXT TO the sidebar), same as tasks/gallery/etc. 3445let _cookbookDragWired = false; 3446function _wireCookbookDrag(modal) { 3447 if (_cookbookDragWired || !modal) return; 3448 const content = modal.querySelector('.modal-content'); 3449 const header = modal.querySelector('.modal-header'); 3450 if (!content || !header) return; 3451 _cookbookDragWired = true; 3452 makeWindowDraggable(modal, { 3453 content, header, 3454 skipSelector: '.close-btn, .modal-close', 3455 // Keep only the "close to the edge" dock gesture for Cookbook. The 3456 // tileManager side snap is suppressed for this modal so there isn't a 3457 // second, tighter edge state fighting the working one. 3458 enableDock: true, 3459 }); 3460} 3461 3462function _doClose() { 3463 const modal = document.getElementById('cookbook-modal'); 3464 if (!modal) return; 3465 const content = modal.querySelector('.modal-content'); 3466 const myGen = ++_closeGen; 3467 if (content && !content.classList.contains('modal-closing')) { 3468 content.classList.add('modal-closing'); 3469 content.addEventListener('animationend', () => { 3470 if (myGen !== _closeGen) return; 3471 modal.classList.add('hidden'); 3472 content.classList.remove('modal-closing'); 3473 }, { once: true }); 3474 setTimeout(() => { 3475 if (myGen !== _closeGen) return; 3476 if (!modal.classList.contains('hidden')) { modal.classList.add('hidden'); content.classList.remove('modal-closing'); } 3477 }, 250); 3478 } else { 3479 modal.classList.add('hidden'); 3480 } 3481} 3482 3483export function close() { 3484 // Full close â fires registered closeFn, removes badge, unregisters 3485 if (Modals.isRegistered('cookbook-modal')) { 3486 Modals.close('cookbook-modal'); 3487 } else { 3488 _doClose(); 3489 } 3490} 3491 3492export function isVisible() { 3493 const modal = document.getElementById('cookbook-modal'); 3494 if (!modal) return false; 3495 if (Modals.isMinimized('cookbook-modal')) return false; 3496 return !modal.classList.contains('hidden'); 3497} 3498 3499let _sharedSyncInFlight = false; 3500let _sharedSyncLast = 0; 3501const SHARED_STATE_LEADER_KEY = 'odysseus-cookbook-shared-state-leader'; 3502const SHARED_STATE_LEADER_ID = `${Date.now()}-${Math.random().toString(36).slice(2)}`; 3503const SHARED_STATE_LEADER_TTL_MS = 12000; 3504 3505function _foregroundChatBusy() { 3506 try { 3507 return !!window.__odysseusChatBusy || Date.now() < (window.__odysseusChatBusyUntil || 0); 3508 } catch (_) { 3509 return false; 3510 } 3511} 3512 3513function _claimSharedStateLeader() { 3514 if (document.visibilityState !== 'visible') return false; 3515 const now = Date.now(); 3516 try { 3517 const raw = localStorage.getItem(SHARED_STATE_LEADER_KEY); 3518 const current = raw ? JSON.parse(raw) : null; 3519 if ( 3520 !current 3521 || !current.id 3522 || current.id === SHARED_STATE_LEADER_ID 3523 || now - Number(current.ts || 0) > SHARED_STATE_LEADER_TTL_MS 3524 ) { 3525 localStorage.setItem(SHARED_STATE_LEADER_KEY, JSON.stringify({ id: SHARED_STATE_LEADER_ID, ts: now })); 3526 return true; 3527 } 3528 return current.id === SHARED_STATE_LEADER_ID; 3529 } catch (_) { 3530 return true; 3531 } 3532} 3533 3534function _canRefreshSharedCookbookState() { 3535 if (!isVisible() || _sharedSyncInFlight) return false;
3536 if (document.visibilityState !== 'visible') return false; 3537 if (_foregroundChatBusy()) return false; 3538 return _claimSharedStateLeader(); 3539} 3540 3541async function _refreshSharedCookbookState(reason = '') { 3542 if (!_canRefreshSharedCookbookState()) return; 3543 const now = Date.now(); 3544 if (now - _sharedSyncLast < 1500) return; 3545 _sharedSyncInFlight = true; 3546 _sharedSyncLast = now; 3547 try { 3548 const ok = await _syncFromServer(); 3549 if (!ok) return; 3550 try { Object.assign(_envState, _readStoredEnvState()); } catch {} 3551 const modal = document.getElementById('cookbook-modal'); 3552 const activeTab = modal?.querySelector('.cookbook-tab.active')?.dataset?.backend || ''; 3553 if (activeTab === 'Running') { 3554 _renderRunningTab(); 3555 } else if (activeTab === 'Settings') { 3556 const active = document.activeElement; 3557 const editingSettings = active && active.closest && active.closest('.cookbook-settings-stack'); 3558 if (!editingSettings) { 3559 _renderRecipes(); 3560 const tab = document.querySelector('#cookbook-modal .cookbook-tab[data-backend="Settings"]'); 3561 if (tab) tab.click(); 3562 } 3563 } 3564 } catch (e) { 3565 console.warn('[cookbook] shared state refresh failed', reason, e); 3566 } finally { 3567 _sharedSyncInFlight = false; 3568 } 3569} 3570 3571document.addEventListener('cookbook:state-synced', () => { 3572 try { Object.assign(_envState, _readStoredEnvState()); } catch {} 3573 if (isVisible()) { 3574 const activeTab = document.querySelector('#cookbook-modal .cookbook-tab.active')?.dataset?.backend || ''; 3575 if (activeTab === 'Running') _renderRunningTab(); 3576 else if (activeTab === 'Serve') _rerenderCachedModels(); 3577 } 3578}); 3579 3580window.addEventListener('focus', () => { _refreshSharedCookbookState('focus'); }); 3581document.addEventListener('visibilitychange', () => { 3582 if (document.visibilityState === 'visible') _refreshSharedCookbookState('visible'); 3583}); 3584setInterval(() => { 3585 if (!isVisible()) return; 3586 const activeTab = document.querySelector('#cookbook-modal .cookbook-tab.active')?.dataset?.backend || ''; 3587 if (activeTab === 'Running') _refreshSharedCookbookState('active-poll'); 3588}, 5000); 3589 3590// Close button 3591document.addEventListener('DOMContentLoaded', () => { 3592 const closeBtn = document.getElementById('close-cookbook-modal'); 3593 if (closeBtn) closeBtn.addEventListener('click', close); 3594 3595 const modal = document.getElementById('cookbook-modal'); 3596 if (modal) { 3597 modal.addEventListener('click', (e) => { 3598 if (uiModule.isTouchInsideModal()) return; 3599 if (e.target === modal) close(); 3600 }); 3601 } 3602}); 3603 3604// ââ Initialize sub-modules ââ 3605 3606// Shared SSH-port resolver â sub-modules use this via the shared bundle 3607// instead of redefining it. Kept here as the single source of truth. 3608function _sshPrefix(port) { 3609 return port && port !== '22' ? `-p ${port} ` : ''; 3610} 3611 3612const shared = { 3613 _envState, 3614 _sshCmd, 3615 _getPort, 3616 _sshPrefix, 3617 _serverByVal, 3618 _serverKey, 3619 _selectedServer, 3620 _getPlatform, 3621 _isWindows, 3622 _isMetal, 3623 _buildEnvPrefix, 3624 _buildServeCmd, 3625 _shellQuote, 3626 _psQuote, 3627 _detectBackend, 3628 _detectToolParser, 3629 _detectModelOptimizations, 3630 _loadPresets, 3631 _savePresets, 3632 _copyText, 3633 _persistEnvState, 3634 _refreshDependencies: _fetchDependencies, 3635 _getGpuToggleTotal: () => _gpuToggleTotal, 3636 modelLogo, 3637 esc, 3638}; 3639 3640// Init running module (adds task management, auto-fix, launch, background monitor) 3641initRunning({ 3642 ...shared, 3643}); 3644 3645// Init download module (adds SSE, panel rendering, download commands) 3646initDownload({ 3647 ...shared, 3648 _addTask, 3649 _renderRunningTab, 3650 _loadTasks, 3651 _saveTasks, 3652}); 3653 3654// Init serve module (adds cached models, serve panels, launch) 3655initServe({ 3656 ...shared, 3657 _launchServeTask, 3658 _retryDownload, 3659 _nextAvailablePort, 3660}); 3661 3662// ââ Re-exports for cookbook-diagnosis.js and cookbook-hwfit.js ââ 3663// These modules import from cookbook.js, so we re-export what they need 3664 3665export { 3666 _loadTasks, _saveTasks, _addTask, _removeTask, 3667 _tmuxCmd, _renderRunningTab, 3668 _launchServeTask, _serveAutoFix, _serveAutoRetry, _serveAutoRetryReplace, _serveAutoRetryRemove, 3669 _startBackgroundMonitor, 3670 _setPanelField, _setPanelCheckbox, 3671 _wirePanelEvents, _runPanelCmd, _runModelDownload, _buildDownloadCmd, 3672 _isLocalEntry, 3673}; 3674
3675const cookbookModule = { open, close, isVisible, startBackgroundMonitor: _startBackgroundMonitor }; 3676 3677export default cookbookModule;
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.