mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-16 09:45:24 -04:00
Improve Cookbook serve diagnostics and recommendations
This commit is contained in:
+253
-48
@@ -27,6 +27,56 @@ import spinnerModule from './spinner.js';
|
||||
|
||||
// ── Error diagnosis ──
|
||||
|
||||
function _openCookbookDependencies(pkgName = '') {
|
||||
const cookbook = window.cookbookModule;
|
||||
if (cookbook && typeof cookbook.open === 'function') {
|
||||
cookbook.open({ tab: 'Dependencies' });
|
||||
} else {
|
||||
document.getElementById('tool-cookbook-btn')?.click();
|
||||
}
|
||||
|
||||
const wanted = String(pkgName || '').toLowerCase();
|
||||
const tryHighlight = (attempt = 0) => {
|
||||
const modal = document.getElementById('cookbook-modal');
|
||||
const tab = modal?.querySelector('.cookbook-tab[data-backend="Dependencies"]');
|
||||
if (tab && !tab.classList.contains('active')) tab.click();
|
||||
|
||||
const rows = [...document.querySelectorAll('#cookbook-deps-list [data-pkg-name]')];
|
||||
if (!rows.length) {
|
||||
if (attempt < 45) setTimeout(() => tryHighlight(attempt + 1), 100);
|
||||
return;
|
||||
}
|
||||
if (!wanted) return;
|
||||
const row = rows.find(r => {
|
||||
const name = (r.dataset.pkgName || '').toLowerCase();
|
||||
const pip = (r.dataset.depPip || '').toLowerCase();
|
||||
return name === wanted || pip.includes(wanted) || wanted.includes(name);
|
||||
});
|
||||
if (row) {
|
||||
row.scrollIntoView({ block: 'center' });
|
||||
row.classList.add('cookbook-pkg-flash');
|
||||
setTimeout(() => row.classList.remove('cookbook-pkg-flash'), 1800);
|
||||
}
|
||||
};
|
||||
tryHighlight();
|
||||
}
|
||||
|
||||
function _openServeEditFromDiagnosis(panel, fields = null) {
|
||||
const task = panel?.closest?.('.cookbook-task');
|
||||
if (!task) return;
|
||||
task.dispatchEvent(new CustomEvent('cookbook:edit-serve', { bubbles: true, detail: { fields } }));
|
||||
}
|
||||
|
||||
function _openCpuServeEdit(panel) {
|
||||
_openServeEditFromDiagnosis(panel, {
|
||||
backend: 'llamacpp',
|
||||
gpus: '',
|
||||
tp: '1',
|
||||
gpu_mem: '0.80',
|
||||
_forceBackend: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Infer the gated base repo that single-file checkpoints need configs from
|
||||
function _inferBaseRepo(text) {
|
||||
if (!text) return null;
|
||||
@@ -218,6 +268,7 @@ export const ERROR_PATTERNS = [
|
||||
pattern: /vllm.*command not found|No module named vllm/i,
|
||||
message: 'vLLM is not installed or not in PATH.',
|
||||
fixes: [
|
||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('vllm') },
|
||||
{ label: 'Check environment is set', action: (panel) => {
|
||||
const el = panel.querySelector('[data-field="env_type"]');
|
||||
if (el) { el.focus(); el.style.borderColor = 'var(--red)'; }
|
||||
@@ -226,11 +277,21 @@ export const ERROR_PATTERNS = [
|
||||
},
|
||||
{
|
||||
pattern: /sglang.*command not found|No module named sglang|SGLang is not installed/i,
|
||||
message: 'SGLang is not installed or not in PATH. Open Cookbook → Dependencies and install sglang on this server.',
|
||||
message: 'SGLang is not installed or not in PATH.',
|
||||
fixes: [
|
||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') },
|
||||
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install "sglang[all]"') },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /No accelerator \(CUDA, XPU, HPU, NPU, MUSA, MPS\) is available|Triton is not supported on current platform/i,
|
||||
message: 'SGLang needs a visible GPU/accelerator on this server.',
|
||||
suggestion: 'Suggested action: switch this serve config to llama.cpp for CPU/local serving, or choose a GPU server.',
|
||||
fixes: [
|
||||
{ label: 'Switch to llama.cpp', action: (panel) => _openCpuServeEdit(panel) },
|
||||
{ label: 'Choose GPU server', action: (panel) => _openServeEditFromDiagnosis(panel) },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /flashinfer.*version.*does not match|flashinfer-cubin version/i,
|
||||
message: 'FlashInfer version mismatch.',
|
||||
@@ -241,8 +302,12 @@ export const ERROR_PATTERNS = [
|
||||
},
|
||||
{
|
||||
pattern: /torch\.cuda\.is_available\(\).*False|No CUDA runtime/i,
|
||||
message: 'CUDA not available in this environment.',
|
||||
fixes: [],
|
||||
message: 'vLLM needs a visible CUDA/ROCm GPU.',
|
||||
suggestion: 'Suggested action: switch this serve config to llama.cpp for CPU/local serving, or choose a GPU server.',
|
||||
fixes: [
|
||||
{ label: 'Switch to llama.cpp', action: (panel) => _openCpuServeEdit(panel) },
|
||||
{ label: 'Choose GPU server', action: (panel) => _openServeEditFromDiagnosis(panel) },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /Engine core initialization failed/i,
|
||||
@@ -295,17 +360,20 @@ export const ERROR_PATTERNS = [
|
||||
},
|
||||
{
|
||||
pattern: /Either a revision or a version must be specified|transformers\.integrations\.hub_kernels|kernels\/layer/i,
|
||||
message: 'vLLM/Transformers kernel package mismatch.',
|
||||
message: 'Transformers/kernels package mismatch.',
|
||||
fixes: [
|
||||
{ label: 'Update vLLM/Transformers/kernels', action: (panel) => {
|
||||
{ label: 'Repair kernel package', action: (panel) => {
|
||||
const taskEl = panel.closest('.cookbook-task');
|
||||
const task = taskEl ? _loadTasks().find(t => t.sessionId === taskEl.dataset.taskId) : null;
|
||||
const host = task?.remoteHost || '';
|
||||
const prefix = _buildEnvPrefix();
|
||||
const pipCmd = prefix ? prefix + ' python3 -m pip install -U vllm transformers kernels' : 'python3 -m pip install -U vllm transformers kernels';
|
||||
const pipCmd = prefix
|
||||
? prefix + ' python3 -m pip install --user --break-system-packages "kernels<0.15"'
|
||||
: 'python3 -m pip install --user --break-system-packages "kernels<0.15"';
|
||||
const cmd = host ? _sshCmd(host, pipCmd) : pipCmd;
|
||||
_launchServeTask('update-vllm-stack', 'pip-update', cmd);
|
||||
_launchServeTask('repair-kernels', 'pip-update', cmd);
|
||||
}},
|
||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -319,13 +387,24 @@ export const ERROR_PATTERNS = [
|
||||
pattern: /llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'/i,
|
||||
message: 'llama-cpp-python server is not installed. Run: pip install "llama-cpp-python[server]"',
|
||||
fixes: [
|
||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('llama_cpp') },
|
||||
{ label: 'Copy install command', action: () => _copyText('pip install "llama-cpp-python[server]"') },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /CUDA Toolkit not found|Unable to find cudart library|missing:\s*CUDA_CUDART/i,
|
||||
message: 'llama.cpp found nvcc, but the CUDA runtime library is missing.',
|
||||
suggestion: 'Suggested action: relaunch with the updated runner so llama.cpp builds CPU-only, or install a complete CUDA toolkit/runtime on this server for GPU llama.cpp.',
|
||||
fixes: [
|
||||
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
|
||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('llama_cpp') },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /No module named ['"]?torch|No module named ['"]?diffusers|diffusers.*command not found/i,
|
||||
message: 'Diffusion serving needs PyTorch and diffusers. Install diffusers from Cookbook → Dependencies.',
|
||||
fixes: [
|
||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('diffusers') },
|
||||
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install "diffusers[torch]"') },
|
||||
],
|
||||
},
|
||||
@@ -402,10 +481,32 @@ export function _diagnose(text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function _diagnosisCopyBundle(task, diagnosis, sourceText, suggestionText) {
|
||||
const lines = ['## Odysseus Cookbook troubleshooting'];
|
||||
if (task) {
|
||||
lines.push(
|
||||
'',
|
||||
'### Task',
|
||||
`- ID: ${task.sessionId || task.id || 'unknown'}`,
|
||||
`- Type: ${task.type || 'unknown'}`,
|
||||
`- Status: ${task.status || 'unknown'}`,
|
||||
`- Model: ${task.payload?.repo_id || task.name || 'unknown'}`,
|
||||
`- Host: ${task.remoteHost || 'local'}${task.sshPort ? `:${task.sshPort}` : ''}`,
|
||||
);
|
||||
}
|
||||
lines.push('', '### Diagnosis', diagnosis?.message || '(none)');
|
||||
if (suggestionText) lines.push('', '### Suggested action', suggestionText.replace(/^Suggested action:\s*/i, ''));
|
||||
const cmd = task?.payload?._cmd || '';
|
||||
if (cmd) lines.push('', '### Launch command', '```bash', cmd, '```');
|
||||
if (sourceText) lines.push('', '### Captured output', '```text', String(sourceText).trim(), '```');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function _showDiagnosis(panel, diagnosis, sourceText) {
|
||||
if (panel._lastDiagMsg === diagnosis.message) return;
|
||||
if (panel._diagDismissed === diagnosis.message) return; // stay dismissed until new error
|
||||
const wasCollapsed = panel._lastDiagMsg === diagnosis.message && panel._diagCollapsed;
|
||||
if (panel._diagDismissed === diagnosis.message) return;
|
||||
panel._lastDiagMsg = diagnosis.message;
|
||||
panel._diagCollapsed = !!wasCollapsed;
|
||||
|
||||
let diag = panel.querySelector('.cookbook-diagnosis');
|
||||
if (!diag) {
|
||||
@@ -417,57 +518,161 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
|
||||
}
|
||||
diag.classList.remove('hidden');
|
||||
diag.innerHTML = '';
|
||||
const taskEl = panel?.closest?.('.cookbook-task');
|
||||
const task = taskEl ? _loadTasks().find(t => t.sessionId === taskEl.dataset.taskId) : null;
|
||||
const fixes = [...(diagnosis.fixes || [])];
|
||||
if (task?.type === 'serve' && task.payload?._cmd && !fixes.some(f => f.label === 'Edit serve')) {
|
||||
fixes.push({ label: 'Edit serve', action: (p) => _openServeEditFromDiagnosis(p) });
|
||||
}
|
||||
const suggestionText = diagnosis.suggestion || (fixes.length
|
||||
? `Suggested action: ${fixes[0].label}.`
|
||||
: 'Suggested action: copy the error and adjust the serve settings.');
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;';
|
||||
header.className = 'cookbook-diag-header';
|
||||
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'cookbook-diag-message';
|
||||
msg.textContent = diagnosis.message;
|
||||
header.appendChild(msg);
|
||||
const fold = document.createElement('button');
|
||||
fold.className = 'cookbook-diag-fold';
|
||||
fold.type = 'button';
|
||||
fold.innerHTML = '<span class="cookbook-diag-chevron">▾</span><span>Error message:</span>';
|
||||
header.appendChild(fold);
|
||||
|
||||
const copy = document.createElement('button');
|
||||
copy.className = 'cookbook-diag-copy';
|
||||
copy.type = 'button';
|
||||
copy.title = 'Copy troubleshooting bundle';
|
||||
copy.setAttribute('aria-label', 'Copy troubleshooting bundle');
|
||||
copy.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.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>';
|
||||
copy.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_copyText(_diagnosisCopyBundle(task, diagnosis, sourceText, suggestionText));
|
||||
copy.classList.add('copied');
|
||||
copy.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
setTimeout(() => {
|
||||
if (!copy.isConnected) return;
|
||||
copy.classList.remove('copied');
|
||||
copy.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.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>';
|
||||
}, 1200);
|
||||
});
|
||||
header.appendChild(copy);
|
||||
|
||||
const dismiss = document.createElement('button');
|
||||
dismiss.className = 'close-btn';
|
||||
dismiss.style.cssText = 'width:16px;height:16px;font-size:9px;flex-shrink:0;';
|
||||
dismiss.textContent = '\u2715';
|
||||
dismiss.addEventListener('click', () => { panel._diagDismissed = diagnosis.message; _clearDiagnosis(panel); });
|
||||
dismiss.className = 'cookbook-diag-dismiss';
|
||||
dismiss.type = 'button';
|
||||
dismiss.title = 'Dismiss error';
|
||||
dismiss.setAttribute('aria-label', 'Dismiss error');
|
||||
dismiss.textContent = '×';
|
||||
dismiss.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
panel._diagDismissed = diagnosis.message;
|
||||
_clearDiagnosis(panel);
|
||||
});
|
||||
header.appendChild(dismiss);
|
||||
|
||||
diag.appendChild(header);
|
||||
|
||||
if (diagnosis.fixes && diagnosis.fixes.length) {
|
||||
const body = document.createElement('div');
|
||||
body.className = 'cookbook-diag-body';
|
||||
body.classList.toggle('hidden', panel._diagCollapsed);
|
||||
fold.querySelector('.cookbook-diag-chevron').textContent = panel._diagCollapsed ? '▸' : '▾';
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'cookbook-diag-message';
|
||||
msg.textContent = diagnosis.message;
|
||||
body.appendChild(msg);
|
||||
const suggestion = document.createElement('div');
|
||||
suggestion.className = 'cookbook-diag-suggestion';
|
||||
suggestion.textContent = suggestionText;
|
||||
body.appendChild(suggestion);
|
||||
fold.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
panel._diagCollapsed = !panel._diagCollapsed;
|
||||
body.classList.toggle('hidden', panel._diagCollapsed);
|
||||
fold.querySelector('.cookbook-diag-chevron').textContent = panel._diagCollapsed ? '▸' : '▾';
|
||||
});
|
||||
diag.appendChild(body);
|
||||
|
||||
const runFix = async (fix, button, busyLabel = fix.label, onStart = null, onDone = null) => {
|
||||
if (!fix || !button || button.dataset.busy) return;
|
||||
button.dataset.busy = '1';
|
||||
const _orig = button.textContent;
|
||||
const wp = spinnerModule.createWhirlpool(12);
|
||||
wp.element.style.cssText = 'display:inline-block;vertical-align:middle;width:12px;height:12px;margin-right:5px;';
|
||||
button.textContent = '';
|
||||
button.appendChild(wp.element);
|
||||
const _lbl = document.createElement('span');
|
||||
_lbl.textContent = busyLabel;
|
||||
_lbl.style.verticalAlign = 'middle';
|
||||
button.appendChild(_lbl);
|
||||
try {
|
||||
if (typeof onStart === 'function') onStart();
|
||||
await fix.action(panel, sourceText);
|
||||
} catch (err) {
|
||||
console.error('[cookbook] diagnosis fix failed', err);
|
||||
} finally {
|
||||
if (button.isConnected) {
|
||||
try { wp.destroy(); } catch {}
|
||||
button.textContent = _orig;
|
||||
delete button.dataset.busy;
|
||||
}
|
||||
if (typeof onDone === 'function') onDone();
|
||||
}
|
||||
};
|
||||
|
||||
if (fixes.length) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'cookbook-diag-fixes';
|
||||
for (const fix of diagnosis.fixes) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'cookbook-btn cookbook-diag-btn';
|
||||
btn.textContent = fix.label;
|
||||
btn.addEventListener('click', async () => {
|
||||
if (btn.dataset.busy) return;
|
||||
btn.dataset.busy = '1';
|
||||
// Spinner feedback while the fix runs (kill + relaunch takes a moment).
|
||||
const _orig = btn.textContent;
|
||||
const wp = spinnerModule.createWhirlpool(12);
|
||||
wp.element.style.cssText = 'display:inline-block;vertical-align:middle;width:12px;height:12px;margin-right:5px;';
|
||||
btn.textContent = '';
|
||||
btn.appendChild(wp.element);
|
||||
const _lbl = document.createElement('span');
|
||||
_lbl.textContent = _orig;
|
||||
_lbl.style.verticalAlign = 'middle';
|
||||
btn.appendChild(_lbl);
|
||||
try {
|
||||
await fix.action(panel, sourceText);
|
||||
} catch (e) {
|
||||
console.error('[cookbook] diagnosis fix failed', e);
|
||||
} finally {
|
||||
// Retries animate the whole card away (button goes with it). For fixes
|
||||
// that leave the card in place, restore the label.
|
||||
if (btn.isConnected) { try { wp.destroy(); } catch {} btn.textContent = _orig; delete btn.dataset.busy; }
|
||||
}
|
||||
});
|
||||
row.appendChild(btn);
|
||||
|
||||
if (fixes.length <= 3) {
|
||||
for (const fix of fixes) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'cookbook-btn cookbook-diag-btn';
|
||||
btn.type = 'button';
|
||||
btn.textContent = fix.label;
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
runFix(fix, btn);
|
||||
});
|
||||
row.appendChild(btn);
|
||||
}
|
||||
body.appendChild(row);
|
||||
return;
|
||||
}
|
||||
diag.appendChild(row);
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'cookbook-diag-actions';
|
||||
|
||||
const trigger = document.createElement('button');
|
||||
trigger.className = 'cookbook-btn cookbook-diag-action-trigger';
|
||||
trigger.type = 'button';
|
||||
trigger.textContent = 'Actions';
|
||||
trigger.appendChild(document.createTextNode(' ▾'));
|
||||
wrap.appendChild(trigger);
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'dropdown cookbook-diag-menu hidden';
|
||||
for (const fix of fixes) {
|
||||
const item = document.createElement('button');
|
||||
item.type = 'button';
|
||||
item.textContent = fix.label;
|
||||
item.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (item.dataset.busy || trigger.dataset.busy) return;
|
||||
item.dataset.busy = '1';
|
||||
await runFix(fix, trigger, fix.label, () => menu.classList.add('hidden'), () => delete item.dataset.busy);
|
||||
});
|
||||
menu.appendChild(item);
|
||||
}
|
||||
wrap.appendChild(menu);
|
||||
trigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (trigger.dataset.busy) return;
|
||||
document.querySelectorAll('.cookbook-diag-menu').forEach(m => {
|
||||
if (m !== menu) m.classList.add('hidden');
|
||||
});
|
||||
menu.classList.toggle('hidden');
|
||||
});
|
||||
row.appendChild(wrap);
|
||||
body.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -193,6 +193,8 @@ export function _renderGpuToggles(system) {
|
||||
if (quantSel) {
|
||||
if (count <= 1) {
|
||||
quantSel.value = 'Q4_K_M'; // RAM or 1 GPU -> Q4 sweet spot
|
||||
} else if (String(system?.backend || '').toLowerCase() === 'rocm') {
|
||||
quantSel.value = 'Q4_K_M'; // ROCm default stays GGUF/local-safe; AWQ is explicit only
|
||||
} else {
|
||||
quantSel.value = 'AWQ-4bit'; // Multi-GPU -> AWQ for vLLM
|
||||
}
|
||||
|
||||
+54
-28
@@ -260,12 +260,31 @@ export function _detectBackend(model) {
|
||||
const q = (model.quant || '').toUpperCase();
|
||||
const sysBackend = String(_hwfitCache?.system?.backend || '').toLowerCase();
|
||||
const isRocm = sysBackend === 'rocm';
|
||||
const isAppleSilicon = ['metal', 'mps', 'apple'].includes(sysBackend);
|
||||
const _nm = `${model.repo_id || ''} ${model.path || ''} ${model.name || ''}`.toLowerCase();
|
||||
if (!isAppleSilicon && (/\bmlx\b|mlx-|_mlx/i.test(_nm) || q.startsWith('MLX'))) {
|
||||
return { backend: 'unsupported', label: 'Unsupported' };
|
||||
}
|
||||
const isAwqLike = /^AWQ|^GPTQ|^NVFP4/.test(q) || q === 'FP8' || /\b(awq|gptq|fp8|nvfp4)\b/i.test(_nm);
|
||||
const isGgufLike = model.is_gguf || /^Q[2-8]/.test(q) || /^IQ/.test(q) || q === 'GGUF' || _nm.includes('gguf');
|
||||
|
||||
// Image gen models → diffusers
|
||||
if (model.is_image_gen || model.is_diffusion || model._tag === 'image') {
|
||||
return { backend: 'diffusers', label: 'Diffusers' };
|
||||
}
|
||||
|
||||
// AWQ / GPTQ / FP8 are safetensors GPU-serving formats. Never route them
|
||||
// through llama.cpp/Ollama just because the host is Mac/Windows; those engines
|
||||
// need GGUF. The UI will warn/block on Metal where vLLM/SGLang aren't viable.
|
||||
if (isAwqLike) {
|
||||
return { backend: 'vllm', label: 'vLLM' };
|
||||
}
|
||||
|
||||
// GGUF → llama.cpp/Ollama-compatible.
|
||||
if (isGgufLike) {
|
||||
return { backend: 'llamacpp', label: 'llama.cpp' };
|
||||
}
|
||||
|
||||
// Windows → default to llama.cpp (no vLLM support on Windows)
|
||||
if (_isWindows()) {
|
||||
return { backend: 'llamacpp', label: 'llama.cpp' };
|
||||
@@ -278,19 +297,6 @@ export function _detectBackend(model) {
|
||||
return { backend: 'llamacpp', label: 'llama.cpp' };
|
||||
}
|
||||
|
||||
// AWQ / GPTQ / FP8 → vLLM
|
||||
if (/^AWQ|^GPTQ/.test(q) || q === 'FP8') {
|
||||
return { backend: 'vllm', label: 'vLLM' };
|
||||
}
|
||||
|
||||
// GGUF → llama.cpp. Match the quant tag OR a gguf hint in the repo/path/name:
|
||||
// a raw .gguf file often has no quant field, which made it fall through to the
|
||||
// vLLM default below.
|
||||
const _nm = `${model.repo_id || ''} ${model.path || ''} ${model.name || ''}`.toLowerCase();
|
||||
if (model.is_gguf || /^Q[2-8]/.test(q) || /^IQ/.test(q) || q === 'GGUF' || _nm.includes('gguf')) {
|
||||
return { backend: 'llamacpp', label: 'llama.cpp' };
|
||||
}
|
||||
|
||||
// ROCm/AMD machines should not blindly default HF safetensors models to
|
||||
// vLLM. SGLang is the safer OpenAI-compatible default for plain HF text
|
||||
// repos there; llama.cpp still wins above whenever the model is GGUF.
|
||||
@@ -1020,6 +1026,16 @@ function _wireTabEvents(body) {
|
||||
// Download input
|
||||
const dlBtn = document.getElementById('cookbook-dl-btn');
|
||||
const dlInput = document.getElementById('cookbook-dl-repo');
|
||||
const dlCardToggle = document.getElementById('cookbook-download-card-toggle');
|
||||
const dlCardBody = document.getElementById('cookbook-download-card-body');
|
||||
const dlCardArrow = document.getElementById('cookbook-download-card-arrow');
|
||||
if (dlCardToggle && dlCardBody) {
|
||||
dlCardToggle.addEventListener('click', () => {
|
||||
const isOpen = dlCardBody.style.display !== 'none';
|
||||
dlCardBody.style.display = isOpen ? 'none' : 'block';
|
||||
if (dlCardArrow) dlCardArrow.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)';
|
||||
});
|
||||
}
|
||||
if (dlBtn && dlInput) {
|
||||
function _stripHfUrl(input) {
|
||||
let repo = input.trim();
|
||||
@@ -1099,8 +1115,12 @@ function _wireTabEvents(body) {
|
||||
if (hfToggle && hfList) {
|
||||
let _loaded = false;
|
||||
// Per-server VRAM cache so we don't re-probe on every expand
|
||||
const _vramCache = {};
|
||||
async function _getSelectedServerVram() {
|
||||
const _hwCache = {};
|
||||
function _hfModelLooksAwqLike(m) {
|
||||
const text = `${m?.repo_id || ''} ${(m?.tags || []).join(' ')}`.toLowerCase();
|
||||
return /\b(awq|gptq|fp8|4bit|int4)\b/.test(text);
|
||||
}
|
||||
async function _getSelectedServerHw() {
|
||||
// Prefer the "What Fits" dropdown (the main control that shows hardware);
|
||||
// fall back to the download dropdown. This is the server the list ranks for.
|
||||
const dlSrv = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server');
|
||||
@@ -1117,7 +1137,7 @@ function _wireTabEvents(body) {
|
||||
}
|
||||
}
|
||||
const cacheKey = host || 'local';
|
||||
if (_vramCache[cacheKey] !== undefined) return _vramCache[cacheKey];
|
||||
if (_hwCache[cacheKey]) return _hwCache[cacheKey];
|
||||
// Fetch system info for this server from hwfit
|
||||
try {
|
||||
const qp = new URLSearchParams();
|
||||
@@ -1127,13 +1147,13 @@ function _wireTabEvents(body) {
|
||||
const r = await fetch(`/api/hwfit/system?${qp}`);
|
||||
if (r.ok) {
|
||||
const sys = await r.json();
|
||||
const v = sys?.gpu_vram_gb || 0;
|
||||
_vramCache[cacheKey] = v;
|
||||
return v;
|
||||
const hw = { vram: sys?.gpu_vram_gb || 0, backend: String(sys?.backend || '').toLowerCase() };
|
||||
_hwCache[cacheKey] = hw;
|
||||
return hw;
|
||||
}
|
||||
} catch {}
|
||||
_vramCache[cacheKey] = 0;
|
||||
return 0;
|
||||
_hwCache[cacheKey] = { vram: 0, backend: '' };
|
||||
return _hwCache[cacheKey];
|
||||
}
|
||||
async function _loadLatest() {
|
||||
// Match the Dependencies loader: whirlpool spinner + text label so the
|
||||
@@ -1152,7 +1172,8 @@ function _wireTabEvents(body) {
|
||||
} catch {
|
||||
hfList.innerHTML = '<div class="hwfit-loading">Scanning models…</div>';
|
||||
}
|
||||
const vram = await _getSelectedServerVram();
|
||||
const hwInfo = await _getSelectedServerHw();
|
||||
const vram = hwInfo.vram || 0;
|
||||
try {
|
||||
let lastErr = '';
|
||||
const _fetchLatest = async (v) => {
|
||||
@@ -1168,6 +1189,9 @@ function _wireTabEvents(body) {
|
||||
if (!models.length && vram > 0) {
|
||||
models = await _fetchLatest(0);
|
||||
}
|
||||
if (['rocm', 'metal', 'mps', 'apple', 'generic', 'cpu'].includes(hwInfo.backend)) {
|
||||
models = models.filter(m => !_hfModelLooksAwqLike(m));
|
||||
}
|
||||
if (!models.length) {
|
||||
// Distinguish "the HF API failed" from "nothing matched" so an outage
|
||||
// doesn't masquerade as no-fitting-models.
|
||||
@@ -1351,10 +1375,12 @@ function _renderRecipes() {
|
||||
// Search group
|
||||
html += '<div class="cookbook-group" data-backend-group="Search" style="flex:0 0 auto;">';
|
||||
html += '<div class="admin-card" style="display:flex;flex-direction:column;overflow:hidden;">';
|
||||
html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">';
|
||||
html += '<button type="button" id="cookbook-download-card-toggle" style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;width:100%;background:transparent;border:0;padding:0;color:inherit;text-align:left;cursor:pointer;">';
|
||||
html += '<h2 style="margin:0;padding:0;line-height:1;">Download</h2>';
|
||||
html += '</div>';
|
||||
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>';
|
||||
html += '<span id="cookbook-download-card-arrow" style="margin-left:auto;display:inline-block;transition:transform 0.15s;font-size:13px;line-height:1;">\u25B8</span>';
|
||||
html += '</button>';
|
||||
html += '<div id="cookbook-download-card-body" style="display:none;">';
|
||||
html += '<p class="memory-desc doclib-desc" style="margin-top:6px;">Download directly from Scan, or paste a HuggingFace model link.</p>';
|
||||
html += '<div class="hwfit-container" id="hwfit-container">';
|
||||
|
||||
// Section 1: Settings
|
||||
@@ -1383,7 +1409,7 @@ function _renderRecipes() {
|
||||
// silently sending downloads to the wrong server. An empty selection means Local; the user
|
||||
// chooses a remote server explicitly via the dropdown.
|
||||
|
||||
// Download input
|
||||
// Manual download input
|
||||
html += `<div style="margin-top:7px;margin-bottom:2px;display:flex;gap:4px;align-items:center;">`;
|
||||
if (_es.servers.length > 1) {
|
||||
html += `<select class="cookbook-field-input hwfit-dl-server" id="hwfit-dl-server" style="height:28px;position:relative;top:0px;">`;
|
||||
@@ -1399,7 +1425,7 @@ function _renderRecipes() {
|
||||
html += `<button class="cookbook-btn cookbook-dl-btn" id="cookbook-dl-btn">Download</button>`;
|
||||
html += `</div>`;
|
||||
// Latest HF models that fit — collapsible card list
|
||||
html += `<div style="margin-top:2px;position:relative;top:-8px;">`;
|
||||
html += `<div style="margin-top:5px;position:relative;top:-3px;">`;
|
||||
html += `<div style="display:flex;gap:4px;align-items:center;">`;
|
||||
html += `<button type="button" class="memory-toolbar-btn" id="cookbook-hf-latest-toggle" style="flex:1;text-align:left;height:26px;display:flex;align-items:center;gap:6px;border-radius:4px;">`;
|
||||
html += `<span id="cookbook-hf-latest-arrow" style="display:inline-block;transition:transform 0.15s;pointer-events:none;">\u25B8</span>`;
|
||||
@@ -1411,7 +1437,7 @@ function _renderRecipes() {
|
||||
html += `</div>`;
|
||||
|
||||
// Search section
|
||||
html += '</div></div></div>';
|
||||
html += '</div></div></div></div>';
|
||||
html += '<div class="cookbook-group" data-backend-group="Search">';
|
||||
html += '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">';
|
||||
html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">';
|
||||
|
||||
@@ -86,6 +86,9 @@ function _ggufIncludePattern(model, source) {
|
||||
|
||||
function _missingGgufMessage(model) {
|
||||
const name = model?.name || 'this model';
|
||||
if (/\bnvfp4\b/i.test(name)) {
|
||||
return `${name} is an NVIDIA NVFP4 checkpoint, not a GGUF download. Pick the base model row with an Unsloth GGUF source, or paste the GGUF repo directly.`;
|
||||
}
|
||||
return `No GGUF source is configured for ${name}. Pick a model with a GGUF source, or paste the GGUF repo in Download.`;
|
||||
}
|
||||
|
||||
|
||||
+173
-44
@@ -34,12 +34,106 @@ function _taskBadge(task) {
|
||||
return { text: _statusLabel(task.status, task.type), cls: 'cookbook-task-' + task.status };
|
||||
}
|
||||
|
||||
function _canClearTask(task) {
|
||||
if (!task || task.status === 'running') return false;
|
||||
if (task.type === 'serve' && (task.status === 'ready' || task._serveReady)) return false;
|
||||
if (task.type === 'download' && task.status === 'done' && !task.payload?._dep) return false;
|
||||
return ['done', 'stopped', 'error', 'crashed', 'failed'].includes(task.status);
|
||||
}
|
||||
|
||||
function _clearPillLabel(task) {
|
||||
return 'clear';
|
||||
}
|
||||
|
||||
function _shouldOfferCrashReport(task) {
|
||||
if (!task) return false;
|
||||
if (task._unreachable && task.type === 'serve') return true;
|
||||
return ['error', 'crashed', 'failed'].includes(task.status);
|
||||
}
|
||||
|
||||
function _serveTaskLooksAwqOnLocalBackend(task, outputText = '') {
|
||||
const repo = `${task?.payload?.repo_id || ''} ${task?.name || ''}`.toLowerCase();
|
||||
const cmd = `${task?.payload?._cmd || ''} ${outputText || ''}`.toLowerCase();
|
||||
return /\b(awq|gptq|fp8)\b/.test(repo) && /(llama-server|llama_cpp\.server|ollama|ggml_cuda_enable_unified_memory)/.test(cmd);
|
||||
}
|
||||
|
||||
function _serveTaskLooksAwqWithoutUsableAccelerator(task, outputText = '') {
|
||||
const repo = `${task?.payload?.repo_id || ''} ${task?.name || ''}`.toLowerCase();
|
||||
const out = String(outputText || '').toLowerCase();
|
||||
return /\b(awq|gptq|fp8)\b/.test(repo)
|
||||
&& /(no accelerator|no cuda runtime|failed to infer device type|triton is not supported|0 active driver)/i.test(out);
|
||||
}
|
||||
|
||||
async function _openDownloadForGgufTask(task) {
|
||||
const raw = task?.payload?.repo_id || task?.name || '';
|
||||
const modelName = String(raw)
|
||||
.split('/').pop()
|
||||
.replace(/[-_](?:AWQ|GPTQ|FP8|4bit|8bit|Int4|Int8).*$/i, '')
|
||||
.replace(/[-_]+$/g, '')
|
||||
|| String(raw).split('/').pop()
|
||||
|| raw;
|
||||
const cookbook = window.cookbookModule;
|
||||
if (cookbook && typeof cookbook.open === 'function') {
|
||||
cookbook.open({ tab: 'Search' });
|
||||
} else {
|
||||
document.getElementById('tool-cookbook-btn')?.click();
|
||||
}
|
||||
setTimeout(async () => {
|
||||
const modal = document.getElementById('cookbook-modal');
|
||||
const tab = modal?.querySelector('.cookbook-tab[data-backend="Search"]');
|
||||
if (tab && !tab.classList.contains('active')) tab.click();
|
||||
const search = document.getElementById('hwfit-search');
|
||||
if (search) {
|
||||
search.value = modelName;
|
||||
search.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
search.focus();
|
||||
}
|
||||
const quant = document.getElementById('hwfit-quant');
|
||||
if (quant) {
|
||||
quant.value = 'Q4_K_M';
|
||||
quant.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
try {
|
||||
const hwfit = await import('./cookbook-hwfit.js');
|
||||
if (typeof hwfit._hwfitFetch === 'function') hwfit._hwfitFetch(true);
|
||||
} catch {}
|
||||
}, 80);
|
||||
}
|
||||
|
||||
function _terminalServeDiagnosis(task, outputText) {
|
||||
const out = String(outputText || task?.output || '');
|
||||
if (!task || task.type !== 'serve' || !['stopped', 'error', 'crashed', 'failed'].includes(task.status) || !out.trim()) return null;
|
||||
if (_serveTaskLooksAwqOnLocalBackend(task, out)) {
|
||||
return {
|
||||
message: 'AWQ/GPTQ/FP8 cannot be served through llama.cpp/Ollama unified-memory mode.',
|
||||
suggestion: 'Suggested action: use vLLM/SGLang on a compatible CUDA/ROCm GPU server, or download a GGUF version for llama.cpp/Ollama/unified-memory serving.',
|
||||
fixes: [
|
||||
{ label: 'Find GGUF download', action: () => _openDownloadForGgufTask(task) },
|
||||
{ label: 'Edit serve', action: (panel) => _openServeEditForTask(task) },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (_serveTaskLooksAwqWithoutUsableAccelerator(task, out)) {
|
||||
return {
|
||||
message: 'AWQ/GPTQ/FP8 needs a working vLLM/SGLang accelerator path; this server did not expose one.',
|
||||
suggestion: 'Suggested action: choose a CUDA/ROCm server where vLLM/SGLang can see the GPU, or download a GGUF version and serve it with llama.cpp/Ollama.',
|
||||
fixes: [
|
||||
{ label: 'Find GGUF download', action: () => _openDownloadForGgufTask(task) },
|
||||
{ label: 'Edit serve', action: (panel) => _openServeEditForTask(task) },
|
||||
],
|
||||
};
|
||||
}
|
||||
return _diagnose(out) || {
|
||||
message: /Native llama-server not found|building llama-server|llama\.cpp/i.test(out)
|
||||
? 'llama.cpp build stopped before the server became reachable.'
|
||||
: 'Serve stopped before the model became reachable.',
|
||||
suggestion: /Native llama-server not found|building llama-server|llama\.cpp/i.test(out)
|
||||
? 'Suggested action: copy the troubleshooting bundle, then edit serve settings. For the quickest local/CPU path, use Ollama or a prebuilt llama-server; source builds can take several minutes and fail if build dependencies are incomplete.'
|
||||
: 'Suggested action: copy the troubleshooting bundle, then edit serve settings or relaunch with a CPU/backend fallback.',
|
||||
fixes: [{ label: 'Edit serve', action: (panel) => _openServeEditForTask(task) }],
|
||||
};
|
||||
}
|
||||
|
||||
function _redactCrashReportText(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
@@ -173,6 +267,23 @@ export function _parseServePhase(snapshot) {
|
||||
if (/Ollama API ready on port\s+\d+/i.test(flat)) {
|
||||
return { phase: 'ready', status: 'ready' };
|
||||
}
|
||||
const llamaBuildMatches = [...flat.matchAll(/\[\s*(\d{1,3})%\]\s*(?:Building|Linking)/gi)];
|
||||
if (llamaBuildMatches.length) {
|
||||
const pct = Math.min(100, parseInt(llamaBuildMatches[llamaBuildMatches.length - 1][1], 10));
|
||||
return { phase: `building llama.cpp ${pct}%`, status: 'running', pct };
|
||||
}
|
||||
if (/Native llama-server not found|building from source/i.test(flat)) {
|
||||
if (/Cloning into ['"]?llama\.cpp/i.test(flat) && !/Receiving objects:\s*100%/i.test(flat)) {
|
||||
return { phase: 'cloning llama.cpp', status: 'running' };
|
||||
}
|
||||
if (/Configuring incomplete|CMake Error/i.test(flat)) {
|
||||
return {};
|
||||
}
|
||||
if (/CMAKE_BUILD_TYPE|Detecting CXX|Found Threads|Including CPU backend|CUDA nvcc found|building llama-server/i.test(flat)) {
|
||||
return { phase: 'configuring llama.cpp', status: 'running' };
|
||||
}
|
||||
return { phase: 'building llama.cpp', status: 'running' };
|
||||
}
|
||||
// HTTP access logs (e.g. GET /v1/models 200 OK) mean the server is up
|
||||
if (/(?:GET|POST)\s+\/[^\s]*\s+HTTP\/[\d.]+"\s*\d{3}/.test(flat)) {
|
||||
return { phase: 'idle', status: 'ready' };
|
||||
@@ -341,8 +452,24 @@ async function _startQueuedDownload(task) {
|
||||
|
||||
// ── Task CRUD ──
|
||||
|
||||
function _serveOutputLooksReady(task) {
|
||||
const out = String(task?.output || '');
|
||||
return !!task?._serveReady
|
||||
|| /Application startup complete/i.test(out)
|
||||
|| /Ollama API ready on port\s+\d+/i.test(out)
|
||||
|| /(?:GET|POST)\s+\/[^\s]*\s+HTTP\/[\d.]+"\s*2\d\d/i.test(out);
|
||||
}
|
||||
|
||||
function _normalizeTaskForDisplay(task) {
|
||||
if (!task || typeof task !== 'object') return task;
|
||||
if (task.type === 'serve' && task.status === 'done' && !_serveOutputLooksReady(task)) {
|
||||
return { ...task, status: 'error' };
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
export function _loadTasks() {
|
||||
try { return JSON.parse(localStorage.getItem(TASKS_KEY)) || []; }
|
||||
try { return (JSON.parse(localStorage.getItem(TASKS_KEY)) || []).map(_normalizeTaskForDisplay); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
@@ -876,7 +1003,7 @@ export async function _serveAutoFix(panel, envVar) {
|
||||
// Edit button, but optionally with a modified command (used by the diagnosis
|
||||
// "Retry with X" buttons so a retry lands in the editable Serve panel with the
|
||||
// adjusted setting, instead of blindly relaunching).
|
||||
async function _openServeEditForTask(task, cmdOverride) {
|
||||
async function _openServeEditForTask(task, cmdOverride, fieldOverrides = null) {
|
||||
const repo = task.payload?.repo_id;
|
||||
if (!repo) { uiModule.showToast('No model info on this task'); return; }
|
||||
const cmd = cmdOverride || task.payload?._cmd;
|
||||
@@ -884,6 +1011,9 @@ async function _openServeEditForTask(task, cmdOverride) {
|
||||
let fields = cmdOverride
|
||||
? _parseServeCmdToFields(cmd)
|
||||
: (task.payload?._fields || (cmd ? _parseServeCmdToFields(cmd) : null));
|
||||
if (fieldOverrides && typeof fieldOverrides === 'object') {
|
||||
fields = { ...(fields || {}), ...fieldOverrides };
|
||||
}
|
||||
// Switch the active server to the one this serve ran on (mirrors _openEdit).
|
||||
const _tHost = task.remoteHost || '';
|
||||
_envState.remoteHost = _tHost;
|
||||
@@ -1352,8 +1482,8 @@ export function _renderRunningTab() {
|
||||
const host = btn.dataset.clearServer;
|
||||
if (!await window.styledConfirm(`Clear finished tasks on ${_serverName(host)}?`, { confirmText: 'Clear' })) return;
|
||||
const allTasks = _loadTasks();
|
||||
const toRemove = allTasks.filter(t => (t.remoteHost || '') === host && t.status !== 'running');
|
||||
const remaining = allTasks.filter(t => (t.remoteHost || '') !== host || t.status === 'running');
|
||||
const toRemove = allTasks.filter(t => (t.remoteHost || '') === host && _canClearTask(t));
|
||||
const remaining = allTasks.filter(t => (t.remoteHost || '') !== host || !_canClearTask(t));
|
||||
_saveTasks(remaining);
|
||||
// Fade/slide each finished card out (same exit as the per-card clear)
|
||||
// instead of yanking them instantly.
|
||||
@@ -1443,16 +1573,19 @@ export function _renderRunningTab() {
|
||||
const _bdg = _taskBadge(task);
|
||||
badge.textContent = _bdg.text;
|
||||
badge.className = 'cookbook-task-status' + (_bdg.cls ? ' ' + _bdg.cls : '');
|
||||
badge.style.display = isDone ? 'none' : ''; // hidden — type chip carries it
|
||||
badge.style.display = '';
|
||||
}
|
||||
// Indicator: spinning wave while running, green check when finished.
|
||||
const wave = el.querySelector('.cookbook-task-wave');
|
||||
if (wave) wave.style.display = task.status === 'running' ? '' : 'none';
|
||||
// Model downloads (which have a Serve → button) don't get a clear pill —
|
||||
// pressing Serve clears them. Dep installs / serve tasks keep it.
|
||||
const check = el.querySelector('.cookbook-task-check');
|
||||
const _showClear = isDone && !(task.type === 'download' && !task.payload?._dep);
|
||||
if (check) check.style.display = _showClear ? '' : 'none';
|
||||
if (check) {
|
||||
check.style.display = _canClearTask(task) ? '' : 'none';
|
||||
const label = check.querySelector('.cookbook-task-done-label');
|
||||
if (label) label.textContent = _clearPillLabel(task);
|
||||
}
|
||||
const terminalDiag = _terminalServeDiagnosis(task, el.querySelector('.cookbook-output-pre')?.textContent || task.output || '');
|
||||
if (terminalDiag) _showDiagnosis(el, terminalDiag, el.querySelector('.cookbook-output-pre')?.textContent || task.output || '');
|
||||
}
|
||||
if (!task) {
|
||||
if (el._uptimeInterval) { clearInterval(el._uptimeInterval); el._uptimeInterval = null; }
|
||||
@@ -1476,11 +1609,8 @@ export function _renderRunningTab() {
|
||||
<div class="cookbook-task-header">
|
||||
<span class="cookbook-task-type${(task.status === 'done' && task.type === 'download') ? ' cookbook-task-type-done' : ''}" data-type="${esc(task.type)}">${esc((task.status === 'done' && task.type === 'download') ? 'finished' : task.type)}</span>
|
||||
<span class="cookbook-task-name">${modelLogo(task.name)}${esc(task.name)}</span>
|
||||
<span class="cookbook-task-status ${_bdg.cls}" style="display:${task.status === 'done' ? 'none' : ''}"${_bdgTitle}>${esc(_bdg.text)}</span>
|
||||
${task.type === 'serve' && task.payload?._cmd ? '<button class="cookbook-task-edit-btn" title="Edit settings & relaunch"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>' : ''}
|
||||
${task.type === 'serve' && task.payload?._cmd ? '<button class="cookbook-task-save-btn" title="Save preset"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></button>' : ''}
|
||||
<span class="cookbook-task-indicator"><span class="cookbook-task-wave" style="display:${task.status === 'running' ? '' : 'none'}"></span><span class="cookbook-task-check" title="Clear" style="display:${(task.status === 'done' && !(task.type === 'download' && !task.payload?._dep)) ? '' : 'none'}"><svg class="cookbook-task-check-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><svg class="cookbook-task-clear-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span class="cookbook-task-done-label">done</span><span class="cookbook-task-clear-label">clear</span></span></span>
|
||||
${task.type === 'download' && !task.payload?._dep && task.status === 'done' ? `<span class="cookbook-task-status cookbook-task-done">finished</span>` : ''}
|
||||
<span class="cookbook-task-indicator"><span class="cookbook-task-wave" style="display:${task.status === 'running' ? '' : 'none'}"></span><span class="cookbook-task-check" title="Clear" style="display:${_canClearTask(task) ? '' : 'none'}"><svg class="cookbook-task-check-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><svg class="cookbook-task-clear-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span class="cookbook-task-done-label">${esc(_clearPillLabel(task))}</span><span class="cookbook-task-clear-label">clear</span></span></span>
|
||||
<span class="cookbook-task-status ${_bdg.cls}"${_bdgTitle}>${esc(_bdg.text)}</span>
|
||||
<button class="cookbook-task-menu-btn" title="Actions">⋮</button>
|
||||
</div>
|
||||
<div class="cookbook-task-sub"><span class="cookbook-task-session">${esc(task.sessionId)}</span><span class="cookbook-task-uptime" style="display:${((task.type === 'serve' || task.type === 'download') && task.status === 'running') ? '' : 'none'}"></span></div>
|
||||
@@ -1490,6 +1620,9 @@ export function _renderRunningTab() {
|
||||
const _waveEl = el.querySelector('.cookbook-task-wave');
|
||||
if (_waveEl && task.status === 'running') _registerWaveEl(_waveEl);
|
||||
|
||||
const terminalDiag = _terminalServeDiagnosis(task, task.output || '');
|
||||
if (terminalDiag) _showDiagnosis(el, terminalDiag, task.output || '');
|
||||
|
||||
const _uptimeEl = el.querySelector('.cookbook-task-uptime');
|
||||
if (_uptimeEl && (task.type === 'serve' || task.type === 'download') && task.status === 'running') {
|
||||
const _startedAt = task.ts || Date.now();
|
||||
@@ -1506,35 +1639,12 @@ export function _renderRunningTab() {
|
||||
}
|
||||
|
||||
// Re-open the Serve panel for this model, pre-filled with the EXACT
|
||||
// settings this instance launched with, and on the SERVER it runs on —
|
||||
// shared by the edit icon button and the ⋮ "Edit settings" menu item.
|
||||
// settings this instance launched with, and on the SERVER it runs on.
|
||||
const _openEdit = () => _openServeEditForTask(task);
|
||||
const editBtn = el.querySelector('.cookbook-task-edit-btn');
|
||||
if (editBtn) {
|
||||
editBtn.addEventListener('click', (e) => { e.stopPropagation(); _openEdit(); });
|
||||
}
|
||||
|
||||
// Wire save icon button
|
||||
const saveBtn = el.querySelector('.cookbook-task-save-btn');
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
// Tell them it's already saved up front (often true now that working
|
||||
// configs auto-save) instead of after they've typed a name.
|
||||
if (_loadPresets().some(p => p.cmd === task.payload?._cmd)) {
|
||||
uiModule.showToast('Already saved');
|
||||
return;
|
||||
}
|
||||
const label = (await uiModule.styledPrompt('Name this config so you can recall it later.', {
|
||||
title: 'Save Config', defaultValue: task.name, placeholder: 'e.g. 8-bit, fast', confirmText: 'Save',
|
||||
}) || '').trim();
|
||||
if (!label) return;
|
||||
if (!_saveTaskAsPreset(task, label)) { uiModule.showToast('Already saved'); return; }
|
||||
saveBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="2.5" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
uiModule.showToast(`Saved "${label}"`);
|
||||
setTimeout(() => { saveBtn.style.display = 'none'; }, 1500);
|
||||
});
|
||||
}
|
||||
el.addEventListener('cookbook:edit-serve', (e) => {
|
||||
e.stopPropagation();
|
||||
_openServeEditForTask(task, null, e.detail?.fields || null);
|
||||
});
|
||||
|
||||
// Finished download → an explicit "Serve →" button jumps straight to the
|
||||
// Serve tab with this model pre-selected (on the server it downloaded to).
|
||||
@@ -2018,12 +2128,31 @@ async function _reconnectTask(el, task) {
|
||||
if (badge) { badge.textContent = _statusLabel('error', task.type); badge.className = 'cookbook-task-status cookbook-task-error'; }
|
||||
_showCookbookNotif(true);
|
||||
} else {
|
||||
const looksSuccessful = !lastOutput.includes('DOWNLOAD_FAILED') && (lastOutput.includes('DONE') || lastOutput.includes('100%') || lastOutput.includes('Application startup complete') || lastOutput.includes('/snapshots/') || lastOutput.includes('Download complete') || lastOutput.includes('DOWNLOAD_OK'));
|
||||
if (!lastOutput.trim() || (task.type === 'download' && !looksSuccessful)) {
|
||||
const downloadLooksSuccessful = !lastOutput.includes('DOWNLOAD_FAILED')
|
||||
&& (lastOutput.includes('DONE') || lastOutput.includes('100%') || lastOutput.includes('/snapshots/') || lastOutput.includes('Download complete') || lastOutput.includes('DOWNLOAD_OK'));
|
||||
const serveLooksReady = task.type === 'serve' && _serveOutputLooksReady({ ...task, output: lastOutput });
|
||||
const looksSuccessful = task.type === 'download' ? downloadLooksSuccessful : serveLooksReady;
|
||||
if (!lastOutput.trim() || !looksSuccessful) {
|
||||
_updateTask(task.sessionId, { status: 'crashed' });
|
||||
el.dataset.status = 'crashed';
|
||||
const badge = el.querySelector('.cookbook-task-status');
|
||||
if (badge) { badge.textContent = _statusLabel('crashed', task.type); badge.className = 'cookbook-task-status cookbook-task-crashed'; }
|
||||
if (task.type === 'serve') {
|
||||
const diag = _diagnose(lastOutput) || {
|
||||
message: _serveTaskLooksAwqOnLocalBackend(task, lastOutput)
|
||||
? 'AWQ/GPTQ/FP8 cannot be served through llama.cpp/Ollama unified-memory mode.'
|
||||
: /Native llama-server not found|building llama-server|llama\.cpp/i.test(lastOutput)
|
||||
? 'llama.cpp build stopped before the server became reachable.'
|
||||
: 'Serve stopped before the model became reachable.',
|
||||
suggestion: _serveTaskLooksAwqOnLocalBackend(task, lastOutput)
|
||||
? 'Suggested action: use vLLM/SGLang on a compatible CUDA/ROCm GPU server, or download a GGUF version for llama.cpp/Ollama/unified-memory serving.'
|
||||
: /Native llama-server not found|building llama-server|llama\.cpp/i.test(lastOutput)
|
||||
? 'Suggested action: copy the troubleshooting bundle, then edit serve settings. For the quickest local/CPU path, use Ollama or a prebuilt llama-server; source builds can take several minutes and fail if build dependencies are incomplete.'
|
||||
: 'Suggested action: copy the troubleshooting bundle, then edit serve settings or relaunch with a CPU/backend fallback.',
|
||||
fixes: [{ label: 'Edit serve', action: (panel) => _openServeEditForTask(task) }],
|
||||
};
|
||||
_showDiagnosis(el, diag, lastOutput);
|
||||
}
|
||||
_showCookbookNotif(true);
|
||||
} else {
|
||||
_updateTask(task.sessionId, { status: 'done' });
|
||||
|
||||
@@ -41,6 +41,48 @@ const SERVE_STATE_KEY = 'cookbook-serve-state';
|
||||
|
||||
let _cachedAllModels = [];
|
||||
|
||||
function _repoLooksAwqLike(model, repo) {
|
||||
const q = String(model?.quant || '').toUpperCase();
|
||||
const n = `${repo || ''} ${model?.repo_id || ''} ${model?.name || ''} ${model?.path || ''}`.toLowerCase();
|
||||
return /^AWQ|^GPTQ/.test(q) || q === 'FP8' || /\b(awq|gptq|fp8)\b/i.test(n);
|
||||
}
|
||||
|
||||
function _repoLooksGgufLike(model, repo) {
|
||||
const q = String(model?.quant || '').toUpperCase();
|
||||
const n = `${repo || ''} ${model?.repo_id || ''} ${model?.name || ''} ${model?.path || ''}`.toLowerCase();
|
||||
return !!model?.is_gguf || /^Q[2-8]/.test(q) || /^IQ/.test(q) || q === 'GGUF' || n.includes('gguf');
|
||||
}
|
||||
|
||||
function _serveBackendWarning(model, repo, backend, fields = {}) {
|
||||
const awqLike = _repoLooksAwqLike(model, repo);
|
||||
const ggufLike = _repoLooksGgufLike(model, repo);
|
||||
if (awqLike && (backend === 'llamacpp' || backend === 'ollama')) {
|
||||
return {
|
||||
title: 'AWQ needs vLLM or SGLang',
|
||||
body: 'This model looks like AWQ/GPTQ/FP8 safetensors. llama.cpp and Ollama need GGUF files, so this backend cannot serve it. Choose vLLM/SGLang on a CUDA/ROCm GPU server, or download a GGUF version for llama.cpp/Ollama.',
|
||||
};
|
||||
}
|
||||
if (awqLike && _isMetal() && (backend === 'vllm' || backend === 'sglang')) {
|
||||
return {
|
||||
title: 'AWQ is not a unified-memory path',
|
||||
body: 'This model looks like AWQ/GPTQ/FP8 safetensors. AWQ is for vLLM/SGLang on CUDA/ROCm-style GPU servers, not local unified-memory llama.cpp/Ollama serving. For unified memory, download a GGUF model and use llama.cpp/Ollama.',
|
||||
};
|
||||
}
|
||||
if (awqLike && fields.unified_mem) {
|
||||
return {
|
||||
title: 'AWQ is not a unified-memory path',
|
||||
body: 'This model looks like AWQ/GPTQ/FP8 safetensors, but unified-memory local serving expects GGUF. Use vLLM/SGLang on a compatible GPU server, or download a GGUF version for llama.cpp/Ollama.',
|
||||
};
|
||||
}
|
||||
if (ggufLike && (backend === 'vllm' || backend === 'sglang')) {
|
||||
return {
|
||||
title: 'GGUF needs llama.cpp or Ollama',
|
||||
body: 'This model looks like GGUF. vLLM/SGLang expect HuggingFace safetensors-style repos. Choose llama.cpp/Ollama for GGUF, or download a safetensors model for vLLM/SGLang.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _hasOwn(obj, key) {
|
||||
return Object.prototype.hasOwnProperty.call(obj || {}, key);
|
||||
}
|
||||
@@ -324,12 +366,6 @@ function _rerenderCachedModels() {
|
||||
c.style.alignItems = '';
|
||||
});
|
||||
|
||||
// Capture grid height
|
||||
const _tb = list.closest('.admin-card')?.querySelector('.memory-toolbar');
|
||||
const _tbH = _tb ? _tb.offsetHeight : 0;
|
||||
list.style.minHeight = (list.offsetHeight + _tbH) + 'px';
|
||||
list.style.maxHeight = (list.offsetHeight + _tbH) + 'px';
|
||||
|
||||
const shortName = repo.split('/').pop();
|
||||
const _es = _envState;
|
||||
// The venv set per-server in Settings (server.envPath). Used as the venv
|
||||
@@ -350,8 +386,13 @@ function _rerenderCachedModels() {
|
||||
? _byRepo[repo]
|
||||
: (_lastUsed || (_isLegacyFlat ? _allSs : {}));
|
||||
const detectedBackend = _detectBackend(m).backend;
|
||||
const defaultBackend = detectedBackend;
|
||||
const savedMatchesBackend = (ss.backend || 'vllm') === detectedBackend;
|
||||
const _allowedBackends = new Set(_isWindows()
|
||||
? ['llamacpp']
|
||||
: (_isMetal() ? ['llamacpp', 'ollama'] : ['vllm', 'sglang', 'llamacpp', 'ollama', 'diffusers']));
|
||||
const defaultBackend = (ss._forceBackend && ss.backend && _allowedBackends.has(ss.backend))
|
||||
? ss.backend
|
||||
: detectedBackend;
|
||||
const savedMatchesBackend = !!ss._forceBackend || (ss.backend || 'vllm') === detectedBackend;
|
||||
const sv = (k, def) => (ss[k] !== undefined && savedMatchesBackend) ? ss[k] : def;
|
||||
const defaultTp = defaultBackend === 'llamacpp' ? '1' : sv('tp', '1');
|
||||
const detectedGpuIds = _allGpuIds(_getGpuToggleTotal?.());
|
||||
@@ -1200,7 +1241,16 @@ function _rerenderCachedModels() {
|
||||
if (el.type === 'checkbox') serveState[el.dataset.field] = el.checked;
|
||||
else serveState[el.dataset.field] = el.value;
|
||||
});
|
||||
serveState.backend = (_detectBackend(m).backend) || serveState.backend || 'vllm';
|
||||
serveState.backend = serveState.backend || (_detectBackend(m).backend) || 'vllm';
|
||||
const backendWarning = _serveBackendWarning(m, repo, serveState.backend, serveState);
|
||||
if (backendWarning) {
|
||||
await window.styledConfirm(backendWarning.body, {
|
||||
title: backendWarning.title,
|
||||
confirmText: 'Edit settings',
|
||||
cancelText: 'Close',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Save in the { _byRepo, _lastUsed } schema — no legacy flat keys at
|
||||
// the root so per-model state doesn't leak between models.
|
||||
try {
|
||||
|
||||
+4
-2
@@ -2253,8 +2253,9 @@ function _renderActivityEntry(entry) {
|
||||
const hue = _categoryHue(entry.taskName, entry.kind);
|
||||
// CSS vars feed the colored title + accent stripe.
|
||||
const styleVars = `--cat-hue:${hue};`;
|
||||
const _runningPlaceholder = /^(Starting…|Starting\.\.\.|_Running…_|_Running\.\.\._|_Queued\b)/i.test((entry.result || '').trim());
|
||||
const hasResult = !!(entry.result && entry.result.trim() && entry.status !== 'running' && entry.status !== 'queued');
|
||||
const hasRunningProgress = !!(entry.result && entry.result.trim() && (entry.status === 'running' || entry.status === 'queued'));
|
||||
const hasRunningProgress = !!(entry.result && entry.result.trim() && !_runningPlaceholder && (entry.status === 'running' || entry.status === 'queued'));
|
||||
// "Open in chat" only makes sense for runs whose result is a real assistant
|
||||
// message (Prompt / Research tasks). Action/event runs are just log lines
|
||||
// (e.g. "No recent emails", "Tidied N memories") — for those, replace the
|
||||
@@ -2299,9 +2300,10 @@ function _renderActivityEntry(entry) {
|
||||
let rightHtml;
|
||||
if (_isRunning) {
|
||||
const isQueued = entry.status === 'queued';
|
||||
const label = isQueued ? 'Queued' : 'Running';
|
||||
// Initial elapsed for the first paint; the 1s interval below keeps it live.
|
||||
const startMs = entry.ts ? new Date(entry.ts).getTime() : Date.now();
|
||||
const stale = !isQueued && (Date.now() - startMs) > 30 * 60 * 1000;
|
||||
const label = isQueued ? 'Queued' : stale ? 'Still running' : 'Running';
|
||||
const elapsedInit = isQueued ? '' : `<span class="task-log-running-elapsed" data-since="${startMs}">${_fmtElapsed(Date.now() - startMs)}</span>`;
|
||||
const forceBtn = isQueued && entry.taskId ? `<button class="task-log-force-run" type="button" title="Start now in parallel, bypassing the queue" style="border:0;background:transparent;box-shadow:none;margin-left:5px;padding:0;width:12px;height:12px;display:inline-flex;align-items:center;justify-content:center;font-size:10px;line-height:1;color:inherit;opacity:.8;"><svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor" style="display:block;"><polygon points="6 4 20 12 6 20 6 4"/></svg></button>` : '';
|
||||
const stopBtn = entry.taskId ? `<button class="task-log-stop" type="button" title="Stop this task"><svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="1"/></svg></button>` : '';
|
||||
|
||||
+138
-21
@@ -5363,19 +5363,20 @@ body.bg-pattern-sparkles {
|
||||
#compare-model-overlay .modal-header h4 {
|
||||
pointer-events: none;
|
||||
}
|
||||
/* Compare modal sizes to content — the global .modal-content max-height
|
||||
+ .modal-body overflow combo makes BOTH the outer card and the inner
|
||||
body scrollable, so even when the content fits the viewport you get
|
||||
a stray vertical scrollbar. Drop the cap and disable inner scroll
|
||||
here; if the viewport is genuinely tiny the modal still won't exceed
|
||||
it because it's centered and the parent .modal flex layout shrinks. */
|
||||
/* Compare model selector: keep manually-resized/tiny windows contained.
|
||||
Picker dropdowns are appended to document.body, so the card itself can
|
||||
clip and scroll without cropping the dropdown list. */
|
||||
#compare-model-overlay .modal-content {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(720px, calc(100dvh - 48px));
|
||||
overflow: hidden;
|
||||
min-height: 180px;
|
||||
}
|
||||
#compare-model-overlay .modal-body {
|
||||
overflow: visible;
|
||||
flex: 0 0 auto;
|
||||
overflow: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.vis-hint {
|
||||
font-size: 10px;
|
||||
@@ -6955,6 +6956,8 @@ pre { background: var(--code-bg, var(--hl-bg, #282c34)) !important; }
|
||||
.compare-mode-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Type tabs match Mode toggles 1:1 (same flex column layout, same metrics) */
|
||||
.compare-mode-tab {
|
||||
@@ -19015,7 +19018,7 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
top: 0;
|
||||
cursor: pointer;
|
||||
padding: 1px 6px 1px 4px;
|
||||
border-radius: 9px;
|
||||
@@ -19024,22 +19027,17 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
}
|
||||
.cookbook-task-check svg { flex-shrink: 0; }
|
||||
.cookbook-task-check:hover { background: color-mix(in srgb, var(--red, #ff5555) 18%, transparent); }
|
||||
/* Shows "done" (green) normally; on hover the icon + label swap to a red ✕ /
|
||||
"clear" to reveal it's a dismiss action. */
|
||||
/* Terminal task clear pill. */
|
||||
.cookbook-task-done-label,
|
||||
.cookbook-task-clear-label {
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.cookbook-task-done-label { color: var(--green, #50fa7b); }
|
||||
.cookbook-task-clear-label { display: none; color: var(--red, #ff5555); }
|
||||
.cookbook-task-check:hover .cookbook-task-done-label { display: none; }
|
||||
.cookbook-task-check:hover .cookbook-task-clear-label { display: inline; }
|
||||
/* Default: show the green check. On hover: swap to a red ✕ to signal "clear". */
|
||||
.cookbook-task-clear-ico { display: none; }
|
||||
.cookbook-task-check:hover .cookbook-task-check-ico { display: none; }
|
||||
.cookbook-task-check:hover .cookbook-task-clear-ico { display: inline; }
|
||||
.cookbook-task-done-label { color: var(--red, #ff5555); }
|
||||
.cookbook-task-clear-label { display: none; }
|
||||
.cookbook-task-check-ico { display: none; }
|
||||
.cookbook-task-clear-ico { display: inline; }
|
||||
/* "Serve" button on a finished download — green pill matching the "running" /
|
||||
finished badge (it sits next to the green FINISHED chip + check). */
|
||||
.cookbook-task-serve-btn {
|
||||
@@ -19583,17 +19581,136 @@ body.gallery-selecting .gallery-dl-btn,
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.cookbook-diag-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
position: relative;
|
||||
top: -4px;
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
.cookbook-diag-fold {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-error);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
margin-right: auto;
|
||||
}
|
||||
.cookbook-diag-fold:hover {
|
||||
background: transparent;
|
||||
color: var(--color-error);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cookbook-diag-chevron {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.cookbook-diag-copy {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--fg-muted);
|
||||
padding: 0 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
min-height: 18px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.cookbook-diag-copy:hover {
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
}
|
||||
.cookbook-diag-copy.copied {
|
||||
color: var(--green, #50fa7b);
|
||||
}
|
||||
.cookbook-diag-copy svg {
|
||||
display: block;
|
||||
}
|
||||
.cookbook-diag-dismiss {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--fg-muted);
|
||||
padding: 0;
|
||||
width: 16px;
|
||||
height: 18px;
|
||||
min-height: 18px;
|
||||
line-height: 16px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
.cookbook-diag-dismiss:hover {
|
||||
background: transparent;
|
||||
color: var(--color-error);
|
||||
}
|
||||
.cookbook-diag-body {
|
||||
margin-top: 7px;
|
||||
}
|
||||
.cookbook-diag-message {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-error);
|
||||
margin-bottom: 4px;
|
||||
margin-left: 2px;
|
||||
user-select: text;
|
||||
}
|
||||
.cookbook-diag-suggestion {
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
color: var(--fg-muted);
|
||||
margin-bottom: 8px;
|
||||
margin-left: 2px;
|
||||
user-select: text;
|
||||
}
|
||||
.cookbook-diag-fixes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.cookbook-diag-actions {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
.cookbook-diag-action-trigger {
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
min-height: 24px;
|
||||
background: var(--panel);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error) 40%, transparent);
|
||||
color: var(--fg);
|
||||
}
|
||||
.cookbook-diag-action-trigger:hover {
|
||||
border-color: var(--color-error);
|
||||
background: color-mix(in srgb, var(--color-error) 12%, transparent);
|
||||
}
|
||||
.cookbook-diag-menu {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(100% + 4px);
|
||||
min-width: 180px;
|
||||
z-index: 80;
|
||||
}
|
||||
.cookbook-diag-menu button {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cookbook-diag-btn {
|
||||
font-size: 11px;
|
||||
padding: 4px 10px;
|
||||
|
||||
Reference in New Issue
Block a user