PageSourceSearch

https://odysseus-lxcq.onrender.com/static/js/cookbook.js

js odysseus-lxcq.onrender.com collected 2026-10-03 11:42:55 UTC 204,958 bytes, 3,677 lines download raw bytes

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

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