Cookbook model workflow fixes

This commit is contained in:
pewdiepie-archdaemon
2026-06-21 11:02:35 +00:00
parent 8c46172e87
commit c504214925
38 changed files with 3042 additions and 459 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+7 -2
View File
@@ -879,7 +879,7 @@
<span class="grow">Library</span>
<button type="button" class="list-item-plus-btn" id="library-new-doc-btn" title="New document">
<svg class="list-item-plus-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:11px;height:11px;"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span class="list-item-plus-label">new</span>
<span class="list-item-plus-label">document</span>
</button>
</div>
<div class="list-item" id="tool-notes-btn">
@@ -1005,7 +1005,12 @@
<button type="button" class="model-picker-btn" id="model-picker-btn" title="Switch model"><span id="model-picker-label">Select model</span> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 15 12 9 18 15"/></svg></button>
<div class="model-picker-menu hidden" id="model-picker-menu">
<div class="model-picker-search-row">
<input type="text" id="model-picker-search" placeholder="Search models..." autocomplete="off" aria-label="Search models">
<div class="model-picker-search-wrap">
<input type="text" id="model-picker-search" placeholder="Search models..." autocomplete="off" aria-label="Search models">
<button type="button" class="model-picker-refresh-btn" id="model-picker-refresh-btn" title="Refresh model picker" aria-label="Refresh model picker">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
</div>
<button type="button" class="model-picker-action-btn primary" id="model-picker-add-models-btn" title="Add model endpoints" aria-label="Add model endpoints">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
</button>
+13 -11
View File
@@ -1164,6 +1164,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
}
function _replyAfterClosedThinking(text) {
text = markdownModule.normalizeThinkingMarkup(text || '');
const closeRe = /<\/(?:think(?:ing)?|thought)>|<channel\|>/gi;
let match = null;
let last = null;
@@ -1174,7 +1175,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// Direct render helper for streaming text
_renderStream = () => {
let dt = stripToolBlocks(roundText);
let dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(roundText));
const bodyEl = roundHolder.querySelector('.body');
const contentEl = _ensureStreamLayout(bodyEl);
@@ -1466,12 +1467,13 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// 1. Normal: <think>...no closing tag yet
// 2. Malformed: <think></think>\n...text but no second </think> yet
// 3. Qwen3.5: "Thinking Process:" without <think> tags
let hasUnclosedThink = markdownModule.hasUnclosedThinkTag(roundText);
const normalizedRoundText = markdownModule.normalizeThinkingMarkup(roundText);
let hasUnclosedThink = markdownModule.hasUnclosedThinkTag(normalizedRoundText);
// Detect non-tag thinking patterns: "Thinking:", "Thinking Process:", Gemma-style reasoning
// These patterns don't use <think> tags, so we simulate unclosed thinking during streaming
const _replyPrefixes = ['Hey', 'Hi ', 'Hi!', 'Hello', 'Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome', 'Good ', "I'm happy", "I'd be"];
if (!hasUnclosedThink && !/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought/i.test(roundText)) {
const _trimmedRT = roundText.trimStart();
if (!hasUnclosedThink && !/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought/i.test(normalizedRoundText)) {
const _trimmedRT = normalizedRoundText.trimStart();
const _isReasoning = markdownModule.startsWithReasoningPrefix(_trimmedRT);
if (_isReasoning) {
// Check if we can see a reply boundary yet (newline then reply pattern)
@@ -1496,9 +1498,9 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
}
}
}
if (!hasUnclosedThink && /^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i.test(roundText)) {
if (!hasUnclosedThink && /^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i.test(normalizedRoundText)) {
// Empty <think></think> — the model likely put thinking outside the tags
const afterEmpty = roundText.replace(/^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i, '').trim();
const afterEmpty = normalizedRoundText.replace(/^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i, '').trim();
const closeTags = (afterEmpty.match(/<\/(?:think(?:ing)?|thought)>/gi) || []).length;
if (closeTags === 0 && afterEmpty.length > 0) {
hasUnclosedThink = true; // still waiting for real closing tag
@@ -1508,10 +1510,10 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// Only applies when there's a second </think> later (model leaked thinking outside tags)
// Do NOT trigger if the text after </think> contains tool calls (that's real content)
if (!hasUnclosedThink && isThinking) {
const _thinkMatch = roundText.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i);
const _thinkMatch = normalizedRoundText.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i);
const _thinkLen = _thinkMatch ? _thinkMatch[1].trim().length : 0;
if (_thinkLen < 20) {
const _afterClose = roundText.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i, '').trim();
const _afterClose = normalizedRoundText.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i, '').trim();
// Only keep waiting if there's trailing text that looks like thinking (not tool calls)
const _hasToolCall = /```(?:bash|python|web_search|read_file|write_file|create_document|edit_document|manage_|generate_image)/i.test(_afterClose);
const _hasOrphanClose = /<\/(?:think(?:ing)?|thought)>/i.test(_afterClose);
@@ -1572,7 +1574,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
} else if (hasUnclosedThink && isThinking) {
if (_liveThinkInner) {
// Extract raw thinking text (strip known thinking wrappers and prefixes)
var thinkText = roundText
var thinkText = markdownModule.normalizeThinkingMarkup(roundText)
.replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '')
.replace(/<\|channel>thought\s*\n?/gi, '')
.replace(/<\|channel>response\s*\n?/gi, '')
@@ -2045,7 +2047,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (!roundFinalized) {
roundFinalized = true;
if (spinner && spinner.element) spinner.destroy();
const dt = stripToolBlocks(roundText);
const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(roundText));
if (dt.trim()) {
var _body3 = roundHolder.querySelector('.body');
var _contentEl3 = _ensureStreamLayout(_body3);
@@ -3405,7 +3407,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
};
const renderDelta = () => {
const dt = stripToolBlocks(roundText);
const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(roundText));
contentDiv.innerHTML = markdownModule.mdToHtml(markdownModule.squashOutsideCode(dt));
uiModule.scrollHistory();
};
+73 -17
View File
@@ -73,6 +73,45 @@ function isCompareActive() {
return state.isActive;
}
function _compareModeLabel() {
return ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models');
}
function _setToolbarMode(mode, syncModeTools = !state.isActive) {
const target = mode === 'agent' ? 'agent' : 'chat';
const toggleState = Storage.loadToggleState();
toggleState.mode = target;
Storage.saveToggleState(toggleState);
const agentBtn = document.getElementById('mode-agent-btn');
const chatBtn = document.getElementById('mode-chat-btn');
const modeToggle = agentBtn?.closest('.mode-toggle') || chatBtn?.closest('.mode-toggle') || document.querySelector('.mode-toggle');
if (agentBtn && chatBtn) {
agentBtn.classList.toggle('active', target === 'agent');
chatBtn.classList.toggle('active', target === 'chat');
agentBtn.setAttribute('aria-pressed', target === 'agent' ? 'true' : 'false');
chatBtn.setAttribute('aria-pressed', target === 'chat' ? 'true' : 'false');
}
if (modeToggle) {
modeToggle.classList.toggle('mode-chat', target === 'chat');
modeToggle.classList.toggle('mode-right', target === 'chat');
}
if (syncModeTools) {
document.querySelectorAll('[data-mode-tool]').forEach(b => { b.style.display = target === 'agent' ? '' : 'none'; });
}
}
function _syncCompareModeFromToolbar(mode) {
if (!state.isActive) return;
state._compareMode = mode === 'agent' ? 'agent' : 'chat';
_setToolbarMode(state._compareMode, false);
const headerLabel = document.querySelector('.compare-header-label');
if (headerLabel) {
headerLabel.textContent = 'Comparing' + _compareModeLabel() + (state._blindMode ? ' (blind)' : '') + ' · ' + state._timeout + 's timeout';
}
const evalWrap = document.getElementById('cmp-eval-wrap');
if (evalWrap && typeof evalWrap._renderItems === 'function') evalWrap._renderItems();
}
// ────────────────────────────────────────────────────────────────────────────
// ── closeCompare ──
// ────────────────────────────────────────────────────────────────────────────
@@ -170,12 +209,7 @@ async function deactivate(teardown) {
});
// Restore agent/chat mode to what it was before compare
const _ts = Storage.loadToggleState();
_ts.mode = state._savedMode;
Storage.saveToggleState(_ts);
const _ab2 = document.getElementById('mode-agent-btn'), _cb2 = document.getElementById('mode-chat-btn');
if (_ab2 && _cb2) { _ab2.classList.toggle('active', state._savedMode === 'agent'); _cb2.classList.toggle('active', state._savedMode === 'chat'); }
document.querySelectorAll('[data-mode-tool]').forEach(b => { b.style.display = state._savedMode === 'agent' ? '' : 'none'; });
_setToolbarMode(state._savedMode, true);
// Delete unsaved sessions, then reload
if (teardown) {
@@ -258,19 +292,30 @@ async function _buildCompareUI() {
if (el) state._savedIndicatorDisplay[id] = el.style.display;
});
// 5. Save current mode and lock to the right one for this compare type
// 5. Save current mode and seed the toolbar for this compare type.
const _toggleState = Storage.loadToggleState();
state._savedMode = _toggleState.mode || 'chat';
const _targetMode = (state._compareMode === 'agent') ? 'agent' : 'chat';
_toggleState.mode = _targetMode;
Storage.saveToggleState(_toggleState);
_setToolbarMode(_targetMode, false);
const _ab = document.getElementById('mode-agent-btn'), _cb = document.getElementById('mode-chat-btn');
let _modeCleanup = null;
const _onCompareModeClick = (ev) => {
ev.stopPropagation();
ev.stopImmediatePropagation();
_syncCompareModeFromToolbar(ev.currentTarget === _ab ? 'agent' : 'chat');
};
if (_ab && _cb) {
_ab.classList.toggle('active', _targetMode === 'agent');
_cb.classList.toggle('active', _targetMode === 'chat');
_ab.addEventListener('click', _onCompareModeClick, true);
_cb.addEventListener('click', _onCompareModeClick, true);
_modeCleanup = document.createElement('span');
_modeCleanup.style.display = 'none';
_modeCleanup._cleanup = () => {
_ab.removeEventListener('click', _onCompareModeClick, true);
_cb.removeEventListener('click', _onCompareModeClick, true);
};
}
const _modeToggle = document.querySelector('.mode-toggle');
if (_modeToggle) { _modeToggle.style.pointerEvents = 'none'; _modeToggle.style.opacity = '0.4'; }
if (_modeToggle) { _modeToggle.style.pointerEvents = ''; _modeToggle.style.opacity = ''; }
// 6. Force tool toggles per compare mode
disableToolToggles();
@@ -289,6 +334,7 @@ async function _buildCompareUI() {
// 7. Hide existing chat container children (preserves event listeners)
const container = document.getElementById('chat-container');
state._compareElements = [];
if (_modeCleanup) state._compareElements.push(_modeCleanup);
Array.from(container.children).forEach(child => {
if (child.style.display === 'none') return;
child.dataset.cmpHidden = '1';
@@ -302,9 +348,9 @@ async function _buildCompareUI() {
headerBar.className = 'compare-header-bar';
headerBar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:6px 10px;flex-shrink:0;';
const headerLabel = document.createElement('span');
headerLabel.className = 'compare-header-label';
headerLabel.style.cssText = 'font-size:10px;font-weight:400;color:var(--fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;';
const _modeLabel = ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models');
headerLabel.textContent = 'Comparing' + _modeLabel + (state._blindMode ? ' (blind)' : '') + ' · ' + state._timeout + 's timeout';
headerLabel.textContent = 'Comparing' + _compareModeLabel() + (state._blindMode ? ' (blind)' : '') + ' · ' + state._timeout + 's timeout';
// Left side: the Compare tool icon (two side-by-side panes, matching the
// rail/sidebar icon) + the label. Other tool headers carry their icon; this
// one was missing it.
@@ -475,7 +521,7 @@ async function _buildCompareUI() {
}
const msgTA = document.getElementById('message');
if (msgTA) {
msgTA.placeholder = 'Enter prompt for all models...';
msgTA.placeholder = window.matchMedia('(max-width: 767px)').matches ? '' : 'Enter prompt for all models...';
requestAnimationFrame(() => msgTA.focus());
}
@@ -891,8 +937,7 @@ async function _executeCompare(message) {
let sharedSearchContext = null;
let sharedSearchSources = null;
const webChk = document.getElementById('web-toggle');
const toggleState = Storage.loadToggleState();
const isAgentMode = (toggleState.mode || 'chat') === 'agent';
const isAgentMode = state._compareMode === 'agent';
const webOn = webChk && webChk.checked;
// In agent mode, web_search is a tool (handled per-pane); in chat mode, pre-search and share
if (webOn && !isAgentMode) {
@@ -1198,6 +1243,15 @@ function _setupEvalPicker() {
function _renderItems() {
const mode = state._compareMode || 'chat';
const label = btn.querySelector('.cmp-eval-label');
if (label) {
label.textContent = ({
agent: 'Agent prompts',
chat: 'Chat prompts',
search: 'Search prompts',
research: 'Research prompts'
}[mode] || 'Eval prompts');
}
// research/html aren't first-class compare types — fall back gracefully
const key = EVAL_PROMPTS[mode] ? mode
: (mode === 'research' ? 'search' : 'chat');
@@ -1258,8 +1312,10 @@ function _setupEvalPicker() {
};
document.addEventListener('click', _onDocClick);
_renderItems();
wrap.appendChild(btn);
wrap.appendChild(menu);
wrap._renderItems = _renderItems;
inputTop.appendChild(wrap);
// Expected-answer chip — placed above the chat-input-bar (outside it), so
+27 -4
View File
@@ -551,23 +551,46 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
footer.className = 'msg-footer';
const span = document.createElement('span');
span.className = 'response-metrics';
let text = metrics.output_tokens + ' tokens | ' + metrics.tokens_per_second + ' tok/s';
const outputTokens = metrics.output_tokens;
const responseTime = metrics.response_time ?? metrics.total_time;
const explicitTps = metrics.tokens_per_second ?? metrics.gen_tps ?? metrics.tps;
const numericOutput = Number(outputTokens);
const numericTime = Number(responseTime);
const numericTps = Number(explicitTps);
const derivedTps = Number.isFinite(numericTps)
? numericTps
: (Number.isFinite(numericOutput) && Number.isFinite(numericTime) && numericTime > 0)
? numericOutput / numericTime
: null;
const tpsLabel = derivedTps != null
? (derivedTps >= 100 ? String(Math.round(derivedTps)) : derivedTps.toFixed(2).replace(/\.?0+$/, ''))
: null;
const parts = [];
if (outputTokens != null && outputTokens !== 'undefined') {
parts.push(outputTokens + ' tokens');
}
if (tpsLabel != null) {
parts.push(tpsLabel + ' tok/s');
}
if (responseTime != null && responseTime !== 'undefined' && parts.length === 0) {
parts.push(responseTime + 's');
}
// Add per-request cost and cost per 1000
const _model = metrics.model || (state._selectedModels[paneIdx] && state._selectedModels[paneIdx].model) || '';
const _cost = getModelCost(_model, metrics.input_tokens || 0, metrics.output_tokens || 0);
// Build the metrics span with optional cost and context
span.textContent = text;
span.textContent = parts.join(' | ');
if (_cost !== null) {
const _cost1k = _cost * 1000;
const costSpan = document.createElement('span');
costSpan.style.color = 'var(--color-success, #4caf50)';
costSpan.title = 'Estimated cost per 1,000 responses like this one';
costSpan.textContent = ' | $' + (_cost1k < 1 ? _cost1k.toFixed(2) : _cost1k.toFixed(0)) + '/1k';
costSpan.textContent = (span.textContent ? ' | ' : '') + '$' + (_cost1k < 1 ? _cost1k.toFixed(2) : _cost1k.toFixed(0)) + '/1k';
span.appendChild(costSpan);
}
if (metrics.context_percent > 0) {
const ctx = document.createElement('span');
ctx.textContent = ' | ' + metrics.context_percent + '% ctx';
ctx.textContent = (span.textContent ? ' | ' : '') + metrics.context_percent + '% ctx';
if (metrics.context_percent >= 85) ctx.style.color = 'var(--color-error)';
else if (metrics.context_percent >= 70) ctx.style.color = '#ff9900';
span.appendChild(ctx);
+1 -1
View File
@@ -181,7 +181,7 @@ function handleVote(winnerIdx) {
let html = '';
const caret = ' <span class="pane-title-caret">&#x25BE;</span>';
if (isWinner) html = '<span style="color:var(--red);margin-right:4px;">&#x2605;</span><strong>' + escapeHtml(name) + '</strong> <span style="color:var(--red);font-size:0.82em;font-weight:800;text-transform:uppercase;letter-spacing:1px;position:relative;top:-2px;">Winner!</span>' + caret;
if (isWinner) html = '<span style="color:var(--green, #50fa7b);margin-right:4px;">&#x2605;</span><strong>' + escapeHtml(name) + '</strong> <span style="color:var(--green, #50fa7b);font-size:0.82em;font-weight:800;text-transform:uppercase;letter-spacing:1px;position:relative;top:0;">Winner!</span>' + caret;
else if (isTie) html = '<span style="opacity:0.5;margin-right:4px;">=</span><strong>' + escapeHtml(name) + '</strong>' + caret;
else html = '<strong>' + escapeHtml(name) + '</strong>' + caret;
el.innerHTML = html;
-12
View File
@@ -1421,23 +1421,11 @@ export function _expandModelRow(row, modelData) {
const dlSource = _downloadSourceRepo(modelData, backend);
const hfUrl = `https://huggingface.co/${dlSource.repo}`;
// Official vendor recipe deep-links. These point to vLLM / SGLang's curated
// hardware-specific launch-command pages. They 404 for uncatalogued models \u2014
// a known tradeoff; user just gets the vendor's "model not found" page.
const _recipeRepo = modelData.name || '';
const _vllmUrl = _recipeRepo ? `https://recipes.vllm.ai/${_recipeRepo}` : '';
const _sglangUrl = _recipeRepo ? `https://docs.sglang.io/cookbook/autoregressive/${_recipeRepo}${_sglangHashFor(modelData)}` : '';
let html = `<div class="hwfit-action-panel" data-model-name="${esc(modelData.name)}">`;
html += `<div class="hwfit-panel-header">`;
html += `<span class="hwfit-panel-model">${esc(modelData.name)}${dlSource.kind ? ` <span style="opacity:0.5;font-size:10px;">(${esc(dlSource.kind)} ${esc(modelData.quant || '')})</span>` : (modelData.quant_repo ? ` <span style="opacity:0.5;font-size:10px;">(${esc(modelData.quant)})</span>` : '')}</span>`;
html += `<span class="hwfit-panel-badge">${esc(label)}</span>`;
html += `<a href="${esc(hfUrl)}" target="_blank" rel="noopener" class="hwfit-panel-hf-link" title="View download source on HuggingFace">HF \u2197</a>`;
if (backend === 'vllm' && _vllmUrl) {
html += `<a href="${esc(_vllmUrl)}" target="_blank" rel="noopener" class="hwfit-panel-hf-link" title="vLLM official recipe (curated launch command). 404s if this model isn't in vLLM's recipes catalog.">vLLM \u2197</a>`;
}
if (backend === 'sglang' && _sglangUrl) {
html += `<a href="${esc(_sglangUrl)}" target="_blank" rel="noopener" class="hwfit-panel-hf-link" title="SGLang cookbook (hash pre-filled with your detected hardware). 404s if this model isn't in SGLang's cookbook catalog.">SGLang \u2197</a>`;
}
html += `</div>`;
html += `<div class="hwfit-panel-actions">`;
html += `<button class="cookbook-btn hwfit-dl-btn">Download</button>`;
+242 -53
View File
@@ -267,6 +267,10 @@ function _detectModelOptimizations(modelName) {
else if (n.includes('minimax')) {
opts.flags.push('--enable-expert-parallel');
opts.tips.push('MoE expert parallel for MiniMax');
if (/\bm3\b/.test(n)) {
opts.kvCacheDtype = 'fp8';
opts.tips.push('MiniMax M3 defaults: fp8 KV cache, block-size 128, TRITON attention');
}
}
// Reasoning parser — applies independently of MoE detection. Without this
// flag, models like MiniMax-M2.x, DeepSeek-R1, Qwen3 reasoning, GLM-4.x,
@@ -308,6 +312,9 @@ function _detectModelOptimizations(modelName) {
*/
export function _detectReasoningParser(modelName) {
const n = (modelName || '').toLowerCase();
// MiniMax M3 — newer vLLM nightly/parser builds use minimax_m3. This must
// be checked before the M2.x rule and before the generic MiniMax tool parser.
if (n.includes('minimax') && /\bm3\b/.test(n)) return 'minimax_m3';
// MiniMax M2 / M2.5 / M2.7 — released with a dedicated parser. Catch M2
// before plain "minimax" so M2.x doesn't fall through to a wrong parser.
if (n.includes('minimax') && n.match(/\bm2(?:\.\d)?\b/)) return 'minimax_m2';
@@ -349,6 +356,7 @@ export function _detectToolParser(modelName) {
if (n.includes('mistral') || n.includes('mixtral')) return 'mistral';
if (n.includes('deepseek-v3')) return 'deepseek_v3';
if (n.includes('deepseek')) return 'deepseek_v3';
if (n.includes('minimax') && /\bm3\b/.test(n)) return 'minimax_m3';
if (n.includes('minimax') && n.includes('m2')) return 'minimax_m2';
if (n.includes('minimax')) return 'minimax';
if (n.includes('gemma')) return 'pythonic';
@@ -376,7 +384,9 @@ export function _detectBackend(model) {
return { backend: 'unsupported', label: 'Unsupported' };
}
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);
const isGgufLike = model.is_gguf || /^Q[2-8]/.test(q) || /^IQ/.test(q) || q === 'GGUF' || _nm.includes('gguf');
const hasGgufFile = Array.isArray(model.gguf_files)
&& model.gguf_files.some(f => f && typeof f.rel_path === 'string' && /\.gguf$/i.test(f.rel_path));
const isGgufLike = model.is_gguf || hasGgufFile || /^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') {
@@ -495,14 +505,22 @@ function _buildEnvPrefixWindows() {
return parts.join('; ') + ';';
}
function _venvRootFromPath(path) {
let p = (path || '').toString().trim().replace(/\/+$/, '');
if (!p) return '';
p = p.replace(/\/bin\/(?:activate|python(?:3(?:\.\d+)?)?|vllm|pip(?:3)?)$/i, '');
return p;
}
export function _buildServeCmd(f, modelName, backend) {
// When a venv is configured on the chosen server, use the venv's binaries
// by absolute path. Bare `vllm` / `python3` relies on PATH, and SSH non-
// interactive sessions often leave a user-site install (~/.local/bin/vllm)
// ahead of the venv's bin, so the WRONG vllm gets launched even with the
// venv activated. Absolute path sidesteps the whole PATH question.
const _isVenv = _envState.env === 'venv' && _envState.envPath;
const _venvBin = _isVenv ? (_envState.envPath.replace(/\/+$/, '') + '/bin/') : '';
const _formVenv = (f.venv ?? '').toString().trim();
const _activeVenvPath = _venvRootFromPath(_formVenv || (_envState.env === 'venv' ? (_envState.envPath || '') : ''));
const _venvBin = _activeVenvPath ? (_activeVenvPath + '/bin/') : '';
const _vllmBin = _venvBin ? `${_venvBin}vllm` : 'vllm';
const _py3Bin = _venvBin ? `${_venvBin}python3` : 'python3';
let cmd = '';
@@ -524,21 +542,26 @@ export function _buildServeCmd(f, modelName, backend) {
cmd += 'VLLM_USE_DEEP_GEMM=0 VLLM_USE_FLASHINFER_MOE_FP16=1 OMP_NUM_THREADS=4 ';
}
}
// Pinned attention backend (Attention field). Empty = let vLLM pick.
const _attn = (f.vllm_attn_backend ?? '').toString().trim();
if (_attn) cmd += `VLLM_ATTENTION_BACKEND=${_attn} `;
// Free-text "Env" field — verbatim KEY=VAL pairs (space-separated).
// Collapse any pasted newlines/tabs so the backend allowlist (which
// rejects \n / \r) doesn't trip on a multi-line paste from a model card.
const _extraEnv = (f.extra_env ?? '').toString().replace(/\s+/g, ' ').trim();
if (_extraEnv) cmd += _extraEnv + ' ';
cmd += `${_vllmBin} serve ${modelName} --host 0.0.0.0 --port ${f.port || '8000'}`;
const _servedModelName = (f.served_model_name ?? '').toString().trim();
if (_servedModelName) cmd += ` --served-model-name ${_servedModelName}`;
// Pinned attention backend (Attention field). Empty = let vLLM pick.
const _attn = (f.vllm_attn_backend ?? '').toString().trim();
if (_attn) cmd += ` --attention-backend ${_attn}`;
const _gemma4ChatTemplate = _gemma4ThinkingChatTemplateArg(modelName);
if (_gemma4ChatTemplate) cmd += ` --chat-template ${_gemma4ChatTemplate}`;
cmd += ` --tensor-parallel-size ${f.tp || '1'}`;
const _blockSize = (f.vllm_block_size ?? '').toString().trim();
if (/^\d+$/.test(_blockSize)) cmd += ` --block-size ${_blockSize}`;
cmd += ` --max-model-len ${f.ctx || '8192'}`;
cmd += ` --gpu-memory-utilization ${f.gpu_mem || '0.90'}`;
if (f.swap && f.swap !== '0') cmd += ` --swap-space ${f.swap}`;
const _swapRaw = (f.swap ?? '').toString().trim().toLowerCase();
if (_swapRaw && !['0', 'off', 'none', 'false'].includes(_swapRaw)) cmd += ` --swap-space ${_swapRaw}`;
cmd += ` --dtype ${f.dtype || 'auto'}`;
const _kv = (f.vllm_kv_cache_dtype ?? '').toString().trim();
if (_kv === 'fp8') cmd += ' --kv-cache-dtype fp8';
@@ -548,10 +571,12 @@ export function _buildServeCmd(f, modelName, backend) {
if (f.prefix_cache) cmd += ' --enable-prefix-caching';
if (f.auto_tool) cmd += ` --enable-auto-tool-choice --tool-call-parser ${_detectToolParser(modelName)}`;
if (f.expert_parallel) cmd += ' --enable-expert-parallel';
if (f.language_model_only) cmd += ' --language-model-only';
if (f.disable_custom_all_reduce) cmd += ' --disable-custom-all-reduce';
if (f.reasoning_parser) {
const rp = typeof f.reasoning_parser === 'string' && f.reasoning_parser !== 'true'
? f.reasoning_parser : (f._reasoning_parser_value || 'qwen3');
cmd += ` --reasoning-parser ${rp}`;
? f.reasoning_parser : (f._reasoning_parser_value || _detectReasoningParser(modelName) || '');
if (rp) cmd += ` --reasoning-parser ${rp}`;
}
if (f.speculative) {
const _specMethod = (f.spec_method || 'mtp').trim() || 'mtp';
@@ -590,9 +615,11 @@ export function _buildServeCmd(f, modelName, backend) {
// The Inference mode pill (GPU/CPU) above gates this — when the user picks
// CPU, force ngl=0 here so all downstream flag-suppression fires
// consistently regardless of what the (now-hidden) ngl input shows.
if (String(f.llama_mode || '').toLowerCase() === 'cpu') {
const _llamaMode = String(f.llama_mode || '').toLowerCase();
if (_llamaMode === 'unified') f.unified_mem = true;
if (_llamaMode === 'cpu') {
f.ngl = '0';
} else if (String(f.llama_mode || '').toLowerCase() === 'gpu' && (!f.ngl || String(f.ngl).trim() === '0')) {
} else if (['gpu', 'unified'].includes(_llamaMode) && (!f.ngl || String(f.ngl).trim() === '0')) {
f.ngl = '99';
}
const _cpuOnly = String(f.ngl).trim() === '0';
@@ -616,7 +643,8 @@ export function _buildServeCmd(f, modelName, backend) {
})();
if (f.unified_mem && !_cpuOnly && _isWindows() && _isCudaTarget) cmd += `$env:GGML_CUDA_ENABLE_UNIFIED_MEMORY="1"; `;
if (_isWindows() && !_cpuOnly) cmd += _gpuEnvPrefix(gpuId, true);
const modelArg = `"${ggufPath}"`;
const needsGgufPrelude = /^\$\(\{\s*find\s/.test(String(ggufPath || ''));
const modelArg = needsGgufPrelude ? '"$MODEL_FILE"' : `"${ggufPath}"`;
// Prefer native llama-server. The backend bootstrap resolves/builds the
// right binary (Vulkan/HIP/CUDA/Metal/CPU), so keep the generated command
// as a validator-safe binary + args with no shell chaining.
@@ -692,6 +720,9 @@ export function _buildServeCmd(f, modelName, backend) {
} else {
cmd += `${lcPrefix}llama-server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} -ngl ${f.ngl || '99'} -c ${f.ctx || '8192'}${_lcExtra}`;
}
if (needsGgufPrelude) {
cmd = `MODEL_FILE=${ggufPath} && { [ -n "$MODEL_FILE" ] && [ -f "$MODEL_FILE" ]; } || { echo "ERROR: No GGUF found on this host"; exit 1; } && ${cmd}`;
}
} else if (backend === 'ollama') {
const ollamaPort = f.port || '11434';
// GGUF + Ollama: delegate to the iGPU-bound ollama-test container via
@@ -860,7 +891,7 @@ async function _fetchDependencies() {
const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => {
if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`;
if (pkg.installed && isSystemDep) return `<span class="cookbook-dep-tag cookbook-dep-installed" title="Found on selected server">Installed</span>`;
if (pkg.installed && pkg.pip_update_available === false) {
if (pkg.installed && pkg.pip_update_available === false && pkg.name !== 'llama_cpp') {
const tip = esc(pkg.update_note || pkg.status_note || 'Found externally; update outside Odysseus.');
return `<span class="cookbook-dep-tag cookbook-dep-installed" title="${tip}">Installed</span>`;
}
@@ -902,9 +933,7 @@ async function _fetchDependencies() {
// diagnosis-style `_launchServeTask` with `pip install --force-reinstall`
// so the user can watch the pip install in the Running tab.
let _rebuildBtn = '';
if (pkg.name === 'llama_cpp') {
_rebuildBtn = `<button type="button" class="cookbook-dep-tag cookbook-dep-rebuild" id="cookbook-rebuild-engine" title="Clear the cached llama.cpp build so the next serve recompiles from source (use after installing a CUDA/ROCm toolkit to turn a CPU-only build into a GPU build).">Rebuild</button>`;
} else if (pkg.name === 'vllm' && pkg.installed) {
if (pkg.name === 'vllm' && pkg.installed) {
_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>`;
} else if (pkg.name === 'sglang' && pkg.installed) {
_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>`;
@@ -1410,12 +1439,59 @@ async function _fetchDependencies() {
});
// Wire the ⋮ menu on installed packages — currently just "Update".
async function _rebuildLlamaCpp(updateSource = false, statusEl = null) {
const sel = document.getElementById('hwfit-deps-server');
if (sel) _applyServerSelection(sel.value);
const host = _envState.remoteHost || '';
const where = host || 'this server';
const action = updateSource ? 'Update llama.cpp source and rebuild' : 'Rebuild llama.cpp engine';
const detail = updateSource
? '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.'
: 'This clears the cached llama-server build. The next launch recompiles or installs a matching prebuilt.';
if (!confirm(`${action} on ${where}?\n\n${detail}`)) return;
const oldText = statusEl?.textContent;
if (statusEl) {
statusEl.disabled = true;
statusEl.textContent = updateSource ? 'Updating...' : 'Clearing...';
}
try {
const res = await fetch('/api/cookbook/rebuild-engine', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
engine: 'llamacpp',
remote_host: host || undefined,
ssh_port: _getPort(host) || undefined,
update_source: !!updateSource,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
const reason = data.detail || data.error || `HTTP ${res.status}`;
uiModule.showToast(`${updateSource ? 'Update' : 'Rebuild'} failed: ` + String(reason).slice(0, 300), {
duration: 20000, action: 'OK', onAction: () => {},
});
} else {
uiModule.showToast(`${updateSource ? 'Updated source and cleared' : 'Cleared'} llama.cpp build on ${where}. Re-launch the serve task to rebuild.`);
}
} catch (err) {
uiModule.showToast(`${updateSource ? 'Update' : 'Rebuild'} failed: ` + err.message);
} finally {
if (statusEl) {
statusEl.disabled = false;
statusEl.textContent = oldText;
}
}
}
window._cookbookRebuildLlamaCpp = _rebuildLlamaCpp;
// Wire the installed-package menu.
function _showDepMenu(anchor) {
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
const row = anchor.closest('.cookbook-dep-row');
if (!row) return;
const pipName = row.dataset.depPip;
const rowPkgName = row.dataset.pkgName || '';
const pkgName = row.querySelector('.memory-item-title')?.textContent || pipName;
const isLocalOnly = row.dataset.depTarget === 'local';
const dropdown = document.createElement('div');
@@ -1436,6 +1512,29 @@ async function _fetchDependencies() {
await _installDep(pipName, pkgName, isLocalOnly, true, null);
});
dropdown.appendChild(it);
if (rowPkgName === 'llama_cpp') {
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>';
const rebuild = document.createElement('div');
rebuild.className = 'dropdown-item-compact';
rebuild.innerHTML = `<span class="dropdown-icon">${rebuildIco}</span><span>Rebuild</span>`;
rebuild.title = 'Clear the cached llama-server build so the next launch rebuilds it.';
rebuild.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.remove();
await _rebuildLlamaCpp(false, null);
});
dropdown.appendChild(rebuild);
const source = document.createElement('div');
source.className = 'dropdown-item-compact';
source.innerHTML = `<span class="dropdown-icon">${upIco}</span><span>Update source + rebuild</span>`;
source.title = 'Fast-forward ~/llama.cpp when possible, then clear the cached build.';
source.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.remove();
await _rebuildLlamaCpp(true, null);
});
dropdown.appendChild(source);
}
document.body.appendChild(dropdown);
const close = (ev) => {
if (!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)) {
@@ -1698,33 +1797,7 @@ function _wireTabEvents(body) {
if (sel) _applyServerSelection(sel.value);
const host = _envState.remoteHost || '';
const where = host || 'this server';
if (!confirm(`Rebuild the llama.cpp engine on ${where}?\n\nThis clears the cached llama-server build so the next serve recompiles from source (with CUDA/HIP if a toolchain is present). It does not download or install anything.`)) return;
const _label = rebuildBtn.textContent;
rebuildBtn.disabled = true;
rebuildBtn.textContent = 'Clearing...';
try {
const res = await fetch('/api/cookbook/rebuild-engine', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
engine: 'llamacpp',
remote_host: host || undefined,
ssh_port: _getPort(host) || undefined,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
const reason = data.detail || data.error || `HTTP ${res.status}`;
uiModule.showToast('Rebuild failed: ' + String(reason).slice(0, 200));
} else {
uiModule.showToast(`Cleared llama.cpp build on ${where}. Re-launch the serve task to rebuild with GPU support.`);
}
} catch (err) {
uiModule.showToast('Rebuild failed: ' + err.message);
} finally {
rebuildBtn.disabled = false;
rebuildBtn.textContent = _label;
}
if (window._cookbookRebuildLlamaCpp) await window._cookbookRebuildLlamaCpp(false, rebuildBtn);
});
}
@@ -1835,6 +1908,9 @@ function _wireTabEvents(body) {
// Download input
const dlBtn = document.getElementById('cookbook-dl-btn');
const dlInput = document.getElementById('cookbook-dl-repo');
const dlGgufRow = document.getElementById('cookbook-dl-gguf-row');
const dlGgufQuant = document.getElementById('cookbook-dl-gguf-quant');
const dlGgufNote = document.getElementById('cookbook-dl-gguf-note');
const dlCardToggle = document.getElementById('cookbook-download-card-toggle');
const dlCardBody = document.getElementById('cookbook-download-card-body');
const dlCardArrow = document.getElementById('cookbook-download-card-arrow');
@@ -1868,6 +1944,87 @@ function _wireTabEvents(body) {
if (hfMatch) repo = hfMatch[1];
return repo;
}
function _ggufQuantFromPath(path) {
const clean = String(path || '').split('?')[0];
const parts = clean.split('/').filter(Boolean);
const dir = parts.length > 1 ? parts[0] : '';
const file = parts[parts.length - 1] || clean;
const dirQuant = dir.match(/^(?:I?Q\d(?:_[A-Z0-9]+){0,3}|UD-[A-Z0-9_]+)$/i);
if (dirQuant) return dirQuant[0].toUpperCase();
const fileQuant = file.match(/(?:^|[-_.\/])((?:I?Q\d(?:_[A-Z0-9]+){0,3})|(?:UD-[A-Z0-9_]+))(?=(?:[-_.]|\.gguf|$))/i);
return fileQuant ? fileQuant[1].toUpperCase() : '';
}
function _ggufIncludeForQuant(files, quant) {
const matches = files.filter(f => _ggufQuantFromPath(f) === quant);
if (!matches.length) return '';
const dirs = Array.from(new Set(matches.map(f => f.includes('/') ? f.split('/').slice(0, -1).join('/') : '')));
if (dirs.length === 1) {
const prefix = dirs[0] ? `${dirs[0]}/` : '';
return `${prefix}*${quant}*.gguf`;
}
return `*${quant}*.gguf`;
}
function _hideGgufPicker(message = '') {
if (dlGgufRow) dlGgufRow.style.display = 'none';
if (dlGgufQuant) {
dlGgufQuant.innerHTML = '';
dlGgufQuant.dataset.repo = '';
}
if (dlGgufNote) dlGgufNote.textContent = message;
}
async function _scanGgufRepo(rawValue) {
if (!dlGgufRow || !dlGgufQuant || !dlGgufNote) return false;
const rawRepo = _stripHfUrl(rawValue || '');
const ollamaName = _ollamaName(rawRepo);
const fileSplit = !ollamaName ? _splitRepoFile(rawRepo) : null;
const split = ollamaName ? { repo: ollamaName, include: null } : (fileSplit || _splitRepoTag(rawRepo));
const repo = split.repo || '';
if (ollamaName || split.include || !/^[^\s/]+\/[^\s/]+$/.test(repo)) {
_hideGgufPicker();
return false;
}
dlGgufRow.style.display = 'flex';
dlGgufQuant.innerHTML = '<option value="">Scanning...</option>';
dlGgufQuant.dataset.repo = repo;
dlGgufNote.textContent = '';
try {
const res = await fetch(`/api/cookbook/hf-gguf-files?repo_id=${encodeURIComponent(repo)}`, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (!data.ok) throw new Error(data.error || 'scan failed');
if (dlGgufQuant.dataset.repo !== repo) return false;
const files = (data.files || [])
.map(s => String(s || ''))
.filter(name => /\.gguf$/i.test(name));
const byQuant = new Map();
files.forEach(name => {
const quant = _ggufQuantFromPath(name);
if (!quant) return;
if (!byQuant.has(quant)) byQuant.set(quant, []);
byQuant.get(quant).push(name);
});
if (!byQuant.size) {
_hideGgufPicker('No GGUF quants found');
return false;
}
const quantRank = q => {
const m = q.match(/^I?Q(\d)/i);
return m ? Number(m[1]) : 99;
};
const quants = Array.from(byQuant.keys()).sort((a, b) => quantRank(a) - quantRank(b) || a.localeCompare(b));
dlGgufQuant.innerHTML = quants.map(q => {
const include = _ggufIncludeForQuant(files, q);
const count = byQuant.get(q).length;
return `<option value="${esc(include)}">${esc(q)} (${count})</option>`;
}).join('');
const first = dlGgufQuant.options[0];
dlGgufNote.textContent = first ? first.value : '';
return !!(first && first.value);
} catch (err) {
_hideGgufPicker(`GGUF scan failed: ${err.message || err}`);
return false;
}
}
// Split `org/repo:tag` (Ollama/llama.cpp style) into repo + include-glob.
// The `:tag` picks a specific GGUF quantization file from the repo.
function _splitRepoTag(raw) {
@@ -1902,7 +2059,7 @@ function _wireTabEvents(body) {
}
return null;
}
const triggerDownload = () => {
const triggerDownload = async () => {
const rawRepo = _stripHfUrl(dlInput.value);
if (!rawRepo) return;
const ollamaName = _ollamaName(rawRepo);
@@ -1914,6 +2071,9 @@ function _wireTabEvents(body) {
const { repo, include: autoInclude } = ollamaName
? { repo: ollamaName, include: null }
: (_fileSplit || _splitRepoTag(rawRepo));
let pickerInclude = (!ollamaName && !_fileSplit && !autoInclude && dlGgufQuant?.dataset.repo === repo)
? (dlGgufQuant.value || '')
: '';
// HuggingFace repo IDs must be `org/model`. A bare model name would 404
// at snapshot_download time with a raw traceback, so reject it up front.
// Ollama names (single-segment with a tag) skip this check — they go
@@ -1923,6 +2083,25 @@ function _wireTabEvents(body) {
dlInput.focus();
return;
}
const looksGgufRepo = !ollamaName && !_fileSplit && !autoInclude && /\bgguf\b/i.test(repo);
if (looksGgufRepo && !pickerInclude) {
const oldText = dlBtn.textContent;
dlBtn.disabled = true;
dlBtn.textContent = 'Scanning...';
try {
const found = await _scanGgufRepo(rawRepo);
pickerInclude = (found && dlGgufQuant?.dataset.repo === repo) ? (dlGgufQuant.value || '') : '';
} finally {
dlBtn.disabled = false;
dlBtn.textContent = oldText;
}
if (!pickerInclude) {
uiModule.showToast('Pick a GGUF quant first. Odysseus will not download the whole GGUF repo without an include pattern.');
return;
}
uiModule.showToast('Pick the GGUF quant, then press Download again.');
return;
}
// Resolve the host straight from THIS window's server dropdown, by index
// into the (consistent) servers list. We deliberately don't use
// _envState.remoteHost — there can be multiple copies of the cookbook
@@ -1939,7 +2118,7 @@ function _wireTabEvents(body) {
let envPath = host ? (_hsrv.envPath || '') : _envState.envPath;
const payload = { repo_id: repo };
if (ollamaName) payload.backend = 'ollama';
if (autoInclude) payload.include = autoInclude;
if (autoInclude || pickerInclude) payload.include = autoInclude || pickerInclude;
if (_envState.hfToken && !ollamaName) payload.hf_token = _envState.hfToken;
if (host) { payload.remote_host = host; const _sp3 = _getPort(host); if (_sp3) payload.ssh_port = _sp3; }
const srvPlatform = _getPlatform(host);
@@ -1966,6 +2145,16 @@ function _wireTabEvents(body) {
dlInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') triggerDownload();
});
let _ggufScanTimer = null;
const _scheduleGgufScan = () => {
clearTimeout(_ggufScanTimer);
_ggufScanTimer = setTimeout(() => _scanGgufRepo(dlInput.value), 350);
};
dlInput.addEventListener('input', _scheduleGgufScan);
dlInput.addEventListener('blur', () => _scanGgufRepo(dlInput.value));
dlGgufQuant?.addEventListener('change', () => {
if (dlGgufNote) dlGgufNote.textContent = dlGgufQuant.value || '';
});
}
// Latest HF models that fit — collapsible card list
@@ -2095,12 +2284,6 @@ function _wireTabEvents(body) {
return data.models || [];
};
let models = await _fetchLatest(vram);
// If the VRAM filter wiped everything out (often a flaky/zero hardware
// probe for a remote server — a huge-VRAM box should fit MORE, not
// fewer), fall back to the unfiltered trending list so something shows.
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));
}
@@ -2438,6 +2621,11 @@ function _renderRecipes() {
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;" />`;
html += `<button class="cookbook-btn cookbook-dl-btn" id="cookbook-dl-btn">Download</button>`;
html += `</div>`;
html += `<div id="cookbook-dl-gguf-row" style="display:none;margin-top:1px;gap:5px;align-items:center;font-size:11px;">`;
html += `<span style="opacity:0.65;flex-shrink:0;">GGUF</span>`;
html += `<select class="cookbook-field-input" id="cookbook-dl-gguf-quant" style="height:28px;min-width:118px;flex:0 0 auto;"></select>`;
html += `<span id="cookbook-dl-gguf-note" style="opacity:0.55;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"></span>`;
html += `</div>`;
// Ollama-library browse used to live here as its own collapsible dropdown,
// but that duplicated the Engine filter (which already has Ollama). The
// standalone UI is gone — to find Ollama models, set Engine = Ollama in
@@ -2454,7 +2642,7 @@ function _renderRecipes() {
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>`;
html += `</button>`;
html += `</div>`;
html += `<div id="cookbook-hf-latest-list" style="display:none;margin-top:4px;max-height:320px;overflow-y:auto;flex-direction:column;gap:4px;"></div>`;
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>`;
html += `</div>`;
html += `</div>`; // /#cookbook-dl-tab-fold-body (whole Download card body)
@@ -2884,6 +3072,7 @@ const shared = {
_getPort,
_sshPrefix,
_serverByVal,
_serverKey,
_selectedServer,
_getPlatform,
_isWindows,
+52 -27
View File
@@ -27,6 +27,9 @@ function _statusLabel(status, type) {
// "cookbook-task-status" ('' = the neutral loading style).
function _taskBadge(task) {
if (task._unreachable && task.status === 'running') return { text: 'unreachable', cls: 'cookbook-task-error' };
if (task.type === 'download' && task.status === 'running') {
return { text: _statusLabel(task.status, task.type), cls: 'cookbook-task-downloading' };
}
if (task.type === 'serve' && task.status === 'running' && task.progress) {
// Same green "running" pill — just with dynamic phase text, so it doesn't
// read as a different status while the server is coming up.
@@ -52,13 +55,13 @@ function _downloadOutputLooksActive(task) {
function _canClearTask(task) {
if (!task || task.status === 'running') return false;
if (task.type === 'serve' && (task.status === 'ready' || task._serveReady)) return false;
if (task.type === 'serve' && (task.status === 'ready' || (task._serveReady && !['stopped', 'error', 'crashed', 'failed', 'completed'].includes(task.status)))) return false;
// If the tmux output still shows an in-flight download, the task isn't
// actually finished — hide the clear/check pill so it doesn't show on a
// task that's still doing work. (The next render will reflect this and
// ideally the self-heal flips status back to running.)
if (_downloadOutputLooksActive(task)) return false;
return ['done', 'stopped', 'error', 'crashed', 'failed'].includes(task.status);
return ['done', 'completed', 'stopped', 'error', 'crashed', 'failed'].includes(task.status);
}
function _clearPillLabel(task) {
@@ -66,6 +69,13 @@ function _clearPillLabel(task) {
return 'clear';
}
function _venvRootFromPath(path) {
let p = (path || '').toString().trim().replace(/\/+$/, '');
if (!p) return '';
p = p.replace(/\/bin\/(?:activate|python(?:3(?:\.\d+)?)?|vllm|pip(?:3)?)$/i, '');
return p;
}
// A pip dependency/driver install (payload._dep) reports success with the
// runner's "=== Process exited with code 0 ===" sentinel and pip's
// "Successfully installed" line — never the HuggingFace download markers
@@ -263,6 +273,7 @@ let _copyText;
let _persistEnvState;
let _refreshDependencies;
let _serverByVal;
let _serverKey;
let _selectedServer;
let modelLogo;
let esc;
@@ -688,8 +699,10 @@ export function _saveTasks(tasks) {
export function _addTask(sessionId, name, type, payload) {
let tasks = _loadTasks();
const remoteHost = (payload && payload.remote_host) || _envState.remoteHost || '';
const sshPort = (payload && payload.ssh_port) || _getPort(remoteHost) || '';
const platform = (payload && payload.platform) || _getPlatform(remoteHost) || '';
const remoteServerKey = (payload && payload.remote_server_key) || '';
const remoteServerName = (payload && payload.remote_server_name) || '';
const sshPort = (payload && payload.ssh_port) || _getPort(remoteServerKey || remoteHost) || '';
const platform = (payload && payload.platform) || _getPlatform(remoteServerKey || remoteHost) || '';
// Serving a model supersedes its finished download — clear the matching
// finished download card (covers serving directly from the Serve tab, not just
// via the download card's "Serve →" button).
@@ -704,7 +717,7 @@ export function _addTask(sessionId, name, type, payload) {
return !(key && t.type === 'download' && t.status === 'queued' && _downloadDedupeKey(t) === key);
});
}
const task = _stripTaskSecrets({ id: sessionId, sessionId, name, type, status: 'running', output: '', ts: Date.now(), payload: payload || null, remoteHost, sshPort, platform });
const task = _stripTaskSecrets({ id: sessionId, sessionId, name, type, status: 'running', output: '', ts: Date.now(), payload: payload || null, remoteHost, remoteServerKey, remoteServerName, sshPort, platform });
tasks.push(task);
_saveTasks(tasks);
// New action → collapse all other cards, leave only this one open.
@@ -1520,14 +1533,18 @@ function _parseServeCmdToFields(cmd) {
return fields;
}
export async function _launchServeTask(shortName, repo, cmd, fields, hostOverride) {
export async function _launchServeTask(shortName, repo, cmd, fields, hostOverride, targetMeta = null) {
// Host resolution mirrors the download path: when the caller passes an explicit
// host (resolved from the dropdown the user actually picked), use it and look
// up that server's port/platform from the shared servers list. Only fall back
// to _envState.remoteHost for legacy callers (diagnosis/pip-update).
const _host = (hostOverride !== undefined) ? (hostOverride || '') : (_envState.remoteHost || '');
const _hsrv = _serverByVal(_envState.remoteServerKey || _host)
const _targetKey = targetMeta?.serverKey || '';
const _hsrv = (_targetKey && _targetKey !== 'local' ? _serverByVal(_targetKey) : null)
|| (hostOverride === undefined ? _serverByVal(_envState.remoteServerKey || _host) : null)
|| _envState.servers.find(s => s.host === _host) || {};
const _serverMetaKey = _targetKey || (_hsrv && _serverKey ? _serverKey(_hsrv) : '') || (_host || 'local');
const _serverMetaName = targetMeta?.serverName || _hsrv.name || (_host ? _host : 'Local');
const _hplatform = _host ? (_hsrv.platform || '') : (_envState.platform || '');
// Replace any serve already targeting this same host:port — you can't run two
@@ -1572,7 +1589,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
}
} else {
if (_envState.env === 'venv' && _envState.envPath) {
const p = _envState.envPath;
const p = _venvRootFromPath(_envState.envPath);
envPrefix = 'source ' + (p.endsWith('/bin/activate') ? p : p + '/bin/activate');
} else if (_envState.env === 'conda' && _envState.envPath) {
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _envState.envPath;
@@ -1583,7 +1600,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
repo_id: repo,
cmd: cmd,
remote_host: _host || undefined,
ssh_port: _getPort(_host) || undefined,
ssh_port: _getPort(_serverMetaKey || _host) || undefined,
env_prefix: envPrefix || undefined,
hf_token: _envState.hfToken || undefined,
gpus: _envState.gpus || undefined,
@@ -1607,11 +1624,11 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
return;
}
const _sp = _getPort(_host);
const _sp = _getPort(_serverMetaKey || _host);
// _fields = the exact structured serve-form values used for this launch,
// so the "Edit / relaunch" button can re-open the Serve panel pre-filled
// with these precise settings (not just the last-used-for-repo state).
const payload = { repo_id: repo, remote_host: _host || undefined, ssh_port: _sp || undefined, _cmd: cmd, _fields: fields || undefined, _env: _usedEnv, _envPath: _usedEnvPath, _gpus: _usedGpus };
const payload = { repo_id: repo, remote_host: _host || undefined, remote_server_key: _serverMetaKey || undefined, remote_server_name: _serverMetaName || undefined, ssh_port: _sp || undefined, _cmd: cmd, _fields: fields || undefined, _env: _usedEnv, _envPath: _usedEnvPath, _gpus: _usedGpus };
_addTask(data.session_id, shortName, 'serve', payload);
uiModule.showToast(`Serving ${shortName}...`);
// Auto-register may have enabled an existing (offline) endpoint for this
@@ -1760,16 +1777,25 @@ export function _renderRunningTab() {
}
// Group tasks by server
const _serverName = (host) => {
if (!host) return 'Local';
const srv = _serverByVal(_envState.remoteServerKey || host)
|| _envState.servers.find(s => s.host === host);
return srv?.name || host;
const _taskServerKey = (task) => task?.remoteServerKey || task?.remoteHost || '';
const _serverName = (keyOrTask) => {
if (keyOrTask && typeof keyOrTask === 'object') {
const task = keyOrTask;
if (task.remoteServerName) return task.remoteServerName;
const srv = task.remoteServerKey ? _serverByVal(task.remoteServerKey) : null;
if (srv?.name) return srv.name;
if (!task.remoteHost) return 'Local';
return (_envState.servers.find(s => s.host === task.remoteHost)?.name) || task.remoteHost;
}
const key = keyOrTask || '';
if (!key || key === 'local') return 'Local';
const srv = _serverByVal(key);
return srv?.name || key;
};
const serverGroups = {};
for (const t of tasks) {
const key = t.remoteHost || '';
if (!serverGroups[key]) serverGroups[key] = { name: _serverName(key), serve: [], download: [] };
const key = _taskServerKey(t);
if (!serverGroups[key]) serverGroups[key] = { name: _serverName(t), serve: [], download: [] };
serverGroups[key][t.type === 'serve' ? 'serve' : 'download'].push(t);
}
@@ -1816,12 +1842,12 @@ export function _renderRunningTab() {
e.stopPropagation(); // don't toggle the section collapse (was an inline onclick, blocked by CSP)
const host = btn.dataset.clearServer;
const allTasks = _loadTasks();
const toRemove = allTasks.filter(t => (t.remoteHost || '') === host && _canClearTask(t));
const toRemove = allTasks.filter(t => _taskServerKey(t) === host && _canClearTask(t));
// Bail with a clear message instead of silently doing nothing when
// every task on this server is still running (nothing finished to
// clear yet) — the previous behavior looked like the button was dead.
if (!toRemove.length) {
const stillRunning = allTasks.filter(t => (t.remoteHost || '') === host && t.status === 'running').length;
const stillRunning = allTasks.filter(t => _taskServerKey(t) === host && t.status === 'running').length;
const _msg = stillRunning
? `No finished tasks on ${_serverName(host)}${stillRunning} still running. Stop them first to clear.`
: `No finished tasks on ${_serverName(host)}.`;
@@ -1830,7 +1856,7 @@ export function _renderRunningTab() {
return;
}
if (!await window.styledConfirm(`Clear ${toRemove.length} finished task${toRemove.length === 1 ? '' : 's'} on ${_serverName(host)}?`, { confirmText: 'Clear' })) return;
const remaining = allTasks.filter(t => (t.remoteHost || '') !== host || !_canClearTask(t));
const remaining = allTasks.filter(t => _taskServerKey(t) !== host || !_canClearTask(t));
_saveTasks(remaining);
// Fade/slide each finished card out (same exit as the per-card clear)
// instead of yanking them instantly.
@@ -1864,7 +1890,7 @@ export function _renderRunningTab() {
btn.addEventListener('click', async (e) => {
e.stopPropagation(); // don't toggle the section collapse
const host = btn.dataset.stopServer;
const running = _loadTasks().filter(t => (t.remoteHost || '') === host && t.status === 'running');
const running = _loadTasks().filter(t => _taskServerKey(t) === host && t.status === 'running');
if (!running.length) { uiModule.showToast(`Nothing running on ${_serverName(host)}`); return; }
if (!await window.styledConfirm(`Stop ${running.length} running task${running.length > 1 ? 's' : ''} on ${_serverName(host)}?`, { confirmText: 'Stop all' })) return;
// Mark every task as user-stopped BEFORE firing the kills so that the
@@ -2177,9 +2203,6 @@ export function _renderRunningTab() {
if (task.status !== 'running' && task.status !== 'queued') {
items.push({ group: 'run', label: 'Reconnect tmux', action: 'reconnect' });
}
if (task.status === 'running') {
items.push({ group: 'run', label: 'Stop', action: 'stop', danger: true });
}
items.push({ group: 'run', label: 'Restart', action: 'retry' });
// ── Edit section ────────────────────────────────────────────
// Merged "Edit & relaunch" — opens the structured serve panel
@@ -2539,7 +2562,7 @@ export function _renderRunningTab() {
});
// Route to the right server section body
const serverBodyId = `server-body-${(task.remoteHost || 'local').replace(/[^a-zA-Z0-9-]/g, '_')}`;
const serverBodyId = `server-body-${(_taskServerKey(task) || 'local').replace(/[^a-zA-Z0-9-]/g, '_')}`;
const targetBody = document.getElementById(serverBodyId);
if (targetBody) targetBody.appendChild(el);
else group.appendChild(el);
@@ -3393,7 +3416,8 @@ function _refreshServerDots() {
let tasks;
try { tasks = _loadTasks(); } catch { return; }
const byKey = {};
for (const t of tasks) { (byKey[t.remoteHost || ''] = byKey[t.remoteHost || ''] || []).push(t); }
const _taskServerKeyForDot = (task) => task?.remoteServerKey || task?.remoteHost || '';
for (const t of tasks) { (byKey[_taskServerKeyForDot(t)] = byKey[_taskServerKeyForDot(t)] || []).push(t); }
document.querySelectorAll('.cookbook-section-header').forEach(header => {
const dot = header.querySelector('.cookbook-srv-status');
if (!dot) return;
@@ -3798,6 +3822,7 @@ export function initRunning(shared) {
_persistEnvState = shared._persistEnvState;
_refreshDependencies = shared._refreshDependencies;
_serverByVal = shared._serverByVal;
_serverKey = shared._serverKey;
_selectedServer = shared._selectedServer;
modelLogo = shared.modelLogo;
esc = shared.esc;
+824 -111
View File
File diff suppressed because it is too large Load Diff
+155 -4
View File
@@ -24,6 +24,7 @@ import * as Modals from './modalManager.js';
let _autoDetectDebounce = null;
let _autoTitleDebounce = null;
let _autoSaveDebounce = null;
let _lastAutoSaveErrorAt = 0;
let _animationInProgress = false;
let _animationCancel = null; // function to cancel current animation
let _htmlPreviewActive = false; // true when inline HTML preview iframe is showing
@@ -153,6 +154,20 @@ import * as Modals from './modalManager.js';
addDocToTabs,
syncDocIndicator: _syncDocIndicator,
});
const sidebarNewDocBtn = document.getElementById('library-new-doc-btn');
if (sidebarNewDocBtn && !sidebarNewDocBtn.dataset.docNewWired) {
sidebarNewDocBtn.dataset.docNewWired = '1';
sidebarNewDocBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
try {
await newDocument();
} catch (err) {
console.error('Failed to create document from sidebar button:', err);
if (uiModule) uiModule.showError('Failed to create document');
}
});
}
_maybeOpenDocFromHash();
window.addEventListener('hashchange', _maybeOpenDocFromHash);
}
@@ -2685,6 +2700,104 @@ import * as Modals from './modalManager.js';
await _uploadComposeFiles(files);
}
function _isMarkdownImageFile(file) {
if (!file) return false;
if ((file.type || '').toLowerCase().startsWith('image/')) return true;
return /\.(avif|bmp|gif|jpe?g|png|svg|webp)$/i.test(file.name || '');
}
function _markdownImageAlt(name) {
const base = String(name || 'image').replace(/\.[^.]+$/, '').trim() || 'image';
return base.replace(/[\[\]\n\r]/g, ' ').replace(/\s+/g, ' ').trim() || 'image';
}
function _activeDocLanguage() {
const doc = activeDocId && docs.get(activeDocId);
return ((doc && doc.language) || document.getElementById('doc-language-select')?.value || '').toLowerCase();
}
function _scheduleMarkdownImageAutosave(ta) {
updateLineNumbers(ta.value);
const codeEl = document.getElementById('doc-editor-code');
if (codeEl && !codeEl.dataset.hasDiff) {
codeEl.textContent = ta.value + '\n';
codeEl.style.minHeight = ta.scrollHeight + 'px';
}
clearTimeout(_hlDebounce);
_hlDebounce = setTimeout(syncHighlighting, 80);
clearTimeout(_autoTitleDebounce);
_autoTitleDebounce = setTimeout(() => autoTitleFromContent(ta.value), 600);
clearTimeout(_autoSaveDebounce);
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
}
function _insertMarkdownImages(uploadedFiles) {
const ta = document.getElementById('doc-editor-textarea');
if (!ta) return;
const files = Array.isArray(uploadedFiles) ? uploadedFiles : [];
if (!files.length) return;
const start = ta.selectionStart || 0;
const end = ta.selectionEnd || start;
const before = ta.value.slice(0, start);
const after = ta.value.slice(end);
const lines = files.map(file => {
const id = encodeURIComponent(file.id || file.file_id || '');
const alt = _markdownImageAlt(file.name || file.filename);
return id ? `![${alt}](/api/upload/${id})` : '';
}).filter(Boolean);
if (!lines.length) return;
const prefix = before && !before.endsWith('\n') ? '\n' : '';
const suffix = after && !after.startsWith('\n') ? '\n' : '';
const insert = `${prefix}${lines.join('\n\n')}${suffix}`;
_replaceRange(ta, start, end, insert);
const caret = start + insert.length;
ta.selectionStart = caret;
ta.selectionEnd = caret;
ta.focus();
_scheduleMarkdownImageAutosave(ta);
_refreshMarkdownPreviewIfVisible(activeDocId, ta.value);
}
async function _uploadMarkdownImages(files) {
const images = Array.from(files || []).filter(_isMarkdownImageFile);
if (!images.length) {
if (uiModule) uiModule.showError('Choose an image file');
return;
}
if (_activeDocLanguage() !== 'markdown') {
if (uiModule) uiModule.showError('Switch the document to markdown before inserting images');
return;
}
const fd = new FormData();
images.forEach(file => fd.append('files', file));
try {
const res = await fetch(`${API_BASE}/api/upload`, {
method: 'POST',
credentials: 'same-origin',
body: fd,
});
let data = null;
try { data = await res.json(); } catch (_) {}
if (!res.ok) throw new Error((data && (data.error || data.detail)) || `HTTP ${res.status}`);
const uploaded = Array.isArray(data?.files) ? data.files : [];
if (!uploaded.length) throw new Error('No uploaded files returned');
_insertMarkdownImages(uploaded);
if (uiModule) uiModule.showToast(images.length === 1 ? 'Image inserted' : 'Images inserted');
} catch (err) {
console.error('Failed to insert markdown image:', err);
if (uiModule) uiModule.showError('Failed to insert image');
}
}
async function _handleMarkdownImageUpload(e) {
const files = e.target.files;
e.target.value = '';
await _uploadMarkdownImages(files);
}
function _renderComposeAttachments() {
const container = document.getElementById('doc-email-compose-atts');
if (!container) return;
@@ -3751,9 +3864,12 @@ import * as Modals from './modalManager.js';
const res = await fetch(`${API_BASE}/api/document`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ session_id: sessionId, title: '', content }),
});
if (!res.ok) throw new Error(`Document create failed: HTTP ${res.status}`);
const doc = await res.json();
if (!doc || !doc.id) throw new Error('Document create failed: missing id');
addDocToTabs(doc, sessionId);
// Set the content into the map so switchToDoc preserves it
const d = docs.get(doc.id);
@@ -3980,6 +4096,7 @@ import * as Modals from './modalManager.js';
<input type="hidden" id="doc-email-source-folder" />
<input type="file" id="doc-email-file-input" multiple style="display:none" />
</div>
<input type="file" id="doc-md-image-input" accept="image/*" multiple style="display:none" />
<div class="doc-md-toolbar" id="doc-md-toolbar" style="display:none">
<div class="md-toolbar-items" id="md-toolbar-items">
<span class="md-view-toggle" id="doc-md-view-toggle" style="display:none" role="group" aria-label="Edit or preview">
@@ -4002,7 +4119,7 @@ import * as Modals from './modalManager.js';
<button type="button" class="md-dd-toggle" data-dd="list" title="List"><span style="font-variant-numeric:tabular-nums;">1.</span><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<span class="md-toolbar-sep"></span>
<button type="button" data-md="link" title="Link"><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="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>
<button type="button" id="md-toolbar-attach-btn" class="md-toolbar-attach-btn" title="Attach files"><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.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></button>
<button type="button" id="md-toolbar-attach-btn" class="md-toolbar-attach-btn" title="Insert image"><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.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></button>
<button type="button" class="md-dd-toggle md-toolbar-email-hide" data-dd="code" title="Code">\`<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
<button type="button" data-md="hr" title="Horizontal rule"></button>
<span class="md-toolbar-sep"></span>
@@ -4601,9 +4718,14 @@ import * as Modals from './modalManager.js';
document.getElementById('doc-email-file-input')?.click();
});
document.getElementById('md-toolbar-attach-btn')?.addEventListener('click', () => {
document.getElementById('doc-email-file-input')?.click();
if (_activeDocLanguage() === 'email') {
document.getElementById('doc-email-file-input')?.click();
} else {
document.getElementById('doc-md-image-input')?.click();
}
});
document.getElementById('doc-email-file-input')?.addEventListener('change', _handleAttachUpload);
document.getElementById('doc-md-image-input')?.addEventListener('change', _handleMarkdownImageUpload);
// Cc/Bcc toggle
document.getElementById('doc-email-show-cc')?.addEventListener('click', () => {
@@ -4839,6 +4961,26 @@ import * as Modals from './modalManager.js';
clearTimeout(_autoSaveDebounce);
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 2000);
});
ta.addEventListener('paste', (e) => {
if (_activeDocLanguage() !== 'markdown') return;
const files = Array.from(e.clipboardData?.files || []).filter(_isMarkdownImageFile);
if (!files.length) return;
e.preventDefault();
_uploadMarkdownImages(files);
});
ta.addEventListener('dragover', (e) => {
if (_activeDocLanguage() !== 'markdown') return;
const items = Array.from(e.dataTransfer?.items || []);
if (!items.some(item => item.kind === 'file' && /^image\//i.test(item.type || ''))) return;
e.preventDefault();
});
ta.addEventListener('drop', (e) => {
if (_activeDocLanguage() !== 'markdown') return;
const files = Array.from(e.dataTransfer?.files || []).filter(_isMarkdownImageFile);
if (!files.length) return;
e.preventDefault();
_uploadMarkdownImages(files);
});
ta.addEventListener('scroll', () => {
const code = document.getElementById('doc-editor-code');
if (code) code.style.minHeight = ta.scrollHeight + 'px';
@@ -5547,7 +5689,7 @@ import * as Modals from './modalManager.js';
// any dropdown that just opened. Preventing the default mousedown keeps the
// textarea focused, so formatting hits the live selection and menus stay up.
toolbar.addEventListener('mousedown', (e) => {
if (e.target.closest('[data-md], .md-dd-toggle, .emoji-picker-btn')) e.preventDefault();
if (e.target.closest('[data-md], .md-dd-toggle, .emoji-picker-btn, .md-toolbar-attach-btn')) e.preventDefault();
});
toolbar.addEventListener('click', (e) => {
@@ -5975,6 +6117,7 @@ import * as Modals from './modalManager.js';
const res = await fetch(`${API_BASE}/api/document`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
session_id: sessionId,
title: '',
@@ -5982,7 +6125,9 @@ import * as Modals from './modalManager.js';
language: 'markdown',
}),
});
if (!res.ok) throw new Error(`Document create failed: HTTP ${res.status}`);
const doc = await res.json();
if (!doc || !doc.id) throw new Error('Document create failed: missing id');
addDocToTabs(doc, sessionId);
if (!isOpen) openPanel();
// Re-enable editor if it was in empty state
@@ -8265,8 +8410,10 @@ import * as Modals from './modalManager.js';
const res = await fetch(`${API_BASE}/api/document/${activeDocId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ content: textarea.value }),
});
if (!res.ok) throw new Error(`Document save failed: HTTP ${res.status}`);
const doc = await res.json();
const badge = document.getElementById('doc-version-badge');
if (badge) { const _v = doc.version_count || 1; badge.textContent = `v${_v}`; badge.style.display = _v > 1 ? '' : 'none'; }
@@ -8279,7 +8426,11 @@ import * as Modals from './modalManager.js';
if (!silent && uiModule) uiModule.showToast('Document saved');
} catch (e) {
console.error('Failed to save document:', e);
if (!silent && uiModule) uiModule.showError('Failed to save document');
const now = Date.now();
if (uiModule && (!silent || now - _lastAutoSaveErrorAt > 10000)) {
uiModule.showError(silent ? 'Autosave failed' : 'Failed to save document');
_lastAutoSaveErrorAt = now;
}
}
}
+39 -12
View File
@@ -4574,11 +4574,12 @@ function _wireAttachmentHandlers(reader, folder) {
const uid = openBtn.dataset.openUid;
const index = openBtn.dataset.openIndex;
const name = openBtn.dataset.openName || `attachment-${index}`;
const sourceFolder = openBtn.dataset.openFolder || useFolder;
if (!uid || index == null) return;
const orig = openBtn.style.opacity;
openBtn.style.opacity = '0.4';
try {
const folderQs = encodeURIComponent(useFolder);
const folderQs = encodeURIComponent(sourceFolder);
const res = await fetch(
`${API_BASE}/api/email/attachment-as-doc/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${folderQs}${_acct()}`,
{ method: 'POST', credentials: 'same-origin' }
@@ -4632,8 +4633,9 @@ function _wireAttachmentHandlers(reader, folder) {
const uid = chip.dataset.attUid;
const index = chip.dataset.attIndex;
const name = chip.dataset.attName || `attachment-${index}`;
const sourceFolder = chip.dataset.attFolder || useFolder;
if (!uid || index == null) return;
const url = `${API_BASE}/api/email/attachment/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${encodeURIComponent(useFolder)}${_acct()}`;
const url = `${API_BASE}/api/email/attachment/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${encodeURIComponent(sourceFolder)}${_acct()}`;
if (_isMobileUA) {
window.open(url, '_blank');
return;
@@ -4712,25 +4714,50 @@ function _isLikelySignatureImage(a) {
// Build the attachments header+chips HTML for an email read response. Pulled
// out so both the initial-open and the swap-reader paths can render it.
function _buildAttsHtmlFor(uid, data) {
if (!data || !data.attachments || !data.attachments.length) return '';
const _OPENABLE_RE = /\.(pdf|docx|txt|md|markdown)$/i;
const visible = data.attachments.filter(a => !_isLikelySignatureImage(a));
if (!visible.length) return '';
const chips = visible.map(a => {
if (!data) return '';
const _OPENABLE_RE = /\.(pdf|docx|txt|md|markdown|eml)$/i;
const currentAttachments = Array.isArray(data.attachments) ? data.attachments : [];
const relatedAttachments = Array.isArray(data.related_attachments) ? data.related_attachments : [];
if (!currentAttachments.length && !relatedAttachments.length) return '';
const visible = currentAttachments.filter(a => !_isLikelySignatureImage(a));
const hidden = currentAttachments.filter(a => _isLikelySignatureImage(a));
const related = relatedAttachments.filter(a => !_isLikelySignatureImage(a));
const renderChip = (a, extraClass = '') => {
const openable = _OPENABLE_RE.test(a.filename || '');
const chipUid = a.source_uid || a.uid || uid;
const chipFolder = a.source_folder || data.folder || state._libFolder || 'INBOX';
const openBtn = openable
? `<span class="email-attachment-open" title="Open in document editor" data-open-uid="${_esc(uid)}" data-open-index="${a.index}" data-open-name="${_esc(a.filename)}"><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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/></svg><span class="email-attachment-open-label">Open</span></span>`
? `<span class="email-attachment-open" title="Open in document editor" data-open-uid="${_esc(chipUid)}" data-open-index="${a.index}" data-open-name="${_esc(a.filename)}" data-open-folder="${_esc(chipFolder)}"><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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/></svg><span class="email-attachment-open-label">Open</span></span>`
: '';
return `<button type="button" class="email-attachment-chip" data-att-uid="${_esc(uid)}" data-att-index="${a.index}" data-att-name="${_esc(a.filename)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>${_esc(a.filename)}</span><span class="att-size">${Math.round((a.size||0)/1024)} KB</span>${openBtn}</button>`;
}).join('');
return `<button type="button" class="email-attachment-chip${extraClass}" data-att-uid="${_esc(chipUid)}" data-att-index="${a.index}" data-att-name="${_esc(a.filename)}" data-att-folder="${_esc(chipFolder)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>${_esc(a.filename)}</span><span class="att-size">${Math.round((a.size||0)/1024)} KB</span>${openBtn}</button>`;
};
const chips = visible.map(a => renderChip(a)).join('');
const hiddenChips = hidden.map(a => renderChip(a, ' email-attachment-chip-muted')).join('');
const relatedChips = related.map(a => renderChip(a, ' email-attachment-chip-related')).join('');
const visibleSection = visible.length
? '<div class="email-reader-atts">' + chips + '</div>'
: '';
const relatedSection = related.length
? '<div class="email-reader-atts-hidden-note">From earlier in this thread</div><div class="email-reader-atts email-reader-atts-related">' + relatedChips + '</div>'
: '';
const hiddenSection = hidden.length
? '<div class="email-reader-atts-hidden-note">Filtered inline images / signature files</div><div class="email-reader-atts email-reader-atts-hidden">' + hiddenChips + '</div>'
: '';
const label = visible.length
? `Attachments (${visible.length + related.length})`
: related.length
? `Thread attachments (${related.length})`
: `Hidden inline attachments (${hidden.length})`;
return (
'<div class="email-reader-atts-wrap collapsed">'
+ '<div class="email-reader-atts-header email-summary-toggle" role="button" tabindex="0">'
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>'
+ `<span>Attachments (${data.attachments.length})</span>`
+ `<span>${label}</span>`
+ '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
+ '</div>'
+ '<div class="email-reader-atts">' + chips + '</div>'
+ visibleSection
+ relatedSection
+ hiddenSection
+ '</div>'
);
}
+22 -3
View File
@@ -36,6 +36,14 @@ function linkHtml(text, url) {
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer">${safeText}</a>`;
}
function imageHtml(alt, url, title) {
const safeUrl = safeLinkUrl(url);
if (!safeUrl || safeUrl.startsWith('#')) return escapeHtml(alt || '');
const safeAlt = escapeHtml(alt || '');
const safeTitle = title ? ` title="${escapeHtml(title)}"` : '';
return `<img src="${escapeHtml(safeUrl)}" alt="${safeAlt}"${safeTitle} loading="lazy" decoding="async">`;
}
function _isModelEndpointUrl(rawUrl) {
try {
const parsed = new URL(String(rawUrl || ''), window.location.origin);
@@ -146,7 +154,7 @@ function sanitizeAllowedHtml(html) {
* Check if text has unclosed think tag
*/
export function hasUnclosedThinkTag(text) {
text = text || '';
text = normalizeThinkingMarkup(text || '');
const openCount =
(text.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi) || []).length
+ (text.match(/<\|channel>thought/gi) || []).length;
@@ -163,6 +171,10 @@ export function startsWithReasoningPrefix(text) {
export function normalizeThinkingMarkup(text) {
if (!text) return text;
let normalized = text;
// MiniMax M-series can emit namespaced reasoning tags like
// <mm:think>...</mm:think>. Normalize them into the shared thinking parser.
normalized = normalized.replace(/<mm:think(\s+[^>]*)?>/gi, (_m, attrs = '') => `<think${attrs || ''}>`);
normalized = normalized.replace(/<\/mm:think>/gi, '</think>');
normalized = normalized.replace(/<thought(\s+[^>]*)?>/gi, (_m, attrs = '') => `<think${attrs || ''}>`);
normalized = normalized.replace(/<\/thought>/gi, '</think>');
normalized = normalized.replace(/<\|channel>thought\s*\n?([\s\S]*?)<channel\|>\s*/gi, (_m, content = '') => {
@@ -535,6 +547,12 @@ export function mdToHtml(src, opts) {
'$1[#$2](#$2)',
);
// Convert markdown images before links so ![alt](url) does not become
// literal "!" plus a normal link.
s = s.replace(/!\[([^\]\n]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (match, alt, url, title) => {
return imageHtml(alt, url, title);
});
// Convert markdown links [text](url) to clickable links
// Internal #hash links navigate in-page; external links open in new tab
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
@@ -573,8 +591,9 @@ export function mdToHtml(src, opts) {
return placeholder;
});
// ALSO preserve <a> tags the same way (they're now in the HTML from markdown conversion)
s = s.replace(/<a\s+[^>]*>.*?<\/a>/gi, (match) => {
// ALSO preserve <a>/<img> tags the same way (they're now in the HTML from
// markdown conversion)
s = s.replace(/<(?:a\s+[^>]*>.*?<\/a|img\s+[^>]*?)>/gi, (match) => {
const placeholder = `___ALLOWED_HTML_${allowedHtmlBlocks.length}___`;
allowedHtmlBlocks.push(sanitizeAllowedHtml(match));
return placeholder;
+21
View File
@@ -112,6 +112,7 @@ function _initModelPickerDropdown() {
const search = document.getElementById('model-picker-search');
const listEl = document.getElementById('model-picker-list');
const searchRow = menu ? menu.querySelector('.model-picker-search-row') : null;
const refreshBtn = document.getElementById('model-picker-refresh-btn');
if (!wrap || !btn || !menu || !search || !listEl) return;
function _close() {
@@ -608,6 +609,26 @@ function _initModelPickerDropdown() {
search.addEventListener('input', () => _populate(search.value));
search.addEventListener('click', (e) => e.stopPropagation());
if (refreshBtn) {
refreshBtn.addEventListener('click', async (e) => {
e.stopPropagation();
refreshBtn.disabled = true;
refreshBtn.classList.add('spinning');
try {
if (window.modelsModule && window.modelsModule.refreshModels) {
await window.modelsModule.refreshModels(true);
}
await _refreshLocalProbe();
if (!menu.classList.contains('hidden')) _populate(search.value || '');
updateModelPicker();
} catch (_) {
uiModule.showToast('Model refresh failed');
} finally {
refreshBtn.disabled = false;
refreshBtn.classList.remove('spinning');
}
});
}
search.addEventListener('keydown', (e) => {
_handlePickerKeydown(e, listEl, '.model-switch-item', _close);
});
+22 -3
View File
@@ -17,9 +17,16 @@ let _tasksFetched = false; // first-fetch sentinel — `false` → show loadin
let _escHandler = null;
let _viewingRuns = null; // task id when viewing run history
let _clockInterval = null;
let _taskFailurePending = false;
const DAYS_OF_WEEK = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
function _setTaskFailurePending(active) {
_taskFailurePending = !!active;
document.getElementById('tool-tasks-btn')?.classList.toggle('task-failure-pending', _taskFailurePending);
document.getElementById('rail-tasks')?.classList.toggle('task-failure-pending', _taskFailurePending);
}
// ---- API ----
async function _fetchTasks() {
@@ -2238,6 +2245,9 @@ function _renderActivityEntry(entry) {
status = _classifyResult(entry.result);
}
const statusDot = `<span class="task-log-status task-log-status-${status}" title="${status}"></span>`;
const failedTag = status === 'error'
? '<span class="task-log-failed-tag">(failed)</span>'
: '';
// Render the result through markdown so code blocks, lists, links look right.
let resultHtml;
const _isRunning = entry.status === 'running' || entry.status === 'queued';
@@ -2361,7 +2371,7 @@ function _renderActivityEntry(entry) {
<div class="task-log-row-head">
${statusDot}
<span class="task-log-task-icon">${_taskIcon({ action: entry.action, task_type: entry.kind })}</span>
<span class="task-log-name">${_escHtml(entry.taskName)}</span>${_taskAiMark(entry)}
<span class="task-log-name">${_escHtml(entry.taskName)}</span>${failedTag}${_taskAiMark(entry)}
${repeatBadge}
<span style="flex:1"></span>
${rightHtml}
@@ -2502,8 +2512,11 @@ function _renderMainView() {
export function openTasks(focusId, opts) {
const o = opts || {};
const openActivityForFailure = _taskFailurePending && !focusId && o.filter === undefined;
_setTaskFailurePending(false);
if (_open) {
// Already open — just focus the requested task / apply filter.
if (openActivityForFailure) _switchTab('activity');
if (o.filter !== undefined) { _taskFilter = o.filter; _renderList(); }
if (focusId) _focusTask(focusId);
return;
@@ -2610,7 +2623,7 @@ export function openTasks(focusId, opts) {
// of an empty modal-body that fills in after the fetch resolves — that delay
// was visible as a "flicker" right after opening.
_activeTab = 'tasks';
_switchTab('tasks');
_switchTab(openActivityForFailure ? 'activity' : 'tasks');
_fetchTasks().then(() => {
// Re-render so the list swaps the Loading row for real cards.
_renderList();
@@ -2704,7 +2717,13 @@ async function _pollTaskNotifications() {
const msg = `Task ${ok ? 'finished' : 'failed'}: ${n.task_name}`;
if (!uiModule) continue;
if (ok) uiModule.showToast(msg, { duration: 5000 });
else uiModule.showError(msg);
else {
_setTaskFailurePending(true);
uiModule.showError(msg);
if (_open && document.querySelector('.tasks-tab.active[data-tab="activity"]')) {
_renderActivityView();
}
}
}
} catch (e) {
// Silently ignore — server may be unreachable
+483 -37
View File
@@ -2676,6 +2676,15 @@ body.bg-pattern-sparkles {
.mode-toggle.mode-right::before {
transform: translateX(100%);
}
.mode-toggle.mode-toggle-three::before {
width: 33.3333%;
}
.mode-toggle.mode-toggle-three.mode-mid::before {
transform: translateX(100%);
}
.mode-toggle.mode-toggle-three.mode-third::before {
transform: translateX(200%);
}
#mode-agent-btn {
border-radius: 10px 0 0 10px;
}
@@ -2715,6 +2724,15 @@ body.bg-pattern-sparkles {
.mode-toggle-btn + .mode-toggle-btn {
border-left: none;
}
@media (max-width: 768px) {
.mode-toggle.mode-toggle-three [data-llama-mode="unified"] > span {
font-size: 0;
}
.mode-toggle.mode-toggle-three [data-llama-mode="unified"] > span::after {
content: 'Unif';
font-size: 11px;
}
}
/* Message count badge in the chat-meta header (next to the title).
Auto-hides when empty so a brand-new chat doesn't show "0 msgs". */
.chat-meta-count {
@@ -2822,10 +2840,14 @@ body.bg-pattern-sparkles {
transform: translateX(10px) scale(0.88);
pointer-events: none;
}
.model-picker-search-wrap {
position: relative;
min-width: 0;
}
.model-picker-menu input[type="text"] {
width: 100%;
box-sizing: border-box;
padding: 6px 8px;
padding: 6px 30px 6px 8px;
font-size: 0.82em;
border: 1px solid var(--border);
border-radius: 4px;
@@ -2842,6 +2864,37 @@ body.bg-pattern-sparkles {
.model-picker-menu input[type="text"]::placeholder {
color: color-mix(in srgb, var(--fg) 30%, transparent);
}
.model-picker-refresh-btn {
appearance: none;
position: absolute;
top: 50%;
right: 4px;
transform: translateY(-50%);
width: 22px;
height: 22px;
border: 0;
border-radius: 4px;
background: transparent;
color: color-mix(in srgb, var(--fg) 54%, transparent);
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
cursor: pointer;
}
.model-picker-refresh-btn:hover,
.model-picker-refresh-btn:focus-visible {
color: var(--fg);
background: color-mix(in srgb, var(--fg) 8%, transparent);
outline: none;
}
.model-picker-refresh-btn:disabled {
opacity: 0.7;
cursor: default;
}
.model-picker-refresh-btn.spinning svg {
animation: model-picker-refresh-spin 0.75s linear infinite;
}
.model-picker-action-btn {
appearance: none;
display: inline-flex;
@@ -4102,7 +4155,7 @@ body.bg-pattern-sparkles {
.sidebar {
position: fixed !important;
top: 0; bottom: 0; left: 0;
z-index: 200;
z-index: 400;
width: 80% !important;
max-width: 340px;
box-shadow: 4px 0 20px rgba(0,0,0,0.5);
@@ -4133,7 +4186,7 @@ body.bg-pattern-sparkles {
#sidebar-backdrop {
position: fixed;
inset: 0;
z-index: 199;
z-index: 390;
background: rgba(0,0,0,0.4);
opacity: 0;
pointer-events: none;
@@ -6882,7 +6935,7 @@ pre { background: var(--code-bg, var(--hl-bg, #282c34)) !important; }
inside .chat-input-top, matching .model-picker-wrap's slot. */
.chat-input-top > .cmp-eval-wrap {
position: absolute;
top: 0; right: 0;
top: -2px; right: 0;
z-index: 2;
}
.cmp-eval-btn {
@@ -7224,14 +7277,16 @@ pre { background: var(--code-bg, var(--hl-bg, #282c34)) !important; }
width: 100%; order: 99; margin-top: -4px; padding-bottom: 2px; padding-left: 2px;
}
.pane-finish-badge {
font-weight: 600; color: var(--red);
font-weight: 600; color: var(--green, #50fa7b);
position: relative;
top: 2px;
}
.compare-pane.winner .pane-header {
background: color-mix(in srgb, var(--red) 12%, transparent);
border-bottom-color: color-mix(in srgb, var(--red) 30%, var(--border));
background: color-mix(in srgb, var(--green, #50fa7b) 12%, transparent);
border-bottom-color: color-mix(in srgb, var(--green, #50fa7b) 30%, var(--border));
}
.compare-pane.winner .pane-title {
color: var(--red);
color: var(--green, #50fa7b);
}
.compare-pane.loser .pane-header {
opacity: 0.5;
@@ -7501,6 +7556,33 @@ pre { background: var(--code-bg, var(--hl-bg, #282c34)) !important; }
padding: 14px 8px 10px 8px !important;
min-height: 44px;
}
.compare-pane .pane-header {
align-items: flex-start;
row-gap: 3px;
}
.compare-pane .pane-title-btn {
flex: 0 0 100%;
width: 100%;
order: 0;
padding-right: 2px;
}
.compare-pane .pane-timer,
.compare-pane .pane-finish-badge,
.compare-pane .pane-actions {
order: 1;
}
.compare-grid[data-cols="4"] .compare-pane .pane-timer,
.compare-grid[data-cols="5"] .compare-pane .pane-timer,
.compare-grid[data-cols="6"] .compare-pane .pane-timer {
width: auto;
order: 1;
margin-top: 0;
padding-bottom: 0;
padding-left: 0;
}
.compare-pane .pane-actions {
margin-left: auto;
}
/* Mode tabs: icons only, centered */
.compare-mode-tab span { display: none; }
.compare-mode-tabs { justify-content: center; }
@@ -18869,7 +18951,7 @@ body.gallery-selecting .gallery-dl-btn,
top: -6px;
}
#serve-bulk-bar #serve-bulk-cancel {
top: 0 !important;
top: -2px !important;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -19212,6 +19294,14 @@ body.gallery-selecting .gallery-dl-btn,
position: relative;
top: -3px;
}
@media (max-width: 768px) {
.cookbook-section-header .cookbook-clear-btn {
top: -3px;
}
.cookbook-task-check {
top: 4px;
}
}
/* "Stop all" sits just left of "Clear finished"; it carries the auto margin so
the pair is pushed together to the right of the section title. */
.cookbook-section-header .cookbook-stop-all-btn {
@@ -19355,25 +19445,68 @@ body.gallery-selecting .gallery-dl-btn,
.cookbook-saved-save {
padding: 0 10px;
gap: 4px;
background: var(--red);
color: #fff;
border-color: var(--red);
background: var(--bg);
color: var(--fg);
border-color: var(--border);
font-weight: 600;
opacity: 1;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.cookbook-saved-save:hover {
background: color-mix(in srgb, var(--red) 80%, white);
border-color: color-mix(in srgb, var(--red) 80%, white);
background: var(--border);
border-color: var(--accent);
opacity: 1;
}
.cookbook-saved-arrow {
padding: 0 6px;
background: var(--bg);
color: var(--fg);
border-color: var(--border);
opacity: 1;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: none;
}
.cookbook-saved-menu .cookbook-saved-favorite {
border-left: 3px solid var(--red);
background: color-mix(in srgb, var(--red) 4%, transparent);
}
.cookbook-saved-fav-btn {
width: 20px;
height: 20px;
padding: 0;
border: 1px solid transparent;
border-radius: 6px;
background: none;
color: var(--fg-muted);
opacity: 0.55;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.cookbook-saved-fav-btn:hover,
.cookbook-saved-fav-btn.active {
color: var(--red);
opacity: 1;
background: color-mix(in srgb, var(--red) 10%, transparent);
}
.cookbook-saved-fav-badge {
flex-shrink: 0;
}
.cookbook-serve-favorite-model {
border-left-color: var(--red) !important;
background: color-mix(in srgb, var(--red) 4%, transparent);
}
.cookbook-serve-fav-badge {
display: inline-flex;
align-items: center;
vertical-align: 1px;
margin-left: 5px;
margin-right: 2px;
}
.cookbook-slot-saved { background: color-mix(in srgb, var(--accent) 10%, transparent); border-color: color-mix(in srgb, var(--accent) 30%, transparent); color: var(--accent); }
.cookbook-slot-saved:hover { background: color-mix(in srgb, var(--accent) 20%, transparent); }
.cookbook-slot-btn.active { opacity: 1; background: var(--accent); color: #fff; border-color: var(--accent);
@@ -19502,9 +19635,9 @@ body.gallery-selecting .gallery-dl-btn,
background: color-mix(in srgb, var(--green, #50fa7b) 18%, transparent);
color: var(--green, #50fa7b);
border: 1px solid color-mix(in srgb, var(--green, #50fa7b) 35%, transparent);
/* Match the Install button + Installed split width so all three variants
align in a mixed row. */
min-width: 75.85px;
/* Match Install + Installed ▾ so all three variants align in mixed rows. */
width: 87.7px;
min-width: 87.7px;
padding: 0 10px;
box-sizing: border-box;
}
@@ -19527,9 +19660,8 @@ body.gallery-selecting .gallery-dl-btn,
font-weight: 500;
position: relative;
top: -3px;
/* Width matches the measured Installed split button (75.85px) so a row of
mixed Install / Installed deps lines up. */
min-width: 75.85px;
width: 87.7px;
min-width: 87.7px;
padding: 0 10px;
/* Strip the native button box so it's the same height as the sibling tags
(Firefox renders <button> taller otherwise); height comes from .cookbook-dep-tag. */
@@ -19574,6 +19706,8 @@ body.gallery-selecting .gallery-dl-btn,
opens the actions menu (Update). Replaces the old button. */
.cookbook-dep-installed-btn {
padding: 0;
width: 87.7px;
min-width: 87.7px;
cursor: pointer;
font-family: inherit;
overflow: hidden;
@@ -19619,8 +19753,20 @@ body.gallery-selecting .gallery-dl-btn,
padding now (16px 8px) per follow-up tweak, brings the title back
over the actual Save button instead of overshooting it. */
.hwfit-serve-row label:has(> .cookbook-serve-slots) {
grid-column: -2 / -1;
justify-self: end;
text-align: right;
padding-right: 8px;
padding-right: 0;
}
.hwfit-serve-row label:has(> .cookbook-serve-slots) > span {
display: inline-block;
position: relative;
left: -33px;
}
.hwfit-serve-preset-row {
display: flex;
justify-content: flex-end;
margin: 0 0 6px;
}
/* Expanded serve panel make sure it can be scrolled past when it
grows taller than the visible viewport. Caps panel height to viewport
@@ -19635,6 +19781,10 @@ body.gallery-selecting .gallery-dl-btn,
.hwfit-cached-item .hwfit-serve-panel {
max-height: calc(100svh - 120px);
}
.hwfit-serve-preset-row {
justify-content: flex-end;
margin: -2px 0 6px;
}
}
.hwfit-serve-row label {
font-size: 10px;
@@ -19642,6 +19792,114 @@ body.gallery-selecting .gallery-dl-btn,
white-space: nowrap;
letter-spacing: 0.3px;
}
.hwfit-serve-row-core .hwfit-context-label {
grid-column: span 2;
width: auto;
min-width: 0;
max-width: none;
justify-self: start;
}
.hwfit-context-control {
display: block;
position: relative;
align-items: center;
margin-top: 2px;
width: 100px;
}
.hwfit-context-control .hwfit-sf[data-field="ctx"] {
min-width: 0;
width: 100%;
padding-right: 40px;
}
.hwfit-serve-row-core label:has(.hwfit-sf[data-field="max_seqs"]) {
position: relative;
left: 0;
}
.hwfit-serve-row-core label:has(.hwfit-sf[data-field="gpu_mem"]) {
position: relative;
left: -1px;
}
.hwfit-serve-row-core .hwfit-gpus-label {
position: relative;
left: -69px;
}
.hwfit-context-calc-btn {
position: absolute;
right: 3px;
top: -4px;
width: 34px;
height: 28px;
min-width: 34px;
padding: 0;
font-size: 10px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
overflow: hidden;
white-space: nowrap;
}
.hwfit-context-calc-btn .spinner-whirlpool {
width: 12px !important;
height: 12px !important;
margin: 0 auto !important;
}
.hwfit-context-calc-btn .ai-spinner-whirlpool {
width: 12px !important;
height: 12px !important;
}
@media (min-width: 769px) {
[data-llama-mode-toggle].mode-toggle.mode-toggle-three .mode-toggle-btn > span {
top: -6px !important;
}
.mode-toggle.mode-toggle-three [data-llama-mode="unified"] > span {
font-size: 0;
}
.mode-toggle.mode-toggle-three [data-llama-mode="unified"] > span::after {
content: 'Unif';
font-size: 11px;
}
}
@media (max-width: 768px) {
.hwfit-serve-row-core .hwfit-context-label {
display: flex;
flex-direction: column;
align-items: flex-start;
}
details.hwfit-serve-advanced label:has(.hwfit-sf[data-field="vllm_env_preset"]),
details.hwfit-serve-advanced label:has(.hwfit-sf[data-field="extra_env"]) {
grid-column: 1 / -1 !important;
}
.hwfit-context-control {
display: inline-flex;
align-items: center;
gap: 4px;
width: auto;
}
.hwfit-context-control .hwfit-sf[data-field="ctx"] {
width: 100px;
padding-right: 6px;
}
.hwfit-context-calc-btn {
position: relative;
top: -2px;
left: 2px;
right: auto;
flex: 0 0 34px;
width: 34px;
min-width: 34px;
}
}
.hwfit-auto-ctx-note {
display: block;
margin-top: 3px;
font-size: 9px;
opacity: 0.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hwfit-serve-row label select,
.hwfit-serve-row label input {
display: block;
@@ -19917,23 +20175,66 @@ body.gallery-selecting .gallery-dl-btn,
.hwfit-serve-extra .hwfit-sf {
width: 100%;
}
.hwfit-serve-cmd-details {
margin: 6px 0 0;
}
.hwfit-serve-cmd-summary {
display: flex;
align-items: center;
gap: 6px;
list-style: none;
cursor: pointer;
user-select: none;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 5px;
background: color-mix(in srgb, var(--fg) 4%, transparent);
color: var(--fg-muted);
font-size: 10px;
letter-spacing: 0.3px;
}
.hwfit-serve-cmd-summary::-webkit-details-marker {
display: none;
}
.hwfit-serve-cmd-summary::after {
content: '';
margin-left: auto;
width: 0;
height: 0;
border-left: 4px solid currentColor;
border-top: 3px solid transparent;
border-bottom: 3px solid transparent;
opacity: 0.6;
transform: rotate(0deg);
transition: transform 0.18s ease;
}
.hwfit-serve-cmd-details[open] > .hwfit-serve-cmd-summary::after {
transform: rotate(90deg);
}
.hwfit-serve-cmd-details[open] > .hwfit-serve-cmd-summary {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.hwfit-serve-cmd {
margin: 6px 0;
margin: 0;
padding: 8px 10px;
background: color-mix(in srgb, var(--fg) 4%, transparent);
border: 1px solid var(--border);
border-top: none;
border-radius: 4px;
border-top-left-radius: 0;
border-top-right-radius: 0;
font-family: 'Berkeley Mono', 'SF Mono', 'Fira Code', monospace;
font-size: 10px;
white-space: pre-wrap;
word-break: break-all;
white-space: pre;
word-break: normal;
width: 100%;
box-sizing: border-box;
resize: none;
color: var(--fg);
line-height: 1.5;
min-height: 36px;
overflow: hidden;
min-height: 112px;
overflow: auto;
}
.hwfit-serve-actions {
display: flex;
@@ -19946,6 +20247,10 @@ body.gallery-selecting .gallery-dl-btn,
font-size: 11px;
}
.hwfit-serve-actions-spacer { flex: 1 1 auto; }
.hwfit-serve-actions .cookbook-serve-slots {
margin: 0;
align-self: stretch;
}
.hwfit-serve-launch {
background: var(--accent-primary, var(--red));
color: #fff;
@@ -20017,7 +20322,7 @@ body.gallery-selecting .gallery-dl-btn,
width: 32px;
height: 32px;
min-width: 32px;
top: -5px;
top: -3px;
}
.cookbook-task .cookbook-task-menu-btn:active {
opacity: 1;
@@ -20040,6 +20345,12 @@ body.gallery-selecting .gallery-dl-btn,
.hwfit-serve-cmd-wrap {
position: relative;
}
.hwfit-serve-cmd-title {
margin: 2px 0 3px;
font-size: 10px;
color: var(--fg-muted);
letter-spacing: 0.3px;
}
.hwfit-serve-cmd-wrap .hwfit-serve-cmd {
/* Just enough breathing room so a cursor at line-end doesn't actually
touch the Copy icon text otherwise uses the full width of the box. */
@@ -20171,6 +20482,7 @@ body.gallery-selecting .gallery-dl-btn,
}
/* Status-driven left stripe via :has() — graceful fallback to neutral. */
.cookbook-task:has(.cookbook-task-running) { border-left-color: var(--green, #50fa7b); }
.cookbook-task:has(.cookbook-task-downloading) { border-left-color: var(--color-accent, #00aaff); }
.cookbook-task:has(.cookbook-task-done) { border-left-color: var(--green, #50fa7b); }
.cookbook-task:has(.cookbook-task-error) { border-left-color: var(--color-error, var(--warn, #f87171)); }
.cookbook-task:has(.cookbook-task-queued) { border-left-color: var(--color-warning, #f0ad4e); }
@@ -20222,6 +20534,10 @@ body.gallery-selecting .gallery-dl-btn,
background: color-mix(in srgb, var(--fg) 10%, transparent);
color: var(--fg-muted);
}
.cookbook-task[data-type="download"][data-status="running"] .cookbook-task-type[data-type="download"] {
background: color-mix(in srgb, var(--color-accent, #00aaff) 18%, transparent);
color: var(--color-accent, #00aaff);
}
/* Finished state overrides the per-type colors so a completed download or
serve task shows the same green FINISHED chip. */
.cookbook-task-type.cookbook-task-type-done {
@@ -20390,6 +20706,7 @@ body.gallery-selecting .gallery-dl-btn,
line-height: 16px;
}
.cookbook-task-running { background: color-mix(in srgb, var(--green, #50fa7b) 20%, transparent); color: var(--green, #50fa7b); }
.cookbook-task-downloading { background: color-mix(in srgb, var(--color-accent, #00aaff) 20%, transparent); color: var(--color-accent, #00aaff); }
/* Stopping: same pill treatment as "running" but orange. */
.cookbook-task-stopping { background: color-mix(in srgb, var(--orange, #ffb86c) 22%, transparent); color: var(--orange, #ffb86c); }
.cookbook-task-done { background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); }
@@ -20434,6 +20751,16 @@ body.gallery-selecting .gallery-dl-btn,
.cookbook-task-header {
cursor: pointer;
}
.cookbook-task[data-type="serve"] .cookbook-task-header {
margin: 4px 4px 0;
border-radius: 6px;
}
.cookbook-task[data-type="serve"] .cookbook-output-wrap {
margin: 0 4px 4px;
}
.cookbook-task[data-type="serve"] .cookbook-output-pre {
border-radius: 6px;
}
/* Env bar — match admin-card */
.cookbook-env-bar {
@@ -20555,6 +20882,9 @@ body.gallery-selecting .gallery-dl-btn,
/* Mobile cookbook sizing — kept in line with calendar/library modals. */
@media (max-width: 768px) {
.hwfit-serve-row-core .hwfit-context-label {
grid-column: 1 / -1;
}
/* The Speculative control (checkbox + method dropdown + token stepper)
is too wide for a phone the stepper ran off the right edge of the
modal. Let the group wrap onto its own line, take full width, and
@@ -20562,10 +20892,19 @@ body.gallery-selecting .gallery-dl-btn,
.hwfit-spec-group {
flex-wrap: wrap;
flex-basis: 100%;
column-gap: 4px;
row-gap: 4px;
}
.hwfit-spec-group .hwfit-spec-method { min-width: 0; flex: 1 1 auto; }
.hwfit-spec-group .hwfit-spec-method {
min-width: 0;
flex: 0 1 82px;
max-width: 82px;
}
.hwfit-numstep { flex: 0 0 auto; }
.hwfit-spec-group .hwfit-help-chip-inline {
flex: 0 0 auto;
margin-left: 2px !important;
}
.cookbook-card-title { font-size: 13px; }
.cookbook-card-desc { font-size: 12px; }
.cookbook-field-label { font-size: 12px; }
@@ -22310,6 +22649,9 @@ body:not(.welcome-ready) #welcome-screen {
transform: scaleY(0.4) translateY(8px);
}
}
@keyframes model-picker-refresh-spin {
to { transform: rotate(360deg); }
}
@keyframes modal-enter {
from {
@@ -22401,6 +22743,25 @@ body:not(.welcome-ready) #welcome-screen {
}
/* ── Tasks ── */
#tool-tasks-btn.task-failure-pending,
#rail-tasks.task-failure-pending {
color: var(--red, #f87171);
}
#tool-tasks-btn.task-failure-pending::after,
#rail-tasks.task-failure-pending::after {
content: '';
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--red, #f87171);
box-shadow: 0 0 7px var(--red, #f87171), 0 0 3px var(--red, #f87171);
flex: 0 0 auto;
}
#rail-tasks.task-failure-pending::after {
position: absolute;
right: 8px;
top: 8px;
}
.tasks-modal-content { max-width: 600px; width: min(600px, 92vw); background: var(--bg); font-size: 12px; }
/* Tasks tabs reuse the .memory-tab look. The Brain window's tab bar is
@@ -22449,6 +22810,14 @@ body:not(.welcome-ready) #welcome-screen {
title still reads in dark mode. Lightness stays adaptive. */
color: hsl(var(--cat-hue) 60% 60%);
}
.task-log-failed-tag {
color: var(--red, #f87171);
font-size: 10px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
margin-left: -2px;
}
.task-log-task-icon {
display: inline-flex;
align-items: center;
@@ -23152,6 +23521,8 @@ input.settings-select::placeholder { color: color-mix(in srgb, var(--fg) 35%, tr
gap: 6px;
padding-left: 12px;
border-left: 2px solid color-mix(in srgb, var(--fg) 12%, transparent);
max-width: 100%;
box-sizing: border-box;
}
.settings-fallback-num {
font-size: 11px;
@@ -23159,7 +23530,24 @@ input.settings-select::placeholder { color: color-mix(in srgb, var(--fg) 35%, tr
min-width: 14px;
text-align: right;
}
.settings-fallback-row .settings-select { flex: 1; min-width: 0; }
.settings-fallback-row .settings-select {
width: 0;
min-width: 0;
min-inline-size: 0;
box-sizing: border-box;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-fallback-row .settings-select:first-of-type {
flex: 0 1 128px;
width: 128px;
max-width: 34%;
}
.settings-fallback-row .settings-select:nth-of-type(2) {
flex: 1 1 0;
width: 0;
max-width: 100%;
}
/* Cookbook Serve Advanced fold wraps the rarely-touched tuning rows
(KV/Attention/Swap/Env for vLLM, llama.cpp batch/cache/split, VRAM
monitor, speculative, extra args). Matches the existing .hwfit-panel-
@@ -23240,6 +23628,40 @@ details.hwfit-serve-advanced > .hwfit-serve-row label select,
details.hwfit-serve-advanced > .hwfit-serve-row label input {
margin-top: 1px;
}
details.hwfit-serve-advanced label:has(.hwfit-sf[data-field="vllm_attn_backend"]) {
position: relative;
left: -83px;
}
details.hwfit-serve-advanced label:has(.hwfit-sf[data-field="vllm_block_size"]) {
position: relative;
left: -51px;
}
details.hwfit-serve-advanced .hwfit-sf[data-field="vllm_block_size"] {
width: calc(100% - 6px);
}
details.hwfit-serve-advanced label:has(.hwfit-sf[data-field="swap"]) {
position: relative;
left: -45px;
}
details.hwfit-serve-advanced label:has(.hwfit-sf[data-field="vllm_kv_cache_dtype"]) {
position: relative;
left: 2px;
}
@media (max-width: 768px) {
details.hwfit-serve-advanced label:has(.hwfit-sf[data-field="vllm_block_size"]) {
left: 1px;
}
details.hwfit-serve-advanced label:has(.hwfit-sf[data-field="swap"]) {
left: -3px;
}
details.hwfit-serve-advanced > .hwfit-serve-checks .hwfit-sf-cb {
flex: 1 1 100%;
}
}
details.hwfit-serve-advanced .hwfit-sf[data-field="vllm_kv_cache_dtype"] {
width: 60px;
min-width: 60px;
}
details.hwfit-serve-advanced > .hwfit-serve-checks {
gap: 4px;
row-gap: 4px;
@@ -23253,6 +23675,9 @@ details.hwfit-serve-advanced > .hwfit-serve-checks.hwfit-backend-sglang,
details.hwfit-serve-advanced > .hwfit-serve-extra {
margin-top: -8px;
}
details.hwfit-serve-advanced > .hwfit-serve-extra {
margin-top: -18px;
}
details.hwfit-serve-advanced > .hwfit-serve-row:last-of-type,
details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type {
margin-bottom: 0;
@@ -23260,7 +23685,6 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type {
.settings-fallback-remove {
flex-shrink: 0;
margin-right: 4px;
width: 32px;
height: 32px;
display: inline-flex;
@@ -29267,8 +29691,15 @@ button .spinner-whirlpool {
.email-reader-atts-wrap > .email-reader-atts {
border-bottom: none !important;
}
.email-reader-atts-wrap.collapsed > .email-reader-atts { display: none; }
.email-reader-atts-wrap.collapsed > .email-reader-atts,
.email-reader-atts-wrap.collapsed > .email-reader-atts-hidden-note { display: none; }
.email-reader-atts-wrap.collapsed .email-summary-chevron { transform: rotate(-90deg); }
.email-reader-atts-hidden-note {
padding: 0 14px 6px;
font-size: 10px;
color: var(--fg-muted);
opacity: 0.65;
}
/* Quote fold = neutral full-width band (matches attachments header). */
.email-quote-fold {
@@ -30428,22 +30859,27 @@ body.doc-find-active mark.doc-find-mark.current {
attribute tooltips were slow / unreliable, so we just grow the chip. */
max-width: 90vw;
}
.email-attachment-chip > span:not(.att-size) {
.email-attachment-chip > span:not(.att-size):not(.email-attachment-open) {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
flex: 1 1 auto; min-width: 0;
}
.email-attachment-chip:hover > span:not(.att-size) {
.email-attachment-chip:hover > span:not(.att-size):not(.email-attachment-open) {
overflow: visible;
text-overflow: clip;
}
.email-attachment-chip .att-size { opacity: 0.5; font-size: 10px; flex-shrink: 0; }
.email-attachment-chip-muted { opacity: 0.65; }
.email-attachment-chip-muted:hover { opacity: 1; }
.email-attachment-chip-related {
border-color: color-mix(in srgb, var(--accent) 30%, var(--border));
}
/* "Open in editor" launch icon same prominent style on desktop AND mobile
(was 24px / dim / no border on desktop, easy to miss). Accent-tinted
background + border makes it read as a real action. */
.email-attachment-open {
display: inline-flex; align-items: center; gap: 4px;
height: 22px; padding: 0 9px; border-radius: 11px;
margin-left: 6px; flex-shrink: 0;
height: 22px; padding: 0 9px; border-radius: 999px;
margin-left: 6px; flex: 0 0 auto;
font-size: 10px; font-weight: 500; letter-spacing: 0.02em;
color: var(--accent-primary, var(--red));
background: color-mix(in srgb, var(--accent-primary, var(--red)) 10%, transparent);
@@ -30466,7 +30902,8 @@ body.doc-find-active mark.doc-find-mark.current {
display: none;
}
.email-attachment-chip:not(:hover) .email-attachment-open {
width: 22px;
width: 28px;
min-width: 28px;
padding: 0;
justify-content: center;
gap: 0;
@@ -35930,6 +36367,13 @@ body.research-panel-view #research-divider { display:none; }
font-size: 14px;
font-weight: 600;
letter-spacing: -0.03em;
position: relative;
top: 2px;
}
.research-new-job > .doclib-desc {
position: relative;
top: 4px;
margin-bottom: 6px;
}
@media (max-width: 600px) {
/* Keep the "Research" title visible on mobile (matches the Cookbook tab
@@ -36418,6 +36862,7 @@ body.research-panel-view #research-divider { display:none; }
}
.research-section:not(.collapsed) > .research-section-header { border-bottom: 1px solid var(--border); }
.research-section-header:hover { background: color-mix(in srgb, var(--fg) 4%, transparent); }
.research-section-header:has(.research-library-hint) { padding-bottom: 4px; }
.research-section-title { font-size: 14px; font-weight: 600; letter-spacing: -0.03em; }
.research-section-chevron { flex-shrink: 0; opacity: 0.55; transition: transform 0.2s ease; }
.research-section.collapsed .research-section-chevron { transform: rotate(-90deg); }
@@ -37065,7 +37510,8 @@ body.theme-frosted .modal {
.research-library-hint {
/* full-width line in the header, pulled up with negative MARGIN (collapses
the gap so it moves up without making the header taller). */
width: 100%; flex-basis: 100%; margin: -22px 0 0; line-height: 1.2;
width: 100%; flex-basis: 100%; margin: -26px 0 0; line-height: 1.2;
font-size: calc(1em - 2px); opacity: 0.55;
}
.research-library-link {
background: none; border: none; padding: 0; cursor: pointer;