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 { _hwfitCache, _hwfitDebounce, _hwfitFetch, _hwfitInit, _hwfitRenderList, _hwfitRenderHw, _renderGpuToggles, _expandModelRow, _fitColors, _hwfitColumns, _cachedModelIds, _gpuToggleTotal, _resetGpuToggleState } from './cookbook-hwfit.js'; 12 13// Sub-modules 14import { 15 initRunning, 16 _loadTasks, _saveTasks, _addTask, _removeTask, 17 _tmuxCmd, _renderRunningTab, _clearCookbookNotif, 18 _launchServeTask, _serveAutoFix, _serveAutoRetry, _serveAutoRetryReplace, _serveAutoRetryRemove, 19 _startBackgroundMonitor, _syncFromServer, 20 _retryDownload, _nextAvailablePort, _processQueue, 21 _selfHealStaleTasks, 22} from './cookbookRunning.js'; 23 24import { 25 initDownload, 26 _setPanelField, _setPanelCheckbox, 27 _wirePanelEvents, _runPanelCmd, _runModelDownload, _buildDownloadCmd, 28} from './cookbookDownload.js'; 29 30import { 31 initServe, 32 _fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel, 33} from './cookbookServe.js'; 34 35const STORAGE_KEY = 'cookbook-presets'; 36const LAST_STATE_KEY = 'cookbook-last-state'; 37const SERVE_STATE_KEY = 'cookbook-serve-state'; 38 39// Global, once: tag chip rows (.doclib-lang-chips) scroll horizontally on mobile. 40// Stop their touch events (capture phase, before any ancestor sees them) so a 41// sideways tag scroll never triggers a swipe-to-change-tab / swipe-dismiss 42// gesture in ANY modal (cookbook, document library, etc.). We don't preventDefault, 43// so the browser's native horizontal scroll of the chips still works. 44if (typeof window !== 'undefined' && !window._tagScrollGuardWired) { 45 window._tagScrollGuardWired = true; 46 ['touchstart', 'touchmove'].forEach(evt => { 47 document.addEventListener(evt, (e) => { 48 const t = e.target; 49 if (t && t.closest && t.closest('.doclib-lang-chips')) e.stopPropagation(); 50 }, true); 51 }); 52} 53 54// Radio-style check marking which model directory is a server's download target. 55// OFF = hollow circle (pickable); ON = checked circle (accent-tinted via CSS). 56export 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>'; 57export 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>'; 58 59// Monochrome platform glyphs (currentColor) for a server's OS tag: a penguin for 60// Linux, the four-pane logo for Windows, an Android robot for Termux/Android. 61function _platformIcon(platform) { 62 const k = (platform || '').toLowerCase(); 63 if (k === 'windows') { 64 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>'; 65 } 66 if (k === 'termux' || k === 'android') { 67 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>'; 68 } 69 if (k === 'linux' || k === 'termux-linux') { 70 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>'; 71 } 72 return ''; 73} 74 75export let _envState = { env: 'none', envPath: '', hfToken: '', hfTokenConfigured: false, hfTokenMasked: '', gpus: '', remoteHost: '', servers: [], modelPaths: [], platform: '', defaultServer: '' }; 76let _lastCacheHostVal = null; 77let _cookbookOpeningSpinners = []; 78export function _lastCacheHost() { return _lastCacheHostVal; } 79export function _setLastCacheHost(v) { _lastCacheHostVal = v; } 80 81function _setCookbookOpening(on) { 82 // Sidebar (tool-cookbook-btn) deliberately excluded â the inline 83 // whirlpool on the sidebar row read as "the click didn't register" 84 // rather than "loading", which made users (rightly) think clicks 85 // were being eaten. Keep only the icon-rail spinner since the 86 // rail is narrow enough that an obvious loading state still helps. 87 const targets = [ 88 document.getElementById('rail-cookbook'),
89 ].filter(Boolean); 90 if (!on) { 91 _cookbookOpeningSpinners.forEach(({ spinner, wrap, target }) => { 92 try { spinner?.stop?.(); } catch {} 93 try { wrap?.remove?.(); } catch {} 94 target?.classList?.remove('cookbook-opening'); 95 }); 96 _cookbookOpeningSpinners = []; 97 return; 98 } 99 if (_cookbookOpeningSpinners.length) return; 100 targets.forEach(target => { 101 const spinner = spinnerModule.create('', 'clean', 'whirlpool'); 102 spinner._wpSize = target.id === 'rail-cookbook' ? 12 : 13; 103 const wrap = document.createElement('span'); 104 wrap.className = 'cookbook-open-loading'; 105 wrap.appendChild(spinner.createElement()); 106 target.appendChild(wrap); 107 target.classList.add('cookbook-opening'); 108 spinner.start(); 109 _cookbookOpeningSpinners.push({ spinner, wrap, target }); 110 }); 111} 112 113/** Build server <option> HTML from _envState.servers. excludeLocal skips local-only entries. */ 114// True for the local server entry (empty / "local" / "localhost" host). 115function _isLocalEntry(s) { return !s || !s.host || s.host === 'local' || s.host.toLowerCase() === 'localhost'; } 116 117// Resolve a dropdown option value to a server entry. Option values are the 118// stable HOST string ('local' for the local box) â NOT array indices â because 119// `_envState.servers` gets deduped/reordered, which made index-based selection 120// silently resolve to the wrong (or local) server. Accepts a numeric index too 121// for backwards-compat with any stale value. 122function _serverByVal(val) { 123 if (val == null || val === 'local' || val === '') return null; 124 let s = _envState.servers.find(x => x.host === val); 125 if (!s && /^\d+$/.test(String(val))) s = _envState.servers[parseInt(val)]; 126 return s || null; 127} 128 129function _buildServerOpts(excludeLocal = false) { 130 // The local server is ALWAYS represented by the synthetic value="local" option 131 // (showing its custom name from the "server name" feature). We must therefore 132 // skip that same entry in the loop below â otherwise it appeared twice. 133 const _localIdx = _envState.servers.findIndex(_isLocalEntry); 134 const _localSrv = _localIdx >= 0 ? _envState.servers[_localIdx] : null; 135 const _localLabel = (_localSrv && _localSrv.name) ? _localSrv.name : 'Local'; 136 let html = `<option value="local"${!_envState.remoteHost ? ' selected' : ''}>${esc(_localLabel)}</option>`; 137 for (let i = 0; i < _envState.servers.length; i++) { 138 const s = _envState.servers[i]; 139 if (i === _localIdx) continue; // already the synthetic "local" option 140 if (excludeLocal && _isLocalEntry(s)) continue; 141 const label = s.name || s.host || `Server ${i + 1}`; 142 const selected = _envState.remoteHost === s.host ? ' selected' : ''; 143 html += `<option value="${esc(s.host)}"${selected}>${esc(label)}</option>`; 144 } 145 return html; 146} 147 148/** Wrap a command in SSH for a remote host, with proper single-quote escaping. */ 149export function _sshCmd(host, cmd, port) { 150 const portFlag = port && port !== '22' ? `-p ${port} ` : ''; 151 return `ssh ${portFlag}${host} '${cmd.replace(/'/g, "'\\''")}'`; 152} 153 154/** Get SSH port for a given host (or task object) */ 155function _getPort(hostOrTask) { 156 if (!hostOrTask) return ''; 157 if (typeof hostOrTask === 'object') return hostOrTask.sshPort || _getPort(hostOrTask.remoteHost); 158 const srv = _envState.servers.find(s => s.host === hostOrTask); 159 return srv?.port || ''; 160} 161 162/** Get platform for a given host (or task object). Returns 'windows', 'termux', 'linux', or '' */ 163export function _getPlatform(hostOrTask) { 164 const isWinBrowser = (window.navigator.userAgent || window.navigator.platform || '').toLowerCase().includes('win'); 165 if (!hostOrTask || hostOrTask === 'local') { 166 return _envState.platform || (isWinBrowser ? 'windows' : ''); 167 } 168 if (typeof hostOrTask === 'object') { 169 const h = hostOrTask.remoteHost; 170 if (!h || h === 'local') { 171 return hostOrTask.platform || _envState.platform || (isWinBrowser ? 'windows' : ''); 172 } 173 return hostOrTask.platform || _getPlatform(h); 174 } 175 const srv = _envState.servers.find(s => s.host === hostOrTask); 176 return srv?.platform || ''; 177} 178 179/** Check if the current active server is Windows */ 180export function _isWindows(hostOrTask) { 181 return _getPlatform(hostOrTask) === 'windows'; 182} 183 184/** Check if the detected (local) hardware is Apple Silicon / Metal. Keys off the
185 * hardware probe's backend rather than a platform string, since a local Mac 186 * reports no platform but does report backend: "metal". */ 187export function _isMetal() { 188 return ['metal', 'mps', 'apple'].includes(String(_hwfitCache?.system?.backend || '').toLowerCase()); 189} 190 191const 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 %}`; 192 193function _isGemma4ThinkingModel(modelName) { 194 const n = (modelName || '').toLowerCase(); 195 return n.includes('gemma-4') || n.includes('gemma4'); 196} 197 198function _gemma4ThinkingChatTemplateArg(modelName) { 199 return _isGemma4ThinkingModel(modelName) 200 ? _shellQuote(GEMMA4_THINKING_CHAT_TEMPLATE) 201 : ''; 202} 203 204/** Detect model-specific vLLM optimizations */ 205function _detectModelOptimizations(modelName) { 206 const n = (modelName || '').toLowerCase(); 207 const opts = { envVars: [], flags: [], tips: [] }; 208 209 // Qwen3.5 MoE models 210 if (n.includes('qwen3.5') || n.includes('qwen3-') && (n.includes('a10b') || n.includes('a22b') || n.includes('a3b'))) { 211 opts.envVars.push('VLLM_USE_DEEP_GEMM=0', 'VLLM_USE_FLASHINFER_MOE_FP16=1', 'VLLM_USE_FLASHINFER_SAMPLER=0', 'OMP_NUM_THREADS=4'); 212 opts.flags.push('--enable-expert-parallel', '--reasoning-parser qwen3'); 213 opts.tips.push('MoE optimizations: expert parallel + flashinfer MoE kernels'); 214 } 215 // Qwen3 MoE (non-3.5) 216 else if (n.includes('qwen3') && (n.includes('a10b') || n.includes('a22b') || n.includes('a3b'))) { 217 opts.envVars.push('VLLM_USE_DEEP_GEMM=0', 'VLLM_USE_FLASHINFER_MOE_FP16=1'); 218 opts.flags.push('--enable-expert-parallel', '--reasoning-parser qwen3'); 219 opts.tips.push('MoE optimizations: expert parallel'); 220 } 221 // DeepSeek MoE 222 else if (n.includes('deepseek') && (n.includes('v3') || n.includes('r1'))) { 223 opts.flags.push('--enable-expert-parallel'); 224 opts.tips.push('MoE expert parallel for DeepSeek'); 225 } 226 // Speculative decoding â pick the right MTP method per model family. 227 // opts.spec.{method,tokens} seed the UI dropdown/input; the actual flag is 228 // assembled by the command builder so the user can edit before launching. 229 let specDefault = null; 230 if (n.includes('qwen3-next') || (n.includes('qwen3.5') && (n.includes('a10b') || n.includes('a22b')))) { 231 specDefault = { method: 'qwen3_next_mtp', tokens: 2 }; 232 } else if ( 233 (n.includes('deepseek') && (n.includes('v3') || n.includes('v3.1') || n.includes('r1'))) || 234 n.includes('kimi-k2') || n.includes('kimi_k2') || 235 n.includes('glm-4.5') || n.includes('glm4.5') || 236 n.includes('minimax-m1') || n.includes('minimax_m1') 237 ) { 238 specDefault = { method: 'mtp', tokens: 3 }; 239 } 240 if (specDefault) { 241 opts.spec = specDefault; 242 opts.flags.push(`--speculative-config '{"method":"${specDefault.method}","num_speculative_tokens":${specDefault.tokens}}'`); 243 opts.tips.push(`Speculative decoding (${specDefault.method}, ${specDefault.tokens} tokens): ~1.5-2x faster generation`); 244 } 245 246 return opts; 247} 248 249/** Detect the right vLLM tool-call-parser based on model name. 250 * Qwen tool-call formats split by generation: 251 * - Qwen3-Coder â qwen3_coder (XML <tool_call> with named params) 252 * - Qwen3 (non-coder) â qwen3_xml (reasoning/instruct, XML wrapper) 253 * - Qwen2.5 / Qwen2 / 1.5 â hermes (Qwen2.5 was trained on Hermes format) 254 * Catching "qwen" first and labelling everything qwen3_xml breaks tool 255 * calls on the Qwen2.5 line (the model emits hermes-style which the 256 * qwen3_xml parser doesn't recognise, so the call leaks through as text). 257 */ 258export function _detectToolParser(modelName) { 259 const n = (modelName || '').toLowerCase(); 260 if (n.includes('qwen3') && n.includes('coder')) return 'qwen3_coder'; 261 if (n.includes('qwen3')) return 'qwen3_xml'; 262 if (n.includes('qwen')) return 'hermes'; // Qwen2.5 / Qwen2 / Qwen1.5 263 if (n.includes('llama-4') || n.includes('llama4')) return 'llama4_json'; 264 if (n.includes('llama') || n.includes('nemotron')) return 'llama3_json'; 265 if (n.includes('mistral') || n.includes('mixtral')) return 'mistral'; 266 if (n.includes('deepseek-v3')) return 'deepseek_v3';
267 if (n.includes('deepseek')) return 'deepseek_v3'; 268 if (n.includes('minimax') && n.includes('m2')) return 'minimax_m2'; 269 if (n.includes('minimax')) return 'minimax'; 270 if (n.includes('gemma')) return 'pythonic'; 271 if (n.includes('glm-4')) return 'glm45'; 272 if (n.includes('internlm')) return 'internlm'; 273 if (n.includes('granite')) return 'granite'; 274 return 'hermes'; // default fallback 275} 276 277// ââ Backend detection ââ 278 279export function _detectBackend(model) { 280 if (model?.backend === 'ollama' || model?.is_ollama) { 281 return { backend: 'ollama', label: 'Ollama' }; 282 } 283 const q = (model.quant || '').toUpperCase(); 284 const sysBackend = String(_hwfitCache?.system?.backend || '').toLowerCase(); 285 const isRocm = sysBackend === 'rocm'; 286 const isAppleSilicon = ['metal', 'mps', 'apple'].includes(sysBackend); 287 const _nm = `${model.repo_id || ''} ${model.path || ''} ${model.name || ''}`.toLowerCase(); 288 if (/\bmlx\b|mlx-|_mlx/i.test(_nm) || q.startsWith('MLX')) { 289 return { backend: 'unsupported', label: 'Unsupported' }; 290 } 291 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); 292 const isGgufLike = model.is_gguf || /^Q[2-8]/.test(q) || /^IQ/.test(q) || q === 'GGUF' || _nm.includes('gguf'); 293 294 // Image gen models â diffusers 295 if (model.is_image_gen || model.is_diffusion || model._tag === 'image') { 296 return { backend: 'diffusers', label: 'Diffusers' }; 297 } 298 299 // AWQ / GPTQ / FP8 are safetensors GPU-serving formats. Never route them 300 // through llama.cpp/Ollama just because the host is Mac/Windows; those engines 301 // need GGUF. The UI will warn/block on Metal where vLLM/SGLang aren't viable.
302 if (isAwqLike) { 303 return { backend: 'vllm', label: 'vLLM' }; 304 } 305 306 // GGUF â llama.cpp/Ollama-compatible. 307 if (isGgufLike) { 308 return { backend: 'llamacpp', label: 'llama.cpp' }; 309 } 310 311 // Windows â default to llama.cpp (no vLLM support on Windows) 312 if (_isWindows()) { 313 return { backend: 'llamacpp', label: 'llama.cpp' }; 314 } 315 316 // Apple Silicon (Metal) â llama.cpp (GGUF). vLLM/SGLang are CUDA/ROCm-only and 317 // don't run on macOS; vLLM-native quantized models are already filtered out 318 // of metal Cookbook results, so llama.cpp is always the right engine here. 319 if (['metal', 'mps', 'apple'].includes(sysBackend)) { 320 return { backend: 'llamacpp', label: 'llama.cpp' }; 321 } 322 323 // ROCm/AMD machines should not blindly default HF safetensors models to 324 // vLLM. SGLang is the safer OpenAI-compatible default for plain HF text 325 // repos there; llama.cpp still wins above whenever the model is GGUF. 326 if (isRocm) { 327 return { backend: 'sglang', label: 'SGLang' }; 328 } 329 330 // Unquantized / BF16 / F16 â vLLM 331 return { backend: 'vllm', label: 'vLLM' }; 332} 333 334// ââ Command builders ââ 335 336export function _shellQuote(value) { 337 return "'" + String(value ?? '').replace(/'/g, "'\\''") + "'"; 338} 339 340export function _psQuote(value) { 341 return "'" + String(value ?? '').replace(/'/g, "''") + "'"; 342} 343 344export function _buildEnvPrefix() { 345 if (_isWindows()) return _buildEnvPrefixWindows(); 346 let parts = []; 347 if (_envState.env === 'venv' && _envState.envPath) { 348 const p = _envState.envPath; 349 const activate = p.endsWith('/bin/activate') ? p : p + '/bin/activate'; 350 parts.push('source ' + _shellQuote(activate)); 351 } else if (_envState.env === 'conda' && _envState.envPath) { 352 parts.push('eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath)); 353 } 354 let envVars = []; 355 if (_envState.hfToken) envVars.push('export HF_TOKEN=' + _shellQuote(_envState.hfToken)); 356 if (_envState.gpus) envVars.push('export CUDA_VISIBLE_DEVICES=' + _shellQuote(_envState.gpus)); 357 if (envVars.length) parts.push(envVars.join(' && ')); 358 if (parts.length === 0) return ''; 359 return parts.join(' && ') + ' &&'; 360} 361 362function _buildEnvPrefixWindows() { 363 let parts = []; 364 if (_envState.env === 'venv' && _envState.envPath) { 365 const p = _envState.envPath; 366 const activate = p.endsWith('\\Scripts\\Activate.ps1') ? p : p + '\\Scripts\\Activate.ps1'; 367 parts.push('& ' + _psQuote(activate)); 368 } else if (_envState.env === 'conda' && _envState.envPath) { 369 parts.push('conda activate ' + _psQuote(_envState.envPath)); 370 } 371 if (_envState.hfToken) parts.push('$env:HF_TOKEN=' + _psQuote(_envState.hfToken)); 372 if (_envState.gpus) parts.push('$env:CUDA_VISIBLE_DEVICES=' + _psQuote(_envState.gpus)); 373 if (parts.length === 0) return ''; 374 return parts.join('; ') + ';'; 375} 376 377export function _buildServeCmd(f, modelName, backend) { 378 // When a venv is configured on the chosen server, use the venv's binaries 379 // by absolute path. Bare `vllm` / `python3` relies on PATH, and SSH non- 380 // interactive sessions often leave a user-site install (~/.local/bin/vllm) 381 // ahead of the venv's bin, so the WRONG vllm gets launched even with the 382 // venv activated. Absolute path sidesteps the whole PATH question. 383 const _isVenv = _envState.env === 'venv' && _envState.envPath; 384 const _venvBin = _isVenv ? (_envState.envPath.replace(/\/+$/, '') + '/bin/') : ''; 385 const _vllmBin = _venvBin ? `${_venvBin}vllm` : 'vllm'; 386 const _py3Bin = _venvBin ? `${_venvBin}python3` : 'python3'; 387 let cmd = ''; 388 if (backend === 'vllm') { 389 const gpuId = f.gpu_id?.trim() || ''; 390 if (gpuId) cmd += `CUDA_VISIBLE_DEVICES=${gpuId} `; 391 if (f.moe_env) { 392 const _opts = _detectModelOptimizations(modelName); 393 if (_opts.envVars.length) cmd += _opts.envVars.join(' ') + ' '; 394 } 395 // Pinned attention backend (Attention field). Empty = let vLLM pick. 396 const _attn = (f.vllm_attn_backend ?? '').toString().trim(); 397 if (_attn) cmd += `VLLM_ATTENTION_BACKEND=${_attn} `; 398 // Free-text "Env" field â verbatim KEY=VAL pairs (space-separated). 399 // Collapse any pasted newlines/tabs so the backend allowlist (which 400 // rejects \n / \r) doesn't trip on a multi-line paste from a model card. 401 const _extraEnv = (f.extra_env ?? '').toString().replace(/\s+/g, ' ').trim(); 402 if (_extraEnv) cmd += _extraEnv + ' '; 403 cmd += `${_vllmBin} serve ${modelName}
403 --host 0.0.0.0 --port ${f.port || '8000'}`; 404 const _gemma4ChatTemplate = _gemma4ThinkingChatTemplateArg(modelName); 405 if (_gemma4ChatTemplate) cmd += ` --chat-template ${_gemma4ChatTemplate}`; 406 cmd += ` --tensor-parallel-size ${f.tp || '1'}`; 407 cmd += ` --max-model-len ${f.ctx || '8192'}`; 408 cmd += ` --gpu-memory-utilization ${f.gpu_mem || '0.90'}`; 409 if (f.swap && f.swap !== '0') cmd += ` --swap-space ${f.swap}`; 410 cmd += ` --dtype ${f.dtype || 'auto'}`; 411 const _kv = (f.vllm_kv_cache_dtype ?? '').toString().trim(); 412 if (_kv === 'fp8') cmd += ' --kv-cache-dtype fp8'; 413 if (f.max_seqs && f.max_seqs.toString().trim()) cmd += ` --max-num-seqs ${f.max_seqs.toString().trim()}`; 414 if (f.enforce_eager) cmd += ' --enforce-eager'; 415 if (f.trust_remote) cmd += ' --trust-remote-code'; 416 if (f.prefix_cache) cmd += ' --enable-prefix-caching'; 417 if (f.auto_tool) cmd += ` --enable-auto-tool-choice --tool-call-parser ${_detectToolParser(modelName)}`; 418 if (f.expert_parallel) cmd += ' --enable-expert-parallel'; 419 if (f.reasoning_parser) { 420 const rp = typeof f.reasoning_parser === 'string' && f.reasoning_parser !== 'true' 421 ? f.reasoning_parser : (f._reasoning_parser_value || 'qwen3'); 422 cmd += ` --reasoning-parser ${rp}`; 423 } 424 if (f.speculative) { 425 const _specMethod = (f.spec_method || 'mtp').trim() || 'mtp'; 426 const _specToksRaw = parseInt(f.spec_tokens, 10); 427 const _specToks = (Number.isFinite(_specToksRaw) && _specToksRaw > 0) ? _specToksRaw : 3; 428 cmd += ` --speculative-config '{"method":"${_specMethod}","num_speculative_tokens":${_specToks}}'`; 429 } 430 } else if (backend === 'sglang') { 431 const gpuId = f.gpu_id?.trim() || ''; 432 if (gpuId) cmd += `CUDA_VISIBLE_DEVICES=${gpuId} `; 433 const _extraEnv = (f.extra_env ?? '').toString().replace(/\s+/g, ' ').trim(); 434 if (_extraEnv) cmd += _extraEnv + ' '; 435 cmd += `${_py3Bin} -m sglang.launch_server --model-path ${modelName}
435 --host 0.0.0.0 --port ${f.port || '30000'}`; 436 const _gemma4ChatTemplate = _gemma4ThinkingChatTemplateArg(modelName); 437 if (_gemma4ChatTemplate) cmd += ` --chat-template ${_gemma4ChatTemplate}`; 438 if (f.tp && f.tp !== '1') cmd += ` --tp ${f.tp}`; 439 if (f.ctx) cmd += ` --context-length ${f.ctx}`; 440 if (f.gpu_mem && f.gpu_mem !== '0.90') cmd += ` --mem-fraction-static ${f.gpu_mem}`; 441 if (f.dtype && f.dtype !== 'auto') cmd += ` --dtype ${f.dtype}`; 442 if (f.max_seqs && f.max_seqs.toString().trim()) cmd += ` --max-running-requests ${f.max_seqs.toString().trim()}`; 443 if (f.trust_remote) cmd += ' --trust-remote-code'; 444 if (!f.prefix_cache) cmd += ' --disable-radix-cache'; 445 if (f.enforce_eager) cmd += ' --disable-cuda-graph'; 446 } else if (backend === 'llamacpp') { 447 const ggufPath = f._gguf_path || 'model.gguf'; 448 const gpuId = f.gpu_id?.trim() || ''; 449 const py = _isWindows() ? 'python' : 'python3'; 450 // CPU-only serve (-ngl 0): drop the GPU-only flags, otherwise the command 451 // mixes "zero GPU layers" with CUDA unified-memory + flash-attn and fails to 452 // start (issue #1291). Only affects the ngl=0 path; GPU serving is unchanged. 453 const _cpuOnly = String(f.ngl).trim() === '0'; 454 const lcPrefix = (() => { 455 let p = ''; 456 if (f.unified_mem && !_cpuOnly && !_isWindows()) p += `GGML_CUDA_ENABLE_UNIFIED_MEMORY=1 `; 457 if (gpuId && !_isWindows()) p += `CUDA_VISIBLE_DEVICES=${gpuId} `; 458 return p; 459 })(); 460 if (f.unified_mem && !_cpuOnly && _isWindows()) cmd += `$env:GGML_CUDA_ENABLE_UNIFIED_MEMORY="1"; `; 461 if (gpuId && _isWindows()) cmd += `$env:CUDA_VISIBLE_DEVICES="${gpuId}"; `; 462 if (!_isWindows()) { 463 // Resolve GGUF path once, fail loudly if nothing matched (prevents 464 // `--model ""` which causes confusing downstream errors). 465 cmd += `MODEL_FILE=${ggufPath} && { [ -n "$MODEL_FILE" ] && [ -f "$MODEL_FILE" ]; } || { echo "ERROR: No GGUF found on this host. Either download the model here, or switch to the server where it's cached."; exit 1; } && `; 466 } 467 const modelArg = _isWindows() ? `"${ggufPath}"` : `"$MODEL_FILE"`; 468 // Prefer the native llama-server binary on Linux â its minja templating 469 // renders modern GGUF chat templates that the Python bindings' Jinja2 470 // rejects (do_tojson ensure_ascii). Fall back to llama_cpp.server. 471 // Don't suppress stderr â surface real errors (missing file, lib, OOM). 472 // Optional perf/fit flags from a hardware profile (see services/hwfit/ 473 // profiles.py). n_cpu_moe offloads MoE expert layers to CPU when the model 474 // is bigger than VRAM; flash-attn + a quantized KV cache cut KV memory and 475 // speed things up. Only emitted when set, so manual/older flows are unchanged. 476 const _ncm = (f.n_cpu_moe ?? '').toString().trim(); 477 const _kv = (f.cache_type ?? '').toString().trim(); 478 const _llamaNum = (v) => { 479 const s = String(v || '').trim(); 480 return /^\d+$/.test(s) ? s : ''; 481 }; 482 const _llamaCsv = (v) => { 483 const s = String(v || '').replace(/\s+/g, ''); 484 return /^\d+(?:\.\d+)?(?:,\d+(?:\.\d+)?)*$/.test(s) ? s : ''; 485 }; 486 let _lcExtra = ''; 487 let _lcpExtra = ''; 488 if (_ncm !== '' && Number(_ncm) > 0) { 489 _lcExtra += ` --n-cpu-moe ${_ncm}`; 490 _lcpExtra += ` --n_cpu_moe ${_ncm}`; // llama-cpp-python uses underscores 491 } 492 if (f.flash_attn && !_cpuOnly) { 493 _lcExtra += ' --flash-attn on'; 494 _lcpExtra += ' --flash_attn true'; 495 } 496 if (_kv) { 497 _lcExtra += ` --cache-type-k ${_kv} --cache-type-v ${_kv}`; 498 // llama-cpp-python exposes these as type_k/type_v; pass through best-effort. 499 _lcpExtra += ` --type_k ${_kv} --type_v ${_kv}`; 500 } 501 const _llamaFit = String(f.llama_fit || '').trim(); 502 if (['on', 'off'].includes(_llamaFit)) _lcExtra += ` --fit ${_llamaFit}`; 503 if (f.llama_no_mmap) _lcExtra += ' --no-mmap'; 504 if (f.llama_no_warmup) _lcExtra += ' --no-warmup'; 505 const _llamaSplitMode = String(f.llama_split_mode || '').trim(); 506 if (['none', 'layer', 'row', 'tensor'].includes(_llamaSplitMode)) _lcExtra += ` --split-mode ${_llamaSplitMode}`; 507 const _llamaTensorSplit = _llamaCsv(f.llama_tensor_split); 508 if (_llamaTensorSplit) _lcExtra += ` --tensor-split ${_llamaTensorSplit}`; 509 const _llamaMainGpu = _llamaNum(f.llama_main_gpu); 510 if (_llamaMainGpu) _lcExtra += ` --main-gpu ${_llamaMainGpu}`; 511 const _llamaParallel = _llamaNum(f.llama_parallel); 512 if (_llamaParallel) _lcExtra += ` --parallel ${_llamaParallel}`; 513 const _llamaBatch = _llamaNum(f.llama_batch_size); 514 if (_llamaBatch) _lcExtra += ` --batch-size ${_llamaBatch}`; 515 const _llamaUBatch = _llamaNum(f.llama_ubatch_size); 516 if (_llamaUBatch) _lcExtra += ` --ubatch-size ${_llamaUBatch}`; 517 if (f.llama_speculative_mtp) { 518 const specTokens = parseInt(f.llama_spec_tokens, 10); 519 const specN = Number.isFinite(specTokens) && specTokens > 0 ? specTokens : 3; 520 _lcExtra += ` --spec-type draft-mtp --spec-draft-n-max ${specN}`; 521 } 522 // Vision: serve the multimodal projector so the model can read images. The 523 // mmproj path is resolved at runtime (find mmproj-*.gguf next to the model); 524 // only emitted when the Vision toggle is on AND a projector was found. 525 if (f.vision && f._mmproj_path) { 526 _lcExtra += ` --mmproj "${f._mmproj_path}" --image-max-tokens 1024`; 527 // llama-cpp-python takes the projector via --clip_model_path. 528 _lcpExtra += ` --clip_model_path "${f._mmproj_path}"`; 529 } 530 const _lcpServer = `${lcPrefix}${py} -m llama_cpp.server --model ${modelArg}
530 --host 0.0.0.0 --port ${f.port || '8080'} --n_gpu_layers ${f.ngl || '99'} --n_ctx ${f.ctx || '8192'}${_lcpExtra}`; 531 if (_isWindows()) { 532 cmd += _lcpServer; 533 } else { 534 cmd += `${lcPrefix}llama-server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} -ngl ${f.ngl || '99'} -c ${f.ctx || '8192'}${_lcExtra}`; 535 cmd += ` || ${_lcpServer}`; 536 } 537 } else if (backend === 'ollama') { 538 const ollamaPort = f.port || '11434'; 539 const bindHost = _envState.remoteHost ? '0.0.0.0' : '127.0.0.1'; 540 const hostEnv = ollamaPort !== '11434' ? `OLLAMA_HOST=${bindHost}:${ollamaPort} ` : ''; 541 cmd = `${hostEnv}ollama serve`; 542 } else if (backend === 'diffusers') { 543 const gpuStr = f.gpus?.trim(); 544 if (gpuStr) cmd += `CUDA_VISIBLE_DEVICES=${gpuStr} `; 545 cmd += `python3 scripts/diffusion_server.py --model ${modelName} --port ${f.port || '8100'}`; 546 if (f.diff_dtype && f.diff_dtype !== 'bfloat16') cmd += ` --dtype ${f.diff_dtype}`; 547 if (f.diff_device_map && f.diff_device_map !== 'balanced') cmd += ` --device-map ${f.diff_device_map}`; 548 if (f.diff_steps) cmd += ` --steps ${f.diff_steps}`; 549 if (f.diff_width) cmd += ` --width ${f.diff_width}`; 550 if (f.diff_height) cmd += ` --height ${f.diff_height}`; 551 if (f.diff_offload) cmd += ' --cpu-offload'; 552 if (f.diff_attention_slicing) cmd += ' --attention-slicing'; 553 if (f.diff_vae_slicing) cmd += ' --vae-slicing'; 554 if (f.diff_harmonize_gpu) cmd += ` --harmonize-gpu ${f.diff_harmonize_gpu}`; 555 } 556 return cmd; 557} 558 559/** Get inline logo HTML for a model name/repo_id */ 560export function modelLogo(name) { 561 const logo = providerLogo(name); 562 const svg = logo || '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="4"/></svg>'; 563 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>`; 564} 565 566// Use shared esc() from ui module 567export const esc = uiModule.esc; 568 569// ââ Clipboard ââ 570 571export function _copyText(text) { 572 if (navigator.clipboard && navigator.clipboard.writeText) { 573 return navigator.clipboard.writeText(text).catch(() => _fallbackCopy(text)); 574 } 575 return _fallbackCopy(text); 576} 577 578function _fallbackCopy(text) { 579 const ta = document.createElement('textarea'); 580 ta.value = text; 581 ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px'; 582 document.body.appendChild(ta); 583 ta.select(); 584 try { document.execCommand('copy'); } catch (_) {} 585 document.body.removeChild(ta); 586 return Promise.resolve(); 587} 588 589// ââ Presets (server-synced; localStorage is offline cache) ââ 590// Presets sync to/from cookbook_state.json via _syncToServer / _syncFromServer. 591// _loadPresets reads the cache (which gets refreshed at app boot and on modal open). 592 593export function _loadPresets() { 594 try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || []; } 595 catch { return []; } 596} 597 598export function _savePresets(presets) { 599 localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); 600 // Trigger sync to server (via running module's _syncToServer debounce) 601 _saveTasks(_loadTasks()); 602} 603 604function _envStateForStorage() { 605 const { hfToken, ...safeState } = _envState; 606 return safeState; 607} 608 609function _readStoredEnvState() { 610 const stored = JSON.parse(localStorage.getItem(LAST_STATE_KEY) || '{}'); 611 delete stored.hfToken; 612 return stored; 613} 614 615export function _persistEnvState() { 616 try { localStorage.setItem(LAST_STATE_KEY, JSON.stringify(_envStateForStorage())); } 617 catch (_) {} 618 _saveTasks(_loadTasks()); 619} 620 621// ââ Dependencies ââ 622 623// Category colors removed â using theme CSS classes instead 624 625async function _fetchDependencies() { 626 const list = document.getElementById('cookbook-deps-list'); 627 if (!list) return; 628 // Use the shared whirlpool spinner so the user sees the request is in 629 // flight (the package list takes a few seconds to enumerate on slow links). 630 list.innerHTML = ''; 631 let _spin = null; 632 try { 633 const sp = (await import('./spinner.js')).default; 634 _spin = sp.createWhirlpool(28); 635 _spin.element.style.cssText = 'margin:24px auto 0;display:block;'; 636 list.appendChild(_spin.element); 637 const label = document.createElement('div'); 638 label.className = 'hwfit-loading'; 639 label.textContent = 'Loading packagesâ¦'; 640 label.style.cssText = 'text-align:center;opacity:0.5;font-size:11px;margin-top:6px;'; 641 list.appendChild(label); 642 } catch { 643 list.innerHTML = '<div class="hwfit-loading">Loading packages...</div>'; 644 } 645 try { 646 // Resolve the target server from the deps dropdown so remote-target 647 // packages are checked on THAT server's venv (not just the local host). 648 let _depHost = '', _depPort = '', _depVenv = ''; 649 const _dsel = document.getElementById('hwfit-deps-server'); 650 const _depSrv = _dsel && _dsel.value !== 'local' ? _serverByVal(_dsel.value) : null; 651 if (_depSrv) { 652 _depHost = _depSrv.host || ''; _depPort = _depSrv.port || ''; _depVenv = _depSrv.envPath || ''; 653 } else if (_envState.remoteHost) { 654 _depHost = _envState.remoteHost; _depPort = _getPort(_envState.remoteHost) || ''; _depVenv = _envState.envPath || ''; 655 } 656 const _pkgParams = new URLSearchParams(); 657 if (_depHost) { 658 _pkgParams.set('host', _depHost); 659 if (_depPort) _pkgParams.set('ssh_port', _depPort); 660 if (_depVenv) _pkgParams.set('venv', _depVenv); 661 } 662 const resp = await fetch('/api/cookbook/packages' + (_pkgParams.toString() ? '?' + _pkgParams.toString() : '')); 663 const data = await resp.json(); 664 const pkgs = data.packages || []; 665 if (!pkgs.length) { list.innerHTML = '<div class="hwfit-loading">No packages found</div>'; return; } 666 const _winUnsupported = new Set(['vllm', 'rembg', 'gfpgan']); 667 668 const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => { 669 if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">
669N/A</span>`; 670 if (pkg.installed && isSystemDep) return `<span class="cookbook-dep-tag cookbook-dep-installed" title="Found on selected server">Installed</span>`; 671 if (pkg.installed && pkg.pip_update_available === false) { 672 const tip = esc(pkg.update_note || pkg.status_note || 'Found externally; update outside Odysseus.'); 673 return `<span class="cookbook-dep-tag cookbook-dep-installed" title="${tip}">Installed</span>`; 674 } 675 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>`; 676 if (isSystemDep) { 677 const depTip = esc(pkg.install_hint || 'Install this OS package on the selected server.'); 678 const depLabel = pkg.applicable === false ? 'N/A ?' : 'Missing'; 679 return `<span class="cookbook-dep-tag cookbook-dep-na" title="${depTip}">${depLabel}</span>`; 680 } 681 return `<button class="cookbook-dep-tag cookbook-dep-install" data-dep-pip="${esc(pkg.pip)}" data-dep-target="${isLocal ? 'local' : 'remote'}">Install</button>`; 682 }; 683 684 const _depRow = (pkg) => { 685 const isLocal = pkg.target === 'local'; 686 const isSystemDep = pkg.kind === 'system'; 687 const winBlocked = !isLocal && _isWindows() && _winUnsupported.has(pkg.name); 688 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>` : ''; 689 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>` : ''; 690 // Inline rebuild/reinstall tag. Styled as a .cookbook-dep-tag so it 691 // matches the LLM category tag's pill look, and lives to the LEFT of the 692 // category tag. llama_cpp uses the /api/cookbook/rebuild-engine flow 693 // (clear cached binary so next serve recompiles); vllm/sglang use the 694 // diagnosis-style `_launchServeTask` with `pip install --force-reinstall` 695 // so the user can watch the pip install in the Running tab. 696 let _rebuildBtn = ''; 697 if (pkg.name === 'llama_cpp') { 698 _rebuildBtn = `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild" id="cookbook-rebuild-engine" title="Clear the cached llama.cpp build so the next serve recompiles from source (use after installing a CUDA/ROCm toolkit to turn a CPU-only build into a GPU build).">Rebuild</button>`; 699 } else if (pkg.name === 'vllm' && pkg.installed) { 700 _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>`; 701 } else if (pkg.name === 'sglang' && pkg.installed) { 702 _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>`; 703 } 704 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')}">` 705 + `<div class="cookbook-dep-info">` 706 + `<div class="memory-item-title">${esc(pkg.name)}</div>` 707 + `<div class="memory-item-meta" style="font-size:10px;opacity:0.5;margin-top:2px;">${esc(pkg.desc)}</div>` 708 + note 709 + updateNote 710 + `</div>` 711 + _rebuildBtn 712 + `<span class="cookbook-dep-tag cookbook-dep-cat">${esc(pkg.category)}</span>` 713 + _statusTag(pkg, isLocal, isSystemDep, winBlocked) 714 + `</div>`; 715 }; 716 717 const _section = (title, note, items) => 718 items.length 719 ? `<div class="cookbook-dep-section"><span class="cookbook-dep-section-title">${title}</span><span class="cookbook-dep-section-note">${note}</span></div>` + items.map(_depRow).join('') 720 : ''; 721 722 const _viewingRemote = !!(_dsel && _dsel.value && _dsel.value !== 'local'); 723 const _appDeps = pkgs.filter(p => p.target === 'local'); 724 const _serverDeps = pkgs.filter(p => p.target !== 'local'); 725 726 list.innerHTML = [ 727 _viewingRemote ? '' : _section('Odysseus app', 'Run inside the Odysseus app itself.', _appDeps), 728 _section('Server', 'Run on the server chosen above (Local, or a remote box over SSH).', _serverDeps), 729 ].join(''); 730 731 // Shared install/update routine â used by the Install button and the 732 // "Update" item in an installed package's â® menu. `upgrade` adds pip -U; 733 // `statusEl`, when given, shows "Installingâ¦/Updatingâ¦" and is disabled. 734 async function _installDep(pipName, pkgName, isLocalOnly, upgrade, statusEl) { 735 if (isLocalOnly) { 736 _envState.remoteHost = ''; 737 _envState.env = 'none'; 738 _envState.envPath = ''; 739 } else { 740 const depsServerSel = document.getElementById('hwfit-deps-server'); 741 if (depsServerSel) _applyServerSelection(depsServerSel.value); 742 }
743 const targetHost = isLocalOnly ? 'this server' : (_envState.remoteHost || 'local'); 744 // Always go through `python -m pip` so the leading token is `python` 745 // â matches the /api/model/serve allow-list (bare `pip` is blocked). 746 // Inside a venv/conda env, `--user` is invalid (pip refuses), so we 747 // only add `--user --break-system-packages` when there's no env â 748 // for PEP-668-locked system pythons (Arch, newer Debian). 749 const _inEnv = _envState.env === 'venv' || _envState.env === 'conda'; 750 const _pipFlags = (!_isWindows() && !_inEnv) ? ' --user --break-system-packages' : ''; 751 // Use the venv's python3 by absolute path when configured. Even with the 752 // env_prefix sourcing activate, SSH non-interactive sessions sometimes 753 // pick a `python3` ahead of the venv's bin on PATH, so the install 754 // silently lands in the wrong site-packages. 755 let _py; 756 if (_isWindows()) { 757 _py = 'python'; 758 } else if (_envState.env === 'venv' && _envState.envPath) { 759 _py = `${_envState.envPath.replace(/\/+$/, '')}/bin/python3`; 760 } else { 761 _py = 'python3'; 762 } 763 const cmd = `${_py} -m pip install${upgrade ? ' -U' : ''}${_pipFlags} "${pipName}"`; 764 let envPrefix = ''; 765 if (_isWindows()) { 766 if (_envState.env === 'venv' && _envState.envPath) { 767 envPrefix = '& ' + _psQuote(_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1'); 768 } else if (_envState.env === 'conda' && _envState.envPath) { 769 envPrefix = 'conda activate ' + _psQuote(_envState.envPath); 770 } 771 } else { 772 if (_envState.env === 'venv' && _envState.envPath) { 773 const p = _envState.envPath; 774 envPrefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate'); 775 } else if (_envState.env === 'conda' && _envState.envPath) { 776 envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath); 777 } 778 } 779 try { 780 const reqBody = { 781 repo_id: pipName, 782 cmd: cmd, 783 remote_host: _envState.remoteHost || undefined, 784 ssh_port: _getPort(_envState.remoteHost) || undefined, 785 env_prefix: envPrefix || undefined, 786 platform: _envState.platform || undefined, 787 }; 788 const res = await fetch('/api/model/serve', { 789 method: 'POST', credentials: 'same-origin', 790 headers: { 'Content-Type': 'application/json' }, 791 body: JSON.stringify(reqBody), 792 }); 793 const data = await res.json().catch(() => ({})); 794 if (!res.ok || !data.ok) { 795 // FastAPI HTTPException returns {detail: â¦}; the route's own 796 // path returns {ok:false, error:â¦}. Surface whichever we get. 797 const reason = data.detail || data.error || `HTTP ${res.status}`; 798 uiModule.showToast('Install failed: ' + String(reason).slice(0, 200)); 799 return; 800 } 801 // _dep flags this as a pip dependency/driver install (not a servable 802 // model) so the running-task card doesn't offer a "Serve â" button. 803 const payload = { repo_id: pipName, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true, env_path: _envState.envPath || '' }; 804 _addTask(data.session_id, 'pip ' + pkgName, 'download', payload); 805 if (statusEl) { statusEl.textContent = upgrade ? 'Updating...' : 'Installing...'; statusEl.disabled = true; } 806 uiModule.showToast(`${upgrade ? 'Updating' : 'Installing'} ${pkgName} on ${targetHost}...`); 807 } catch (err) { 808 uiModule.showToast('Install failed: ' + err.message); 809 } 810 } 811 812 // Wire install buttons (not-installed packages) 813 list.querySelectorAll('.cookbook-dep-install').forEach(btn => { 814 btn.addEventListener('click', async (e) => { 815 e.stopPropagation(); 816 const pipName = btn.dataset.depPip; 817 const pkgName = btn.closest('.cookbook-dep-row')?.querySelector('.memory-item-title')?.textContent || pipName; 818 await _installDep(pipName, pkgName, btn.dataset.depTarget === 'local', !!btn.dataset.upgrade, btn); 819 }); 820 }); 821 822 // Wire the â® menu on installed packages â currently just "Update". 823 function _showDepMenu(anchor) { 824 document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
825 const row = anchor.closest('.cookbook-dep-row'); 826 if (!row) return; 827 const pipName = row.dataset.depPip; 828 const pkgName = row.querySelector('.memory-item-title')?.textContent || pipName; 829 const isLocalOnly = row.dataset.depTarget === 'local'; 830 const dropdown = document.createElement('div'); 831 dropdown.className = 'dropdown cookbook-dep-menu'; 832 const rect = anchor.getBoundingClientRect(); 833 const minW = 150; 834 let left = Math.min(rect.right - minW, window.innerWidth - minW - 8); 835 left = Math.max(8, left); 836 dropdown.style.cssText = `position:fixed;display:block;z-index:10001;top:${rect.bottom + 6}px;left:${left}px;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`; 837 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>'; 838 const it = document.createElement('div'); 839 it.className = 'dropdown-item-compact'; 840 it.innerHTML = `<span class="dropdown-icon">${upIco}</span><span>Update</span>`; 841 it.title = `Update ${pkgName} to the latest version (pip install -U)`; 842 it.addEventListener('click', async (e) => { 843 e.stopPropagation(); 844 dropdown.remove(); 845 await _installDep(pipName, pkgName, isLocalOnly, true, null); 846 }); 847 dropdown.appendChild(it); 848 document.body.appendChild(dropdown); 849 const close = (ev) => { 850 if (!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)) { 851 dropdown.remove(); 852 document.removeEventListener('click', close, true); 853 } 854 }; 855 setTimeout(() => document.addEventListener('click', close, true), 10); 856 } 857 list.querySelectorAll('.cookbook-dep-installed-btn').forEach(btn => { 858 btn.addEventListener('click', (e) => { 859 e.stopPropagation(); 860 if (document.querySelector('.cookbook-dep-menu')) { 861 document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove()); 862 return; 863 } 864 _showDepMenu(btn); 865 }); 866 }); 867 } catch (err) { 868 list.innerHTML = `<div class="hwfit-loading">Error loading packages: ${esc(err.message)}</div>`; 869 } 870} 871 872// ââ Tab wiring ââ 873 874function _applyServerSelection(val) { 875 if (val === 'local') { 876 _envState.remoteHost = ''; 877 _envState.env = 'none'; 878 _envState.envPath = ''; 879 _envState.platform = ''; 880 } else { 881 const s = _serverByVal(val); 882 if (s) { 883 _envState.remoteHost = s.host; 884 _envState.env = s.env || 'none'; 885 _envState.envPath = s.envPath || ''; 886 _envState.platform = s.platform || ''; 887 } 888 } 889 // Persist + keep every server dropdown in sync, so the choice sticks across 890 // re-renders and the scan/download all target the SAME host (this was the 891 // bug: the Download/Cache/Deps dropdowns set the host but never saved it, so 892 // it silently reverted and downloads/scans hit the wrong server). 893 _persistEnvState(); 894 const _want = _envState.remoteHost || 'local'; 895 document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => { 896 if (!sel || sel.tagName !== 'SELECT') return; 897 // Option values are host strings now ('local' for the local box). 898 sel.value = _want; 899 // If the host isn't among this select's current options (stale options after 900 // the server list changed), the browser leaves the box BLANK/grey even though 901 // the value is "set". Rebuild the options so the chosen host has an entry, then 902 // re-apply; fall back to 'local' only if it's genuinely gone. 903 if (sel.selectedIndex < 0) { 904 sel.innerHTML = _buildServerOpts(sel.id === 'hwfit-dl-server'); 905 sel.value = _want; 906 if (sel.selectedIndex < 0) sel.value = 'local'; 907 } 908 }); 909} 910 911function _wireTabEvents(body) { 912 // Tab switching 913 body.querySelectorAll('.cookbook-tab').forEach(tab => { 914 tab.addEventListener('click', () => { 915 body.querySelectorAll('.cookbook-tab').forEach(t => t.classList.remove('active')); 916 tab.classList.add('active'); 917 const backend = tab.dataset.backend; 918 body.querySelectorAll('.cookbook-group').forEach(g => { 919 g.classList.toggle('hidden', g.dataset.backendGroup !== backend); 920 }); 921 if (backend === 'Search') { 922 _hwfitInit(); 923 _hwfitFetch(); 924 } 925 if (backend === 'Serve') { 926 _fetchCachedModels(); 927 }
928 if (backend === 'Dependencies') { 929 _fetchDependencies(); 930 } 931 }); 932 }); 933 934 // Mobile: swipe left/right anywhere in the body to move to the next/previous 935 // tab. Guarded so it ignores vertical scrolls, tiny moves, and form fields. 936 if (!body._swipeWired) { 937 body._swipeWired = true; 938 let _sx = null, _sy = null; 939 body.addEventListener('touchstart', (e) => { 940 // Ignore swipes that start in a horizontally-scrollable tag row â those 941 // should scroll the chips, not flip the tab. 942 if (window.innerWidth > 768 || e.touches.length !== 1 943 || e.target.closest('input, textarea, select, .doclib-lang-chips')) { _sx = null; return; } 944 _sx = e.touches[0].clientX; _sy = e.touches[0].clientY; 945 }, { passive: true }); 946 body.addEventListener('touchend', (e) => { 947 if (_sx === null) return; 948 const dx = e.changedTouches[0].clientX - _sx; 949 const dy = e.changedTouches[0].clientY - _sy; 950 _sx = null; 951 // Require a clear horizontal swipe (>60px and mostly horizontal). 952 if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return; 953 const tabs = [...body.querySelectorAll('.cookbook-tab')]; 954 const idx = tabs.findIndex(t => t.classList.contains('active')); 955 if (idx < 0) return; 956 const next = dx < 0 ? idx + 1 : idx - 1; // swipe left â next tab 957 if (next >= 0 && next < tabs.length) tabs[next].click(); 958 }, { passive: true }); 959 } 960 961 // Sync server form DOM â _envState.servers 962 function _syncServers() { 963 const entries = document.querySelectorAll('.cookbook-server-entry'); 964 const servers = []; 965 entries.forEach(entry => { 966 const name = entry.querySelector('.cookbook-srv-name')?.value?.trim() || ''; 967 const host = entry.querySelector('.cookbook-srv-host')?.value?.trim() || ''; 968 const port = entry.querySelector('.cookbook-srv-port')?.value?.trim() || ''; 969 const env = entry.querySelector('.cookbook-srv-env')?.value || 'none'; 970 const envPath = entry.querySelector('.cookbook-srv-path')?.value?.trim() || ''; 971 const platform = entry.dataset.platform || ''; 972 const dirs = []; 973 entry.querySelectorAll('.cookbook-modeldir-tag').forEach(tag => { 974 // Read from data attribute (authoritative) â never parse displayed text 975 const d = (tag.dataset.dir || '').replaceAll('â', '').replaceAll('â', '').trim(); 976 if (d) dirs.push(d); 977 }); 978 // Directory flagged as the download target ('' = default HF cache). 979 const dlEl = entry.querySelector('.cookbook-modeldir-dl.active'); 980 const downloadDir = dlEl ? (dlEl.dataset.dlDir || '') : ''; 981 servers.push({ name, host, port, env, envPath, modelDirs: dirs, downloadDir, platform }); 982 }); 983 _envState.servers = servers; 984 // Auto-default: when the user has configured EXACTLY ONE remote server 985 // and hasn't picked one yet, select it. Without this, the dropdown 986 // stays on "Local" so the eventual serve/scan/launch resolves to no 987 // remote host and the backend rejects the call with 403 (Forbidden), 988 // which read to the user as a permission bug. 989 if (!_envState.remoteHost) { 990 const remotes = servers.filter(s => !_isLocalEntry(s)); 991 if (remotes.length === 1) { 992 _envState.remoteHost = remotes[0].host; 993 _envState.env = remotes[0].env || 'none'; 994 _envState.envPath = remotes[0].envPath || ''; 995 } 996 } 997 const activeSrv = servers.find(s => s.host === _envState.remoteHost); 998 _envState.platform = activeSrv?.platform || ''; 999 localStorage.setItem('cookbook-last-state', JSON.stringify(_envStateForStorage())); 1000 _saveTasks(_loadTasks()); 1001 // Reflect the auto-default selection into every server dropdown so the 1002 // UI matches the resolved host. Done in a microtask so the dropdowns 1003 // exist by the time we set their .value. 1004 Promise.resolve().then(() => { 1005 const _want = _envState.remoteHost || 'local'; 1006 document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => { 1007 if (sel && sel.tagName === 'SELECT') sel.value = _want; 1008 }); 1009 }); 1010 } 1011 1012 // Wire server form inputs 1013 document.querySelectorAll('.cookbook-srv-name, .cookbook-srv-host, .cookbook-srv-port, .cookbook-srv-path').forEach(el => { 1014 el.addEventListener('change', _syncServers); 1015 }); 1016 document.querySelectorAll('.cookbook-srv-env').forEach(el => { 1017 el.addEventListener('change', _syncServers); 1018 }); 1019 1020 // Server selector â the server is global, so switching it here re-scans the 1021 // main Scan/Download list (#hwfit-list) for the new server's hardware too. 1022 // (The trending sublist reloads via its own handler in the HF-latest wiring.) 1023 const dlServer = document.getElementById('hwfit-dl-server'); 1024 if (dlServer) { 1025 dlServer.addEventListener('change', () => { 1026 _applyServerSelection(dlServer.value); 1027 // Reset toggle state (no flicker) so the new server's hardware re-renders. 1028 _resetGpuToggleState(); 1029 _hwfitFetch(); 1030 }); 1031 } 1032 1033 // Add server link â switch to Settings tab 1034 const addServerLink = document.querySelector('.cookbook-dl-add-server'); 1035 if (addServerLink) { 1036 addServerLink.addEventListener('click', () => { 1037 const settingsTab = body.querySelector('.cookbook-tab[data-backend="Settings"]'); 1038 if (settingsTab) settingsTab.click(); 1039 }); 1040 } 1041 1042 // Cache server selector 1043 const cacheServer = document.getElementById('hwfit-cache-server'); 1044 const cacheDirEl = document.getElementById('hwfit-cache-dir'); 1045 if (cacheServer) { 1046 cacheServer.addEventListener('change', () => { 1047 _applyServerSelection(cacheServer.value); 1048 const val = cacheServer.value; 1049 let srv; 1050 if (val === 'local') { 1051 srv = _envState.servers.find(_isLocalEntry) || _envState.servers[0] || {}; 1052 } else { 1053 srv = _serverByVal(val) || {}; 1054 } 1055 if (cacheDirEl) cacheDirEl.value = srv.modelDir || '~/.cache/huggingface/hub'; 1056 const dirsEl = document.querySelector('.cookbook-serve-dirs'); 1057 if (dirsEl) { 1058 const dirs = (Array.isArray(srv.modelDirs) ? srv.modelDirs : [srv.modelDir || '~/.cache/huggingface/hub']).map(d =>
1058 d.replaceAll('â', '').replaceAll('â', '').trim()).filter(Boolean); 1059 dirsEl.innerHTML = dirs.map(d => `<span class="cookbook-serve-dir-pill">${esc(d)}</span>`).join('') + 1060 '<span class="cookbook-serve-dir-edit" title="Edit in Settings">edit</span>'; 1061 dirsEl.querySelector('.cookbook-serve-dir-edit')?.addEventListener('click', () => { 1062 const settingsTab = body.querySelector('.cookbook-tab[data-backend="Settings"]'); 1063 if (settingsTab) settingsTab.click(); 1064 }); 1065 } 1066 _fetchCachedModels(); 1067 }); 1068 } 1069 1070 const scanBtn = document.getElementById('hwfit-cache-scan'); 1071 if (scanBtn) { 1072 scanBtn.addEventListener('click', () => _fetchCachedModels()); 1073 } 1074 1075 const editDirsLink = document.querySelector('.cookbook-serve-dir-edit'); 1076 if (editDirsLink) { 1077 editDirsLink.addEventListener('click', () => { 1078 const settingsTab = body.querySelector('.cookbook-tab[data-backend="Settings"]'); 1079 if (settingsTab) settingsTab.click(); 1080 }); 1081 } 1082 1083 const depsServer = document.getElementById('hwfit-deps-server'); 1084 if (depsServer) { 1085 depsServer.addEventListener('change', () => { 1086 _applyServerSelection(depsServer.value); 1087 // Re-fetch the package list for the newly selected server â the installed 1088 // status is per-server, so the list must refresh on a server switch. 1089 _fetchDependencies(); 1090 }); 1091 } 1092 1093 // "Rebuild llama.cpp" clears the cached build so the next serve recompiles. 1094 // The serve bootstrap only builds llama-server when it is missing from PATH, 1095 // so a host that first built CPU-only (no nvcc at build time) keeps reusing 1096 // that binary forever; this is the lever to force a fresh GPU build after a 1097 // CUDA/ROCm toolkit is installed. 1098 const rebuildBtn = document.getElementById('cookbook-rebuild-engine'); 1099 if (rebuildBtn && !rebuildBtn._wired) { 1100 rebuildBtn._wired = true; 1101 rebuildBtn.addEventListener('click', async () => { 1102 // Match _installDep: honor the Dependencies server selector so the clear 1103 // runs on the same host the build runs on. 1104 const sel = document.getElementById('hwfit-deps-server'); 1105 if (sel) _applyServerSelection(sel.value); 1106 const host = _envState.remoteHost || ''; 1107 const where = host || 'this server'; 1108 if (!confirm(`Rebuild the llama.cpp engine on ${where}?\n\nThis clears the cached llama-server build so the next serve recompiles from source (with CUDA/HIP if a toolchain is present). It does not download or install anything.`)) return; 1109 const _label = rebuildBtn.textContent; 1110 rebuildBtn.disabled = true; 1111 rebuildBtn.textContent = 'Clearing...'; 1112 try { 1113 const res = await fetch('/api/cookbook/rebuild-engine', { 1114 method: 'POST', credentials: 'same-origin', 1115 headers: { 'Content-Type': 'application/json' }, 1116 body: JSON.stringify({ 1117 engine: 'llamacpp', 1118 remote_host: host || undefined, 1119 ssh_port: _getPort(host) || undefined, 1120 }), 1121 }); 1122 const data = await res.json().catch(() => ({})); 1123 if (!res.ok || !data.ok) { 1124 const reason = data.detail || data.error || `HTTP ${res.status}`; 1125 uiModule.showToast('Rebuild failed: ' + String(reason).slice(0, 200)); 1126 } else { 1127 uiModule.showToast(`Cleared llama.cpp build on ${where}. Re-launch the serve task to rebuild with GPU support.`); 1128 } 1129 } catch (err) { 1130 uiModule.showToast('Rebuild failed: ' + err.message); 1131 } finally { 1132 rebuildBtn.disabled = false; 1133 rebuildBtn.textContent = _label; 1134 } 1135 }); 1136 } 1137 1138 // "Reinstall" buttons for pip-based serving stacks (vllm, sglang). The 1139 // deps list renders ASYNCHRONOUSLY after _fetchDependencies resolves, so 1140 // attaching listeners directly here would miss buttons that don't exist 1141 // yet. Use document-level delegation instead â the click always finds the 1142 // right .cookbook-dep-reinstall button no matter when it was painted. 1143 if (!document._cookbookReinstallWired) { 1144 document._cookbookReinstallWired = true; 1145 document.addEventListener('click', async (ev) => { 1146 const btn = ev.target.closest?.('.cookbook-dep-reinstall'); 1147 if (!btn) return; 1148 const pkg = btn.dataset.reinstallPkg || ''; 1149 if (!pkg) return; 1150 ev.preventDefault(); 1151 ev.stopPropagation(); 1152 const sel = document.getElementById('hwfit-deps-server'); 1153 if (sel) _applyServerSelection(sel.value); 1154 const host = _envState.remoteHost || '';
1155 const where = host || 'this server'; 1156 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; 1157 const _venvPy = (_envState.env === 'venv' && _envState.envPath) 1158 ? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` 1159 : 'python3'; 1160 _launchServeTask(`reinstall-${pkg}`, 'pip-reinstall', `${_venvPy} -m pip install --force-reinstall --no-deps ${pkg}`); 1161 }, true); 1162 } 1163 1164 // Serve sort 1165 const serveSort = document.getElementById('serve-sort'); 1166 if (serveSort) { 1167 serveSort.addEventListener('change', () => { 1168 if (_cachedAllModels.length) _rerenderCachedModels(); 1169 }); 1170 } 1171 1172 // Serve search 1173 const serveSearch = document.getElementById('serve-search'); 1174 if (serveSearch) { 1175 let _srvDebounce = null; 1176 serveSearch.addEventListener('input', () => { 1177 clearTimeout(_srvDebounce); 1178 _srvDebounce = setTimeout(() => _filterCachedList(), 200); 1179 }); 1180 } 1181 1182 // Select mode â bulk actions 1183 const selectBtn = document.getElementById('hwfit-cache-select'); 1184 const bulkBar = document.getElementById('serve-bulk-bar'); 1185 if (selectBtn && bulkBar) { 1186 selectBtn.addEventListener('click', () => { 1187 const active = selectBtn.classList.toggle('active'); 1188 selectBtn.textContent = active ? 'Cancel' : 'Select'; 1189 bulkBar.classList.toggle('hidden', !active); 1190 document.querySelectorAll('.serve-select-cb').forEach(dot => { 1191 dot.style.display = active ? '' : 'none'; 1192 dot.classList.remove('selected'); 1193 }); 1194 _updateBulkCount(); 1195 }); 1196 1197 document.getElementById('hwfit-cached-list')?.addEventListener('click', (e) => { 1198 if (!selectBtn.classList.contains('active')) return; 1199 const item = e.target.closest('.memory-item[data-repo]'); 1200 if (!item) return; 1201 if (e.target.closest('a, .hwfit-cached-menu-btn, .memory-item-btn, .hwfit-serve-panel')) return; 1202 const dot = item.querySelector('.serve-select-cb'); 1203 if (dot) { 1204 dot.classList.toggle('selected'); 1205 _updateBulkCount(); 1206 } 1207 }); 1208 1209 function _updateBulkCount() { 1210 const count = document.querySelectorAll('.serve-select-cb.selected').length; 1211 const countEl = document.getElementById('serve-bulk-count'); 1212 if (countEl) countEl.textContent = count + ' selected'; 1213 } 1214 1215 document.getElementById('serve-bulk-cancel')?.addEventListener('click', () => { 1216 selectBtn.classList.remove('active'); 1217 selectBtn.textContent = 'Select'; // reset label so the button doesn't stay reading "Cancel" after exit 1218 bulkBar.classList.add('hidden'); 1219 document.querySelectorAll('.serve-select-cb').forEach(dot => { dot.style.display = 'none'; dot.classList.remove('selected'); }); 1220 }); 1221 1222 document.getElementById('serve-bulk-delete')?.addEventListener('click', async () => { 1223 const checked = document.querySelectorAll('.serve-select-cb.selected'); 1224 if (!checked.length) return; 1225 const repos = []; 1226 checked.forEach(dot => { 1227 const item = dot.closest('.memory-item[data-repo]'); 1228 if (item?.dataset.repo) repos.push(item.dataset.repo); 1229 }); 1230 if (!(await uiModule.styledConfirm(`Delete ${repos.length} model(s)? This removes cached files.`, { confirmText: 'Delete', danger: true }))) return; 1231 for (const repo of repos) { 1232 const item = document.querySelector(`.memory-item[data-repo="${repo}"]`); 1233 if (item) await _deleteCachedModel(repo, item, true); 1234 } 1235 selectBtn.classList.remove('active'); 1236 selectBtn.textContent = 'Select'; // same reset as bulk-cancel 1237 bulkBar.classList.add('hidden'); 1238 document.querySelectorAll('.serve-select-cb').forEach(dot => { dot.style.display = 'none'; dot.classList.remove('selected'); }); 1239 }); 1240 } 1241 1242 // Download input 1243 const dlBtn = document.getElementById('cookbook-dl-btn'); 1244 const dlInput = document.getElementById('cookbook-dl-repo'); 1245 const dlCardToggle = document.getElementById('cookbook-download-card-toggle'); 1246 const dlCardBody = document.getElementById('cookbook-download-card-body'); 1247 const dlCardArrow = document.getElementById('cookbook-download-card-arrow'); 1248 if (dlCardToggle && dlCardBody) { 1249 dlCardToggle.addEventListener('click', () => { 1250 const isOpen = dlCardBody.style.display !== 'none'; 1251 dlCardBody.style.display = isOpen ? 'none' : 'block'; 1252 if (dlCardArrow) dlCardArrow.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)'; 1253 }); 1254 } 1255 if (dlBtn && dlInput) { 1256 function _stripHfUrl(input) { 1257 let repo = input.trim(); 1258 // Strip Ollama-style "hf.co/" prefix if present (e.g. hf.co/unsloth/...:tag) 1259 repo = repo.replace(/^hf\.co\//, ''); 1260 const hfMatch = repo.match(/^https?:\/\/huggingface\.co\/([^/]+\/[^/?#]+(?::[^/?#\s]+)?)/); 1261 if (hfMatch) repo = hfMatch[1]; 1262 return repo; 1263 } 1264 // Split `org/repo:tag` (Ollama/llama.cpp style) into repo + include-glob. 1265 // The `:tag` picks a specific GGUF quantization file from the repo. 1266 function _splitRepoTag(raw) { 1267 const m = raw.match(/^([^\s/:]+\/[^\s/:]+):([^\s/]+)$/); 1268 if (!m) return { repo: raw, include: null }; 1269 return { repo: m[1], include: `*${m[2]}*` }; 1270 } 1271 const triggerDownload = () => { 1272 const rawRepo = _stripHfUrl(dlInput.value); 1273 if (!rawRepo) return; 1274 const { repo, include: autoInclude } = _splitRepoTag(rawRepo); 1275 // HuggingFace repo IDs must be `org/model`. A bare model name would 404 1276 // at snapshot_download time with a raw traceback, so reject it up front. 1277 if (!/^[^\s/]+\/[^\s/]+$/.test(repo)) { 1278 uiModule.showToast('Enter a full HuggingFace repo ID like "org/model-name" (or paste the full HF URL).'); 1279 dlInput.focus(); 1280 return; 1281 } 1282 // Resolve the host straight from THIS window's server dropdown, by index 1283 // into the (consistent) servers list. We deliberately don't use 1284 // _envState.remoteHost â there can be multiple copies of the cookbook 1285 // state in memory and they disagree on the active host, which is what sent 1286 // downloads to the wrong server. The dropdown the user sees is the truth. 1287 const dlSrv = document.getElementById('hwfit-dl-server'); 1288 const srvVal = dlSrv ? dlSrv.value : 'local'; 1289 let host = ''; 1290 if (srvVal !== 'local') {
1291 host = _serverByVal(srvVal)?.host || ''; 1292 } 1293 const _hsrv = _envState.servers.find(sv => sv.host === host) || {}; 1294 let env = host ? (_hsrv.env || 'none') : _envState.env; 1295 let envPath = host ? (_hsrv.envPath || '') : _envState.envPath; 1296 const payload = { repo_id: repo }; 1297 if (autoInclude) payload.include = autoInclude; 1298 if (_envState.hfToken) payload.hf_token = _envState.hfToken; 1299 if (host) { payload.remote_host = host; const _sp3 = _getPort(host); if (_sp3) payload.ssh_port = _sp3; } 1300 const srvPlatform = _getPlatform(host); 1301 if (srvPlatform) payload.platform = srvPlatform; 1302 if (srvPlatform === 'windows') { 1303 if (env === 'venv' && envPath) { 1304 payload.env_prefix = '& ' + _psQuote(envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1'); 1305 } else if (env === 'conda' && envPath) { 1306 payload.env_prefix = 'conda activate ' + _psQuote(envPath); 1307 } 1308 } else { 1309 if (env === 'venv' && envPath) { 1310 const p = envPath; 1311 payload.env_prefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate'); 1312 } else if (env === 'conda' && envPath) { 1313 payload.env_prefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(envPath); 1314 } 1315 } 1316 const shortName = repo.split('/').pop(); 1317 _retryDownload(shortName, payload); 1318 dlInput.value = ''; 1319 }; 1320 dlBtn.addEventListener('click', triggerDownload); 1321 dlInput.addEventListener('keydown', (e) => { 1322 if (e.key === 'Enter') triggerDownload(); 1323 }); 1324 } 1325 1326 // Latest HF models that fit â collapsible card list 1327 // Foldable Download admin-card â h2 "Download" doubles as the chevron 1328 // toggle; collapses the entire card body (description + input + HF list). 1329 // State persisted to localStorage so the fold sticks across reloads. 1330 const dlFold = document.getElementById('cookbook-dl-tab-fold'); 1331 const dlFoldBody = document.getElementById('cookbook-dl-tab-fold-body'); 1332 const dlFoldChevron = document.getElementById('cookbook-dl-tab-chevron'); 1333 if (dlFold && dlFoldBody && dlFoldChevron) { 1334 dlFold.addEventListener('click', () => { 1335 const folded = dlFoldBody.style.display === 'none'; 1336 dlFoldBody.style.display = folded ? '' : 'none'; 1337 dlFoldChevron.textContent = folded ? 'â¾' : 'â¸'; 1338 // Toggle is-folded class on the h2 so the line under it only shows when 1339 // the section is collapsed (the body's content normally provides 1340 // separation; with no body visible, the line gives the h2 definition). 1341 dlFold.classList.toggle('is-folded', !folded); 1342 try { localStorage.setItem('cookbook_dl_tab_folded_v1', folded ? '0' : '1'); } catch {} 1343 }); 1344 } 1345 const hfToggle = document.getElementById('cookbook-hf-latest-toggle'); 1346 const hfArrow = document.getElementById('cookbook-hf-latest-arrow'); 1347 const hfList = document.getElementById('cookbook-hf-latest-list'); 1348 const hfRefresh = document.getElementById('cookbook-hf-latest-refresh'); 1349 if (hfToggle && hfList) { 1350 let _loaded = false; 1351 // Per-server VRAM cache so we don't re-probe on every expand 1352 const _hwCache = {}; 1353 function _hfModelLooksAwqLike(m) { 1354 const text = `${m?.repo_id || ''} ${(m?.tags || []).join(' ')}`.toLowerCase(); 1355 return /\b(awq|gptq|fp8|4bit|int4)\b/.test(text); 1356 } 1357 async function _getSelectedServerHw() { 1358 // Prefer the "What Fits" dropdown (the main control that shows hardware); 1359 // fall back to the download dropdown. This is the server the list ranks for. 1360 const dlSrv = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server'); 1361 const val = dlSrv?.value || 'local'; 1362 let host = ''; 1363 let sshPort = ''; 1364 let platform = ''; 1365 if (val !== 'local') { 1366 const s = _serverByVal(val); 1367 if (s) { 1368 host = s.host || ''; 1369 sshPort = s.port || ''; 1370 platform = s.platform || ''; 1371 } 1372 } 1373 const cacheKey = host || 'local'; 1374 if (_hwCache[cacheKey]) return _hwCache[cacheKey]; 1375 // Fetch system info for this server from hwfit
1376 try { 1377 const qp = new URLSearchParams(); 1378 if (host) qp.set('host', host); 1379 if (sshPort) qp.set('ssh_port', sshPort); 1380 if (platform) qp.set('platform', platform); 1381 const r = await fetch(`/api/hwfit/system?${qp}`); 1382 if (r.ok) { 1383 const sys = await r.json(); 1384 const hw = { vram: sys?.gpu_vram_gb || 0, backend: String(sys?.backend || '').toLowerCase() }; 1385 _hwCache[cacheKey] = hw; 1386 return hw; 1387 } 1388 } catch {} 1389 _hwCache[cacheKey] = { vram: 0, backend: '' }; 1390 return _hwCache[cacheKey]; 1391 } 1392 async function _loadLatest() { 1393 // Match the Dependencies loader: whirlpool spinner + text label so the 1394 // user gets immediate feedback while the scan runs. 1395 hfList.innerHTML = ''; 1396 try { 1397 const sp = (await import('./spinner.js')).default; 1398 const _spin = sp.createWhirlpool(28); 1399 _spin.element.style.cssText = 'margin:24px auto 0;display:block;'; 1400 hfList.appendChild(_spin.element); 1401 const lbl = document.createElement('div'); 1402 lbl.className = 'hwfit-loading'; 1403 lbl.textContent = 'Scanning modelsâ¦'; 1404 lbl.style.cssText = 'text-align:center;opacity:0.5;font-size:11px;margin-top:6px;'; 1405 hfList.appendChild(lbl); 1406 } catch { 1407 hfList.innerHTML = '<div class="hwfit-loading">Scanning modelsâ¦</div>'; 1408 } 1409 const hwInfo = await _getSelectedServerHw(); 1410 const vram = hwInfo.vram || 0; 1411 try { 1412 let lastErr = ''; 1413 const _fetchLatest = async (v) => { 1414 const res = await fetch(`/api/cookbook/hf-latest?vram_gb=${v}&limit=10`); 1415 const data = await res.json(); 1416 if (data.error) lastErr = data.error; // HF API timeout/rate-limit etc. 1417 return data.models || []; 1418 }; 1419 let models = await _fetchLatest(vram); 1420 // If the VRAM filter wiped everything out (often a flaky/zero hardware 1421 // probe for a remote server â a huge-VRAM box should fit MORE, not 1422 // fewer), fall back to the unfiltered trending list so something shows. 1423 if (!models.length && vram > 0) { 1424 models = await _fetchLatest(0); 1425 } 1426 if (['rocm', 'metal', 'mps', 'apple', 'generic', 'cpu'].includes(hwInfo.backend)) { 1427 models = models.filter(m => !_hfModelLooksAwqLike(m)); 1428 } 1429 if (!models.length) { 1430 // Distinguish "the HF API failed" from "nothing matched" so an outage 1431 // doesn't masquerade as no-fitting-models. 1432 const msg = lastErr 1433 ? `Couldn't load trending models (${esc(lastErr)})` 1434 : 'No trending models found'; 1435 hfList.innerHTML = `<div class="hwfit-loading">${msg}</div>`; 1436 return; 1437 } 1438 let html = ''; 1439 for (const m of models) { 1440 const shortName = m.repo_id.split('/').pop() || m.repo_id; 1441 const org = m.repo_id.includes('/') ? m.repo_id.split('/')[0] : ''; 1442 const meta = []; 1443 if (org) meta.push(esc(org)); 1444 if (m.needed_vram_gb) meta.push(`~${m.needed_vram_gb}GB`); 1445 if (m.downloads) meta.push(`${m.downloads.toLocaleString()} downloads`); 1446 const date = m.createdAt ? new Date(m.createdAt).toISOString().slice(0, 10) : ''; 1447 if (date) meta.push(date); 1448 html += `<div class="doclib-card memory-item cookbook-hf-latest-card" data-repo="${esc(m.repo_id)}" style="cursor:pointer;">`; 1449 html += `<div style="flex:1;min-width:0;">`; 1450 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>`; 1451 html += `<div class="memory-item-meta" style="font-size:10px;opacity:0.5;margin-top:2px;">${meta.join(' \u00b7 ')}</div>`; 1452 html += `</div>`; 1453 html += `</div>`; 1454 } 1455 hfList.innerHTML = html; 1456 // Wire card clicks â fill download input 1457 hfList.querySelectorAll('.cookbook-hf-latest-card').forEach(card => { 1458 card.addEventListener('click', (e) => { 1459 if (e.target.closest('a')) return; 1460 if (dlInput) { 1461 dlInput.value = card.dataset.repo; 1462 dlInput.focus(); 1463 } 1464 }); 1465 }); 1466 } catch (e) { 1467 hfList.innerHTML = '<div class="hwfit-loading">Failed to load</div>'; 1468 } 1469 } 1470 hfToggle.addEventListener('click', () => { 1471 const isOpen = hfList.style.display !== 'none'; 1472 hfList.style.display = isOpen ? 'none' : 'flex'; 1473 if (hfArrow) hfArrow.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)'; 1474 if (!isOpen && !_loaded) { 1475 _loaded = true; 1476 _loadLatest(); 1477 } 1478 }); 1479 if (hfRefresh) hfRefresh.addEventListener('click', (e) => { 1480 e.stopPropagation(); 1481 _loaded = true; 1482 _loadLatest(); 1483 // If list is hidden, open it 1484 if (hfList.style.display === 'none') { 1485 hfList.style.display = 'flex'; 1486 if (hfArrow) hfArrow.style.transform = 'rotate(90deg)'; 1487 } 1488 }); 1489 // Re-fetch when a server dropdown changes â different server = different 1490 // hardware/VRAM. Mark the list stale so it reloads for the new server even 1491 // if it's currently collapsed (otherwise reopening showed the old server's 1492 // models); reload immediately when it's open. 1493 const _onServerChange = () => { 1494 _loaded = false;
1495 if (hfList.style.display !== 'none') { _loaded = true; _loadLatest(); } 1496 }; 1497 document.getElementById('hwfit-dl-server')?.addEventListener('change', _onServerChange); 1498 document.getElementById('hwfit-server-select')?.addEventListener('change', _onServerChange); 1499 } 1500 1501 // Server add button, row removal, model-dir add/remove, and per-row wiring 1502 // are ALL owned by cookbook-hwfit.js's _hwfitInit / _wireServerEntry. 1503 // A duplicate add handler used to live here and fired alongside the hwfit 1504 // one, appending two rows per click â removed. 1505 1506 1507 // HF token â save on change 1508 const hfInput = document.getElementById('hwfit-hftoken'); 1509 if (hfInput) { 1510 hfInput.addEventListener('change', async () => { 1511 const val = hfInput.value.trim(); 1512 _envState.hfToken = val; 1513 try { await _persistEnvState(); } catch {} 1514 if (val) { 1515 _envState.hfTokenConfigured = true; 1516 const masked = val.length > 6 ? val.slice(0, 3) + 'â¦' + val.slice(-3) : 'â¢â¢â¢â¢'; 1517 _envState.hfTokenMasked = masked; 1518 hfInput.placeholder = `Stored (${masked}) - enter a new token to replace`; 1519 hfInput.value = ''; 1520 let check = hfInput.parentNode.querySelector('.hwfit-hf-check'); 1521 if (!check) { 1522 check = document.createElement('span'); 1523 check.className = 'hwfit-hf-check'; 1524 check.title = 'Token stored'; 1525 check.textContent = 'â'; 1526 check.style.cssText = 'font-weight:800;color:var(--green,#50fa7b);font-size:15px;line-height:1;flex-shrink:0;position:relative;top:2px;'; 1527 hfInput.parentNode.insertBefore(check, hfInput); 1528 } 1529 const flash = document.createElement('span'); 1530 flash.textContent = 'Saved'; 1531 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;'; 1532 hfInput.parentNode.appendChild(flash); 1533 requestAnimationFrame(() => { flash.style.opacity = '1'; }); 1534 setTimeout(() => { flash.style.opacity = '0'; setTimeout(() => flash.remove(), 220); }, 1400); 1535 } 1536 }); 1537 } 1538} 1539 1540// ââ Main render ââ 1541 1542// Build one server entry's HTML â shared by the Settings render loop AND the 1543// "+ Add server" handler, so a freshly-added server has the IDENTICAL layout 1544// (Model Directory header, default-server checkmark, trash delete, platform icon). 1545// forceRemote renders an editable remote entry even before a host is typed 1546// (a new server's host is empty, which would otherwise read as "Local"). 1547export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) { 1548 const isLocal = (forceRemote || isNew) ? false : (!s.host || s.host === 'local'); 1549 const envOpts = ['none', 'venv'].map(e => `<option value="${e}"${s.env === e ? ' selected' : ''}>${e === 'none' ? 'None' : e}</option>`).join(''); 1550 let html = ''; 1551 html += `<div class="cookbook-server-entry" data-idx="${i}" data-platform="${esc(s.platform || '')}">`; 1552 const _srvTitle = s.name || (isLocal ? 'Local' : (s.host || `Server ${i + 1}`)); 1553 const _srvKey = isLocal ? 'local' : (s.host || ''); 1554 const _isDefaultSrv = (defaultServer || '') === _srvKey; 1555 const _pIco = _platformIcon(s.platform); 1556 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;">
1556<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>`; 1557 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>`; 1558 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;">`; 1559 html += `${esc(_srvTitle)}`; 1560 html += _pIco ? `<span class="cookbook-srv-platform" title="${esc(s.platform || '')}" style="display:inline-flex;align-items:center;opacity:0.55;">${_pIco}</span>` : ''; 1561 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>`; 1562 if (isNew) { 1563 // New server: Cancel (discard) sits top-right; the default toggle only makes 1564 // sense once the server is saved. 1565 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;">
1565<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>`; 1566 } else { 1567 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)}">${_isDefaultSrv ? _MODELDIR_CHECK_ON : _MODELDIR_CHECK_OFF}<span class="cookbook-srv-default-label">default</span></span></span>`; 1568 } 1569 html += `</span>`; 1570 html += `<div class="cookbook-server-row">`; 1571 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;" />`; 1572 html += `<input type="text" class="hwfit-sf cookbook-srv-host" value="${isLocal ? '' : esc(s.host || '')}" placeholder="e.g. user@ip" style="width:214.5px;flex-shrink:0;box-sizing:border-box;" ${isLocal ? 'readonly' : ''} />`; 1573 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' : ''} />`; 1574 html += `<select class="hwfit-sf cookbook-srv-env">${envOpts}</select>`; 1575 html += `<input type="text" class="hwfit-sf cookbook-srv-path" value="${esc(s.envPath || '')}" placeholder="${s.platform === 'windows' ? 'venv path' : '~/venv'}" />`; 1576 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>`; 1577 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>`; 1578 html += `</div>`; 1579 const modelDirs = Array.isArray(s.modelDirs) && s.modelDirs.length ? s.modelDirs : ['~/.cache/huggingface/hub']; 1580 const activeDlDir = s.downloadDir || ''; 1581 html += `<div class="cookbook-modeldirs" style="margin:2px 0 0 0;display:flex;flex-wrap:wrap;gap:4px;align-items:center;">`; 1582 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>`; 1583 for (let j = 0; j < modelDirs.length; j++) { 1584 const isDefault = modelDirs[j] === '~/.cache/huggingface/hub'; 1585 const dirVal = isDefault ? '' : modelDirs[j]; 1586 const isTarget = activeDlDir === dirVal; 1587 const dlBtn = `<span class="cookbook-modeldir-dl${isTarget ? ' active' : ''}" title="${isTarget ? 'Downloads go here' : 'Send downloads here'}" data-dl-dir="${esc(dirVal)}">
1587${isTarget ? _MODELDIR_CHECK_ON : _MODELDIR_CHECK_OFF}</span>`; 1588 const rmBtn = isDefault ? '' : ' <span class="cookbook-modeldir-rm" title="Remove">â</span>'; 1589 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>`; 1590 } 1591 html += `<button class="cookbook-modeldir-add" title="Add model directory">+ Add</button>`; 1592 const _btnStyle = 'margin-left:auto;position:relative;top:-2px;height:22px;box-sizing:border-box;display:inline-flex;align-items:center;'; 1593 if (isNew) { 1594 // A brand-new server: Save (confirm) sits where Delete would be; Cancel is 1595 // top-right in the title. Save confirms with a checkmark (auto-saves on edit too). 1596 html += `<button class="cookbook-server-save-btn" title="Save this server" style="${_btnStyle}"><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>`; 1597 } else if (!isLocal) { 1598 html += `<button class="cookbook-server-rm cookbook-server-rm-btn" title="Delete this server" style="${_btnStyle}">
1598<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>`; 1599 } 1600 html += `</div>`; 1601 if (!isLocal) { 1602 html += `<div class="cookbook-server-key-panel hidden" style="margin-top:6px;flex-direction:column;gap:5px;">`; 1603 html += `<div style="display:flex;gap:4px;align-items:center;">`; 1604 html += `<button type="button" class="memory-toolbar-btn cookbook-server-key-gen" style="height:23px;">Generate key</button>`; 1605 html += `<button type="button" class="memory-toolbar-btn cookbook-server-key-copy" style="height:23px;" disabled>Copy command</button>`; 1606 html += `<span style="font-size:10px;opacity:0.55;line-height:1.25;">Docker: run this command in your terminal once.</span>`; 1607 html += `</div>`; 1608 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>`; 1609 html += `</div>`; 1610 } 1611 html += `</div>`; 1612 return html; 1613} 1614 1615function _renderRecipes() { 1616 const body = document.querySelector('#cookbook-modal .cookbook-body'); 1617 if (!body) return; 1618 1619 const presets = _loadPresets(); 1620 const hasSaved = presets.length > 0; 1621 1622 let html = ''; 1623 1624 // Tabs 1625 html += '<div class="cookbook-tabs">'; 1626 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>'; 1627 html += '<button class="cookbook-tab" data-backend="Serve"><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;"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1"/><circle cx="6" cy="18" r="1"/></svg>Serve</button>'; 1628 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>'; 1629 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>'; 1630 html += '</div>'; 1631 1632 // Search group 1633 html += '<div class="cookbook-group" data-backend-group="Search" style="flex:0 0 auto;">'; 1634 html += '<div class="admin-card" style="display:flex;flex-direction:column;overflow:hidden;">'; 1635 // Foldable Download admin-card: clicking the h2 header collapses the 1636 // entire card body (description + download input + HF latest section). 1637 // State persisted to localStorage so the fold survives reloads.
1638 const _dlTabFolded = (() => { try { return localStorage.getItem('cookbook_dl_tab_folded_v1') === '1'; } catch { return false; } })(); 1639 html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">'; 1640 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;">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>`; 1641 html += '</div>'; 1642 html += `<div id="cookbook-dl-tab-fold-body" style="${_dlTabFolded ? 'display:none;' : ''}">`; 1643 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>'; 1644 html += '<div class="hwfit-container" id="hwfit-container">'; 1645 1646 // Section 1: Settings 1647 const _es = _envState; 1648 if (!_es.servers) _es.servers = []; 1649 let _localSeen = false; 1650 _es.servers = _es.servers.filter(s => { 1651 const isLocal = !s.host || s.host.toLowerCase() === 'local'; 1652 if (isLocal) { 1653 s.host = ''; 1654 if (_localSeen) return false; 1655 _localSeen = true; 1656 } 1657 return true; 1658 }); 1659 if (!_localSeen) { 1660 _es.servers.unshift({ host: '', env: _es.env || 'none', envPath: _es.envPath || '', modelDir: '~/.cache/huggingface/hub' }); 1661 } 1662 if (_es.remoteHost && !_es.servers.some(s => s.host === _es.remoteHost)) { 1663 _es.servers.push({ host: _es.remoteHost, env: _es.env || 'none', envPath: _es.envPath || '', modelDir: '~/.cache/huggingface/hub' }); 1664 _persistEnvState(); 1665 } 1666 // NOTE: deliberately do NOT auto-pick the first remote server when no host is 1667 // selected. That fallback turned any momentarily-empty remoteHost (a clobber, 1668 // a render before the user's pick registered) into the first saved server, 1669 // silently sending downloads to the wrong server. An empty selection means Local; the user 1670 // chooses a remote server explicitly via the dropdown. 1671 1672 // Manual download input 1673 html += `<div style="margin-top:7px;margin-bottom:2px;display:flex;gap:4px;align-items:center;">`; 1674 if (_es.servers.length > 1) { 1675 html += `<select class="cookbook-field-input hwfit-dl-server" id="hwfit-dl-server" style="height:28px;position:relative;top:0px;">`; 1676 html += _buildServerOpts(true); 1677 html += `</select>`; 1678 } else { 1679 html += `<input type="hidden" id="hwfit-dl-server" value="local" />`; 1680 } 1681 html += `<button class="memory-toolbar-btn cookbook-dl-add-server" title="Add server in Settings" style="height:28px;">add server</button>`; 1682 html += `</div>`; 1683 html += `<div class="cookbook-dl-input" style="margin-top:0;">`; 1684 html += `<input type="text" class="cookbook-dl-repo" id="cookbook-dl-repo" placeholder="org/model-name, HF URL, or org/model:QUANT_TAG" />`; 1685 html += `<button class="cookbook-btn cookbook-dl-btn" id="cookbook-dl-btn">Download</button>`; 1686 html += `</div>`; 1687 // Latest HF models that fit â collapsible card list 1688 html += `<div style="margin-top:5px;position:relative;top:-3px;">`; 1689 html += `<div style="display:flex;gap:4px;align-items:center;">`; 1690 html += `<button type="button" class="memory-toolbar-btn" id="cookbook-hf-latest-toggle" style="flex:1;text-align:left;height:26px;display:flex;align-items:center;gap:6px;border-radius:4px;">`; 1691 html += `<span id="cookbook-hf-latest-arrow" style="display:inline-block;transition:transform 0.15s;pointer-events:none;">\u25B8</span>`; 1692 html += `<span style="pointer-events:none;">Trending models that fit your hardware</span>`;
1693 html += `</button>`; 1694 html += `<button type="button" class="memory-toolbar-btn" id="cookbook-hf-latest-refresh" title="Refresh" style="height:26px;width:26px;padding:0;border-radius:4px;">\u21BB</button>`; 1695 html += `</div>`; 1696 html += `<div id="cookbook-hf-latest-list" style="display:none;margin-top:4px;max-height:320px;overflow-y:auto;flex-direction:column;gap:4px;"></div>`; 1697 html += `</div>`; 1698 html += `</div>`; // /#cookbook-dl-tab-fold-body (whole Download card body) 1699 1700 // Search section 1701 html += '</div></div></div></div>'; 1702 html += '<div class="cookbook-group" data-backend-group="Search">'; 1703 html += '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">'; 1704 html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">'; 1705 html += '<h2 style="margin:0;padding:0;line-height:1;">Scan / Download</h2>'; 1706 html += '</div>'; 1707 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>'; 1708 html += '<div class="hwfit-toolbar" style="margin-top:9px;">'; 1709 html += '<select class="cookbook-field-input hwfit-usecase" id="hwfit-usecase" style="height:28px;">'; 1710 html += '<option value="general" selected>Standard</option><option value="coding">Coding</option>'; 1711 html += '<option value="reasoning">Reasoning</option><option value="chat">Chat</option>'; 1712 // Image tab removed â textâimage gen is gone from this build (only inpaint 1713 // remains, which uses its own settings panel). Vision (multimodal) stays. 1714 html += '<option value="multimodal">Vision</option></select>'; 1715 // Engine sits next to the type filter so the "what category / which serving 1716 // path" filters live together; Quant + Context are storage-format and budget 1717 // levers, grouped to the right. 1718 html += '<span class="hwfit-engine-wrap">'; 1719 html += '<select class="cookbook-field-input hwfit-engine" id="hwfit-engine" style="height:28px;" title="Filter by serving engine">'; 1720 html += '<option value="">Engine</option>'; 1721 html += '<option value="llamacpp">llama.cpp</option>'; 1722 html += '<option value="vllm">vLLM</option>'; 1723 html += '<option value="sglang">SGLang</option>'; 1724 html += '</select>'; 1725 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>'; 1726 html += '</span>'; 1727 // Quant (Q4/Q8/â¦). Default is "All" so the list shows the best-scoring 1728 // quant for every model instead of silently filtering to Q4. 1729 html += '<span class="hwfit-quant-wrap">'; 1730 html += '<select class="cookbook-field-input hwfit-quant" id="hwfit-quant" style="height:28px;">'; 1731 html += '<option value="" selected>Quant: All</option>'; 1732 html += '<option value="Q4_K_M">Q4</option><option value="Q8_0">Q8</option>'; 1733 html += '<option value="Q6_K">Q6</option><option value="Q5_K_M">Q5</option>'; 1734 html += '<option value="Q3_K_M">Q3</option><option value="Q2_K">Q2</option>'; 1735 html += '<option value="AWQ-4bit">AWQ</option><option value="FP8">FP8</option><option value="FP4">FP4</option><option value="NVFP4">NVFP4</option></select>'; 1736 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>'; 1737 html += '</span>'; 1738 // Ctx slider â lets you target a context length for fit estimates; the 1739 // hwfit ranking uses _ctxValue() to factor that into VRAM math, so 1740 // dragging this re-sorts the list toward models that fit your chosen ctx. 1741 html += '<label class="hwfit-ctx-control" title="Context length for fit estimates. Lower it to find more models that could fit your hardware.">'; 1742 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" />'; 1743 html += '<output id="hwfit-context-label">50k</output></label>'; 1744 // Search lives at the far right of the toolbar so the controls (Type/Quant/ 1745 // Engine/Context) read as a row of compact filters followed by free-text. 1746 html += '<input type="text" class="cookbook-field-input hwfit-search" id="hwfit-search" placeholder="Search models..." style="flex:1;" />'; 1747 html += '</div>'; 1748 html += '<div class="hwfit-toolbar" style="margin-top:7px;">'; 1749 html += '<select class="cookbook-field-input hwfit-server-select" id="hwfit-server-select" style="height:28px;min-width:88px;position:relative;top:0px;">'; 1750 html += _buildServerOpts(false); 1751 html += '</select>'; 1752 html += '<div class="hwfit-gpu-toggles" id="hwfit-gpu-toggles"></div>'; 1753 // Scan/refresh button (icon-only) where the quant dropdown used to sit. 1754 html += '<button type="button" class="hwfit-gpu-btn" id="hwfit-rescan" title="Re-scan hardware" style="flex-shrink:0;position:relative;top:-3px;left:-1px;">â» RESCAN</button>'; 1755 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;">
1755<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>'; 1756 // Sort state â the clickable column headers read/write this (pewds' original 1757 // sort paradigm). Newest is reachable by clicking the Model column header. 1758 html += '<select class="cookbook-field-input hwfit-sort" id="hwfit-sort" style="display:none">'; 1759 html += '<option value="fit">Fit</option><option value="score">Score</option><option value="vram">VRAM</option>'; 1760 html += '<option value="speed">Speed</option><option value="params">Params</option>'; 1761 html += '<option value="context">Context</option></select>'; 1762 html += '</div>'; 1763 html += '<div class="hwfit-manual-panel hidden" id="hwfit-manual-panel">'; 1764 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>'; 1765 html += '<select class="hwfit-manual-mode"><option value="gpu">GPU</option><option value="ram">RAM</option></select>'; 1766 html += '<label>GPUs<input class="hwfit-manual-gpus" type="text" inputmode="numeric" placeholder="1"></label>'; 1767 html += '<label>VRAM per GPU<input class="hwfit-manual-vram" type="text" inputmode="decimal" placeholder="8 GB"></label>'; 1768 html += '<label>Total RAM<input class="hwfit-manual-ram" type="text" inputmode="decimal" placeholder="32 GB"></label>'; 1769 html += '<select class="hwfit-manual-backend"><option value="cuda">CUDA</option><option value="rocm">ROCm</option></select>'; 1770 html += '<button type="button" class="hwfit-hw-manual-save">â Apply</button>'; 1771 html += '<button type="button" class="hwfit-hw-manual-clear">Ã Clear</button>'; 1772 html += '</div>'; 1773 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>'; 1774 html += '<div class="hwfit-list" id="hwfit-list"></div>'; 1775 // Footer: link to the public discussion where users can request additions 1776 // to the curated model list. Sits below the list so it reads as a callout 1777 // after browsing, not a header. 1778 html += '<div class="hwfit-list-footer" style="margin-top:8px;padding-top:6px;border-top:1px solid color-mix(in srgb, var(--border) 50%, transparent);font-size:9.5px;opacity:0.65;text-align:right;">' 1779 + 'Don\'t see a model? ' 1780 + '<a href="https://github.com/pewdiepie-archdaemon/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;">' 1781 + 'Request it â'
1782 + '<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>' 1783 + '</a>' 1784 + '</div>'; 1785 1786 html += '</div></div>'; 1787 1788 // Serve group 1789 html += '<div class="cookbook-group hidden" data-backend-group="Serve">'; 1790 html += '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">'; 1791 html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">'; 1792 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>'; 1793 html += '</div>'; 1794 const _selSrv = _es.servers.find(s => s.host === _es.remoteHost) || _es.servers[0] || {}; 1795 const _srvDirs = (Array.isArray(_selSrv.modelDirs) ? _selSrv.modelDirs : [_selSrv.modelDir || '~/.cache/huggingface/hub']).map(d => d.replaceAll('â', '').replaceAll('â', '').trim()).filter(Boolean); 1796 html += '<div class="cookbook-serve-dirs" style="margin-top:6px;">'; 1797 html += _srvDirs.map(d => `<span class="cookbook-serve-dir-pill">${esc(d)}</span>`).join(''); 1798 html += '<span class="cookbook-serve-dir-edit" title="Edit in Settings">edit</span>'; 1799 html += '</div>'; 1800 html += '<div style="display:flex;gap:4px;align-items:center;margin-top:4px;">'; 1801 html += '<select class="memory-sort-select" id="hwfit-cache-server" style="height:24px;">' + _buildServerOpts(true) + '</select>'; 1802 html += '<select class="memory-sort-select" id="serve-sort" style="height:24px;">'; 1803 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>'; 1804 html += '</select>'; 1805 html += '</div>'; 1806 html += '<div class="memory-toolbar" style="margin-top:8px;">'; 1807 html += '<div class="memory-category-filters">'; 1808 html += '<input type="text" class="memory-search-input" id="serve-search" placeholder="Search cached models\u2026" style="flex:1;min-width:120px;" />'; 1809 html += '<button class="memory-toolbar-btn" id="hwfit-cache-select">Select</button>'; 1810 html += '</div>'; 1811 html += '<div class="doclib-lang-chips" id="serve-tags"></div>'; 1812 html += '</div>'; 1813 1814 html += '<div class="memory-bulk-bar hidden" id="serve-bulk-bar">'; 1815 html += '<label class="memory-bulk-check-all"><input type="checkbox" id="serve-select-all"> All</label>'; 1816 html += '<span id="serve-bulk-count" style="font-size:10px;opacity:0.5;">0 selected</span>'; 1817 html += '<button class="memory-toolbar-btn danger" id="serve-bulk-delete" style="position:relative;top:-3px;">
1817<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>'; 1818 html += '<button class="memory-toolbar-btn" id="serve-bulk-cancel" title="Cancel (Esc)" style="margin-left:4px;padding:3px 6px;position:relative;top:-3px;"><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>'; 1819 html += '</div>'; 1820 1821 html += '<div class="doclib-grid hwfit-cached-list" id="hwfit-cached-list"></div>'; 1822 html += '</div></div>'; 1823 1824 // Dependencies tab 1825 html += '<div class="cookbook-group hidden" data-backend-group="Dependencies">'; 1826 html += '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">'; 1827 html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">'; 1828 html += '<h2 style="margin:0;padding:0;line-height:1;">Dependencies</h2>'; 1829 // Rebuild llama.cpp button moved into the llama_cpp dep row (see _depRow); 1830 // having it in the title polluted the section header. 1831 html += '<span style="font-size:10px;opacity:0.5;margin-left:auto;">Server</span>'; 1832 html += '<select class="cookbook-field-input" id="hwfit-deps-server" style="height:28px;min-width:70px;">'; 1833 html += _buildServerOpts(false); 1834 html += '</select>'; 1835 html += '</div>'; 1836 html += '<p class="memory-desc doclib-desc">Optional packages that extend Odysseus capabilities.</p>'; 1837 html += '<div class="doclib-grid" id="cookbook-deps-list"></div>'; 1838 html += '</div></div>'; 1839 1840 // Settings tab 1841 // Settings tab â split into two separate `.admin-card` blocks so the 1842 // HF Token and Server config look like distinct panels (matches the 1843 // Download tab's block-per-section layout). 1844 html += '<div class="cookbook-group hidden cookbook-settings-stack" data-backend-group="Settings">'; 1845 1846 // ââ HuggingFace Token block âââââââââââââââââââââââââââââââââââââââââ 1847 html += '<div class="admin-card" style="flex:0 0 auto;display:flex;flex-direction:column;">'; 1848 html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">'; 1849 html += '<h2 style="margin:0;padding:0;line-height:1;">HuggingFace Token</h2>'; 1850 html += '</div>'; 1851 html += '<p class="memory-desc doclib-desc">Personal access token for downloading gated and private models.</p>'; 1852 html += '<div class="memory-toolbar">'; 1853 html += `<div style="display:flex;gap:4px;align-items:center;">`; 1854 // Bold green check shown when a token is stored (a placeholder can't style a 1855 // single glyph, so it's its own element next to the input). 1856 if (_es.hfTokenConfigured) { 1857 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>`; 1858 } 1859 const hfPlaceholder = _es.hfTokenConfigured 1860 ? `Stored (${esc(_es.hfTokenMasked || 'configured')}) - enter a new token to replace` 1861 : 'hf_...'; 1862 html += `<input type="password" class="memory-search-input" id="hwfit-hftoken" value="${esc(_es.hfToken || '')}" placeholder="${hfPlaceholder}" style="flex:1;" />`; 1863 html += `</div>`; 1864 html += '</div>'; 1865 html += '</div>'; 1866 1867 // ââ Servers block âââââââââââââââââââââââââââââââââââââââââââââââââââ 1868 html += '<div class="admin-card" style="flex:0 0 auto;display:flex;flex-direction:column;">'; 1869 html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;margin-top:-4px;">'; 1870 html += '<h2 style="margin:0;padding:0;line-height:1;">Servers</h2>'; 1871 // Reuse the calendar +New pill: spinning plus, label fades in idea uses 1872 // the same `.cal-add-btn-text` rules, so styling stays consistent. 1873 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>'; 1874 html += '</div>'; 1875 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>'; 1876 html += '<div class="memory-toolbar cookbook-servers-toolbar" style="margin-top:4px;">'; 1877 html += `<div id="cookbook-servers-list">`; 1878 for (let i = 0; i < _es.servers.length; i++) { 1879 html += _serverEntryHtml(_es.servers[i], i, _es.defaultServer || '', false); 1880 } 1881 html += `</div>`; 1882 html += '</div>'; 1883 1884 html += '</div></div>'; 1885 1886 body.innerHTML = html; 1887 _wireTabEvents(body); 1888 1889 // Auto-init What Fits 1890 _hwfitInit(); 1891 _hwfitFetch(); 1892} 1893 1894// ââ Public API ââ 1895 1896import * as Modals from './modalManager.js'; 1897 1898let _rendered = false;
1899 1900let _closeGen = 0; 1901 1902// ESC while a Serve card is expanded should collapse just that card, not 1903// close the whole Cookbook modal. Capture-phase so we run before the 1904// modal manager's global ESC-to-close handler and can stop it. 1905if (typeof window !== 'undefined' && !window._cookbookServeEscBound) { 1906 window._cookbookServeEscBound = true; 1907 document.addEventListener('keydown', (e) => { 1908 if (e.key !== 'Escape') return; 1909 const modal = document.getElementById('cookbook-modal'); 1910 if (!modal || modal.classList.contains('hidden')) return; 1911 // Layer 1: a model row in the scan/download list is highlighted â 1912 // deselect it before doing anything else. 1913 const activeRow = modal.querySelector('.hwfit-row-active'); 1914 if (activeRow) { 1915 e.stopImmediatePropagation(); 1916 e.preventDefault(); 1917 activeRow.classList.remove('hwfit-row-active'); 1918 return; 1919 } 1920 const expanded = modal.querySelector('.memory-item.doclib-card-expanded'); 1921 if (!expanded) return; // nothing expanded â let the modal close normally 1922 e.stopImmediatePropagation(); 1923 e.preventDefault(); 1924 // Collapse the card (mirror the toggle-close path in cookbookServe.js). 1925 expanded.querySelector('.hwfit-serve-panel')?.remove(); 1926 expanded.classList.remove('doclib-card-expanded'); 1927 expanded.style.flexDirection = ''; 1928 expanded.style.alignItems = ''; 1929 const list = expanded.closest('.hwfit-cached-list') || document.getElementById('hwfit-cached-list'); 1930 if (list) { list.style.minHeight = ''; list.style.maxHeight = ''; } 1931 }, true); // capture 1932} 1933 1934export async function open(opts) { 1935 const modal = document.getElementById('cookbook-modal'); 1936 if (!modal) return; 1937 // Run any post-open intent (switch tab, prefill search, etc) after the 1938 // current render pass so the target elements exist. 1939 const _applyIntent = () => { 1940 if (!opts) return; 1941 if (opts.tab) { 1942 const t = modal.querySelector(`.cookbook-tab[data-backend="${opts.tab}"]`); 1943 if (t && !t.classList.contains('active')) t.click(); 1944 } 1945 if (opts.usecase) { 1946 const u = document.getElementById('hwfit-usecase'); 1947 if (u && u.value !== opts.usecase) { u.value = opts.usecase; u.dispatchEvent(new Event('change', { bubbles: true })); } 1948 } 1949 if (opts.serveSearch) { 1950 const s = document.getElementById('serve-search'); 1951 if (s) { s.value = opts.serveSearch; s.dispatchEvent(new Event('input', { bubbles: true })); } 1952 } 1953 }; 1954 // If minimized, restore in place â preserve all state 1955 if (Modals.isMinimized('cookbook-modal')) { 1956 Modals.restore('cookbook-modal'); 1957 _renderRunningTab(); 1958 setTimeout(_applyIntent, 0); 1959 return; 1960 } 1961 // If already visible, no-op (but still honour the intent) 1962 if (!modal.classList.contains('hidden')) { 1963 setTimeout(_applyIntent, 0); 1964 return; 1965 } 1966 _setCookbookOpening(true); 1967 try { 1968 // Invalidate any pending close() animation handlers so they won't re-hide us 1969 _closeGen++; 1970 // Clear any leftover inline styles from a previous swipe-dismiss or close animation 1971 const _content = modal.querySelector('.modal-content'); 1972 if (_content) { 1973 _content.classList.remove('modal-closing', 'sheet-ready', 'cookbook-modal-entering'); 1974 _content.style.transform = ''; 1975 _content.style.transition = ''; 1976 _content.style.animation = ''; 1977 _content.style.opacity = ''; 1978 } 1979 modal.style.display = ''; 1980 Modals.register('cookbook-modal', { 1981 railBtnId: 'rail-cookbook', 1982 sidebarBtnId: 'tool-cookbook-btn', 1983 closeFn: () => _doClose(), 1984 restoreFn: () => { _renderRunningTab(); }, 1985 }); 1986 _wireCookbookDrag(modal); 1987 await _syncFromServer(); 1988 // `_syncFromServer` lives in cookbookRunning.js and populates *its* _envState 1989 // (a different object reference than this module's), then mirrors the merged 1990 // state to localStorage. So ALWAYS hydrate our _envState from that mirror â 1991 // on a successful sync it holds the freshly-fetched servers; on failure it 1992 // holds the last-known state. Gating this on `!synced` left the render's 1993 // _envState empty whenever sync succeeded â "servers don't show". 1994 try { Object.assign(_envState, _readStoredEnvState()); } catch {} 1995 // Honour a user-set default server: always land on it when Cookbook opens, so 1996 // every dropdown (scan/download/serve/cache/deps) starts on the same machine. 1997 if (_envState.defaultServer) { 1998 const _dk = _envState.defaultServer; 1999 if (_dk === 'local') { 2000 _envState.remoteHost = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = ''; 2001 } else { 2002 const _ds = (_envState.servers || []).find(s => s.host === _dk); 2003 if (_ds) { _envState.remoteHost = _ds.host; _envState.env = _ds.env || 'none'; _envState.envPath = _ds.envPath || ''; _envState.platform = _ds.platform || ''; } 2004 } 2005 } 2006 // Re-render on every open AFTER sync so the freshly-fetched state (servers, 2007 // HF token, presets) is always reflected. Gating this to once-per-page used 2008 // to freeze a stale/empty servers list whenever the first sync raced or 2009 // returned before hydration â and since close/reopen doesn't reset the page, 2010 // only a full reload recovered it. Re-rendering is cheap and the in-progress 2011 // Running tab is rendered separately just below.
2012 _renderRecipes(); 2013 _rendered = true; 2014 _clearCookbookNotif(); 2015 _renderRunningTab(); 2016 // Self-heal: revive any download tasks whose tmux session is still alive 2017 // but were persisted as done/error (covers the "restarted server while a 2018 // big multi-shard download was in flight" case â the task survived in 2019 // tmux, the cookbook just lost track of it). 2020 try { _selfHealStaleTasks({ oneShot: true }); } catch {} 2021 if (_content) { 2022 // Put the panel in its entering state before it becomes visible. On 2023 // mobile, showing first and adding the class a frame later can paint the 2024 // sheet at its final position, which makes the slide-up look like a snap. 2025 _content.classList.add('cookbook-modal-entering'); 2026 } 2027 modal.classList.remove('hidden'); 2028 if (_content) { 2029 void _content.offsetWidth; 2030 _content.addEventListener('animationend', () => { 2031 _content.classList.remove('cookbook-modal-entering'); 2032 }, { once: true }); 2033 } 2034 setTimeout(_applyIntent, 0); 2035 } finally { 2036 _setCookbookOpening(false); 2037 } 2038} 2039 2040// Make the Cookbook modal draggable (it had no drag wiring at all). We do 2041// NOT supply a fsClass fullscreen here â that would cover the whole viewport 2042// incl. the sidebar. Instead tileManager.js handles maximize/tiling (its 2043// safe-rect sits the window NEXT TO the sidebar), same as tasks/gallery/etc. 2044let _cookbookDragWired = false; 2045function _wireCookbookDrag(modal) { 2046 if (_cookbookDragWired || !modal) return; 2047 const content = modal.querySelector('.modal-content'); 2048 const header = modal.querySelector('.modal-header'); 2049 if (!content || !header) return; 2050 _cookbookDragWired = true; 2051 makeWindowDraggable(modal, { 2052 content, header, 2053 skipSelector: '.close-btn, .modal-close', 2054 // Keep only the "close to the edge" dock gesture for Cookbook. The 2055 // tileManager side snap is suppressed for this modal so there isn't a 2056 // second, tighter edge state fighting the working one. 2057 enableDock: true, 2058 }); 2059} 2060 2061function _doClose() { 2062 const modal = document.getElementById('cookbook-modal'); 2063 if (!modal) return; 2064 const content = modal.querySelector('.modal-content'); 2065 const myGen = ++_closeGen; 2066 if (content && !content.classList.contains('modal-closing')) { 2067 content.classList.add('modal-closing'); 2068 content.addEventListener('animationend', () => { 2069 if (myGen !== _closeGen) return; 2070 modal.classList.add('hidden'); 2071 content.classList.remove('modal-closing'); 2072 }, { once: true }); 2073 setTimeout(() => { 2074 if (myGen !== _closeGen) return; 2075 if (!modal.classList.contains('hidden')) { modal.classList.add('hidden'); content.classList.remove('modal-closing'); } 2076 }, 250); 2077 } else { 2078 modal.classList.add('hidden'); 2079 } 2080} 2081 2082export function close() { 2083 // Full close â fires registered closeFn, removes badge, unregisters 2084 if (Modals.isRegistered('cookbook-modal')) { 2085 Modals.close('cookbook-modal'); 2086 } else { 2087 _doClose(); 2088 } 2089} 2090 2091export function isVisible() { 2092 const modal = document.getElementById('cookbook-modal'); 2093 if (!modal) return false; 2094 if (Modals.isMinimized('cookbook-modal')) return false; 2095 return !modal.classList.contains('hidden'); 2096} 2097 2098// Close button 2099document.addEventListener('DOMContentLoaded', () => { 2100 const closeBtn = document.getElementById('close-cookbook-modal'); 2101 if (closeBtn) closeBtn.addEventListener('click', close); 2102 2103 const modal = document.getElementById('cookbook-modal'); 2104 if (modal) { 2105 modal.addEventListener('click', (e) => { 2106 if (uiModule.isTouchInsideModal()) return; 2107 if (e.target === modal) close(); 2108 }); 2109 } 2110}); 2111 2112// ââ Initialize sub-modules ââ 2113 2114// Shared SSH-port resolver â sub-modules use this via the shared bundle 2115// instead of redefining it. Kept here as the single source of truth. 2116function _sshPrefix(port) { 2117 return port && port !== '22' ? `-p ${port} ` : ''; 2118} 2119 2120const shared = { 2121 _envState, 2122 _sshCmd, 2123 _getPort, 2124 _sshPrefix, 2125 _getPlatform, 2126 _isWindows, 2127 _isMetal, 2128 _buildEnvPrefix, 2129 _buildServeCmd, 2130 _shellQuote, 2131 _psQuote, 2132 _detectBackend, 2133 _detectToolParser, 2134 _detectModelOptimizations, 2135 _loadPresets, 2136 _savePresets, 2137 _copyText, 2138 _persistEnvState, 2139 _refreshDependencies: _fetchDependencies, 2140 _getGpuToggleTotal: () => _gpuToggleTotal, 2141 modelLogo, 2142 esc, 2143}; 2144 2145// Init running module (adds task management, auto-fix, launch, background monitor) 2146initRunning({ 2147 ...shared, 2148}); 2149 2150// Init download module (adds SSE, panel rendering, download commands) 2151initDownload({ 2152 ...shared, 2153 _addTask, 2154 _renderRunningTab, 2155 _loadTasks, 2156 _saveTasks, 2157}); 2158 2159// Init serve module (adds cached models, serve panels, launch) 2160initServe({ 2161 ...shared, 2162 _launchServeTask, 2163 _retryDownload, 2164 _nextAvailablePort, 2165}); 2166 2167// ââ Re-exports for cookbook-diagnosis.js and cookbook-hwfit.js ââ 2168// These modules import from cookbook.js, so we re-export what they need 2169 2170export { 2171 _loadTasks, _saveTasks, _addTask, _removeTask, 2172 _tmuxCmd, _renderRunningTab, 2173 _launchServeTask, _serveAutoFix, _serveAutoRetry, _serveAutoRetryReplace, _serveAutoRetryRemove,
2174 _startBackgroundMonitor, 2175 _setPanelField, _setPanelCheckbox, 2176 _wirePanelEvents, _runPanelCmd, _runModelDownload, _buildDownloadCmd, 2177 _serverByVal, _isLocalEntry, 2178}; 2179 2180const cookbookModule = { open, close, isVisible, startBackgroundMonitor: _startBackgroundMonitor }; 2181 2182export 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.