Merge remote-tracking branch 'origin/main' into visual-pr-playground

# Conflicts:
#	routes/cookbook_routes.py
#	routes/hwfit_routes.py
#	services/hwfit/fit.py
#	services/hwfit/models.py
#	static/js/cookbook-diagnosis.js
#	static/js/cookbook-hwfit.js
#	static/js/cookbook.js
#	static/js/cookbookRunning.js
This commit is contained in:
pewdiepie-archdaemon
2026-06-03 16:49:10 +09:00
569 changed files with 35252 additions and 3489 deletions
+10 -2
View File
@@ -3,6 +3,14 @@
## Purpose
This document describes what each JavaScript module is responsible for.
> **Note:** This file is a partial, historical overview — not a complete authoritative
> inventory. The authoritative module set is the current `static/js/` tree plus the
> scripts loaded by `static/index.html`. As of this writing that tree holds **65 `.js`
> files** across **8 subdirectories** (`calendar/`, `color/`, `compare/`, `editor/`,
> `emailLibrary/`, `markdown/`, `research/`, `util/`), and `static/index.html` loads
> **35** `/static…` script tags. The catalog below covers only the original core
> modules and is not kept in sync with every module.
---
## Core Modules (in static/js/)
@@ -23,7 +31,7 @@ This document describes what each JavaScript module is responsible for.
- Content rendering for message arrays
- Text cleanup (`squashOutsideCode`)
### 3. **session.js**
### 3. **sessions.js**
- Session/chat management
- Create, load, delete, switch sessions
- Session history loading
@@ -54,7 +62,7 @@ This document describes what each JavaScript module is responsible for.
### 7. **models.js**
- Model scanning and display
- Local model discovery (ports 8000-8010)
- Local model discovery (ports 8000-8020)
- Provider management (OpenAI)
- Model selection UI
+8 -1
View File
@@ -871,11 +871,14 @@ function initEndpointForm() {
const raw = (el('adm-epLocalUrl').value || '').trim();
if (!raw) { msg.textContent = 'Enter a base URL to test'; msg.className = 'admin-error'; return; }
const url = _normalizeBaseUrl(raw);
const keyEl = el('adm-epLocalApiKey');
const apiKey = keyEl ? keyEl.value.trim() : '';
localTestBtn.disabled = true;
localTestBtn.textContent = 'Testing...';
try {
const fd = new FormData();
fd.append('base_url', url);
if (apiKey) fd.append('api_key', apiKey);
const res = await fetch('/api/model-endpoints/test', { method: 'POST', body: fd, credentials: 'same-origin' });
const d = await res.json();
_renderEndpointTestResult(msg, res, d);
@@ -894,10 +897,13 @@ function initEndpointForm() {
const raw = (el('adm-epLocalUrl').value || '').trim();
if (!raw) { msg.textContent = 'Enter a base URL (e.g. http://localhost:8002/v1)'; msg.className = 'admin-error'; return; }
const url = _normalizeBaseUrl(raw);
const keyEl = el('adm-epLocalApiKey');
const apiKey = keyEl ? keyEl.value.trim() : '';
localAddBtn.disabled = true; localAddBtn.textContent = 'Adding...';
try {
const fd = new FormData();
fd.append('base_url', url);
if (apiKey) fd.append('api_key', apiKey);
const lt = el('adm-epLocalType');
if (lt) fd.append('model_type', lt.value);
fd.append('skip_probe', 'false');
@@ -905,6 +911,7 @@ function initEndpointForm() {
const d = await res.json();
if (res.ok) {
el('adm-epLocalUrl').value = '';
if (keyEl) keyEl.value = '';
if (lt) lt.value = 'llm';
if (d.id) _recentlyAddedEpId = String(d.id);
await loadEndpoints();
@@ -968,7 +975,7 @@ function initEndpointForm() {
const data = await res.json();
const items = data.items || [];
if (!items.length) {
msg.textContent = 'No model servers found. Make sure vLLM, llama.cpp, SGLang, or Ollama is running. Docker users may need OLLAMA_HOST=0.0.0.0:11434.';
msg.textContent = 'No model servers found. Make sure vLLM, llama.cpp, SGLang, or Ollama is running. Docker users may need Ollama bound to a trusted reachable interface.';
msg.className = 'admin-error';
} else {
// Auto-add each discovered endpoint. Server dedupes on base_url
+3 -3
View File
@@ -180,7 +180,7 @@ function _renderSettingsBody(body, data, tzList) {
<div class="assistant-field">
<span style="display:flex;align-items:center;gap:8px;">Personality
<select id="assistant-character-pick" style="font-size:11px;padding:1px 6px;border:1px solid var(--border);border-radius:3px;background:var(--bg);color:var(--fg);max-width:180px;">
<option value="">-- pick from character --</option>
<option value="">-- pick from persona --</option>
</select>
</span>
<textarea id="assistant-personality" rows="6" placeholder="Describe the assistant's personality, tone, and behavior...">${_esc(crew.personality || '')}</textarea>
@@ -293,7 +293,7 @@ function _renderSettingsBody(body, data, tzList) {
allPresets.push(...presetsRaw);
}
const allTemplates = Array.isArray(templates) ? templates : [];
let opts = '<option value="">-- pick from character --</option>';
let opts = '<option value="">-- pick from persona --</option>';
if (allPresets.length) {
opts += '<optgroup label="Presets">';
for (const p of allPresets) {
@@ -304,7 +304,7 @@ function _renderSettingsBody(body, data, tzList) {
opts += '</optgroup>';
}
if (allTemplates.length) {
opts += '<optgroup label="Characters">';
opts += '<optgroup label="Personas">';
for (const t of allTemplates) {
if (!t.system_prompt && !t.personality) continue;
const name = t.character_name || t.name || 'Unnamed';
+7 -2
View File
@@ -13,6 +13,7 @@ import {
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
_trashIcon, _moreIcon, _bellIcon,
_isCalBgImage, _calBgImageUrl, _calBgCss,
_calReadableTextColor,
_ds, _addDays, _shiftDT, _tzOffset, _localDateOf,
} from './calendar/utils.js';
@@ -371,6 +372,10 @@ function _calColor(ev) {
return c?.color || 'var(--accent)';
}
function _calEventFg(ev) {
return _calReadableTextColor(_calColor(ev));
}
// Extra inline style for an event row when the event has a custom BG image.
// Returns '' for normal solid-color events.
function _calItemBgStyle(ev) {
@@ -975,7 +980,7 @@ async function _renderMonth() {
const startColInt = Math.round(startCol);
const endColInt = Math.round(endCol);
const span = endColInt - startColInt + 1;
h += `<div class="cal-multiday" style="--col:${startColInt};--span:${span};--slot:${barSlot};background:${_calColor(md)}" draggable="true" data-uid="${_e(md.uid)}" title="${_e(md.summary)}">${_e(md.summary)}</div>`;
h += `<div class="cal-multiday" style="--col:${startColInt};--span:${span};--slot:${barSlot};background:${_calColor(md)};--cal-event-fg:${_calEventFg(md)}" draggable="true" data-uid="${_e(md.uid)}" title="${_e(md.summary)}">${_e(md.summary)}</div>`;
barSlot++;
}
h += '</div>';
@@ -1141,7 +1146,7 @@ async function _renderWeek() {
// All-day strip
colsHtml += `<div class="cal-wk-allday">`;
for (const ev of allDayEvents) {
colsHtml += `<div class="cal-wk-allday-event" data-uid="${_e(ev.uid)}" style="background:${_calColor(ev)};" title="${_e(ev.summary)}">${_e(ev.summary)}</div>`;
colsHtml += `<div class="cal-wk-allday-event" data-uid="${_e(ev.uid)}" style="background:${_calColor(ev)};--cal-event-fg:${_calEventFg(ev)};" title="${_e(ev.summary)}">${_e(ev.summary)}</div>`;
}
colsHtml += `</div>`;
// Hour-grid body
+41 -1
View File
@@ -74,6 +74,42 @@ export function _calBgCss(c, fallback) {
return c || fallback || 'var(--accent)';
}
function _hexToRgb(c) {
if (typeof c !== 'string') return null;
const m = c.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (!m) return null;
const hex = m[1].length === 3
? m[1].split('').map(ch => ch + ch).join('')
: m[1];
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
};
}
function _relativeLuminance({ r, g, b }) {
return [r, g, b].map(v => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}).reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i], 0);
}
function _contrastRatio(a, b) {
const light = Math.max(a, b);
const dark = Math.min(a, b);
return (light + 0.05) / (dark + 0.05);
}
export function _calReadableTextColor(bg) {
const rgb = _hexToRgb(bg);
if (!rgb) return 'var(--fg)';
const lum = _relativeLuminance(rgb);
const white = _contrastRatio(lum, 1);
const ink = _contrastRatio(lum, 0.006);
return ink >= white ? '#111820' : '#ffffff';
}
// ── date helpers ──
// `YYYY-MM-DD` string from a Date.
@@ -82,13 +118,17 @@ export function _ds(d) {
}
export function _addDays(dateStr, n) {
if (typeof dateStr !== 'string' || !dateStr) return '';
const d = new Date(dateStr + 'T00:00:00');
if (isNaN(d)) return '';
d.setDate(d.getDate() + n);
return _ds(d);
}
export function _shiftDT(iso, days) {
if (typeof iso !== 'string' || !iso) return '';
const d = new Date(iso);
if (isNaN(d)) return '';
d.setDate(d.getDate() + days);
return _ds(d) + (iso.length > 10 ? 'T' + iso.slice(11) : '');
}
@@ -111,7 +151,7 @@ export function _tzOffset() {
// bucket by the USER's local date. Without this an event at
// "2026-05-13T22:00:00Z" (07:00 May 14 JST) would render on May 13.
export function _localDateOf(isoStr) {
if (!isoStr) return '';
if (typeof isoStr !== 'string' || !isoStr) return '';
if (isoStr.length === 10) return isoStr;
if (/[Zz]$|[+\-]\d{2}:?\d{2}$/.test(isoStr)) {
const d = new Date(isoStr);
+7 -1
View File
@@ -8,7 +8,13 @@
let _enabled = true;
let _observer = null;
const PREF_KEY = 'odysseus-sensitive-blur';
const _prefEnabled = () => localStorage.getItem(PREF_KEY) === 'on';
export const _prefEnabled = () => {
try {
return localStorage.getItem(PREF_KEY) === 'on';
} catch (_) {
return false;
}
};
// Patterns that indicate sensitive data
const PATTERNS = [
+52 -8
View File
@@ -457,6 +457,8 @@ import createResearchSynapse from './researchSynapse.js';
const ok = await sessionModule.materializePendingSession();
if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; }
} else {
el('message').value = '';
if (uiModule.autoResize) uiModule.autoResize(el('message'));
addMessage('assistant',
'No chat session active. You can:\n\n' +
'- Open the model picker in the chat box and pick a model\n' +
@@ -466,6 +468,8 @@ import createResearchSynapse from './researchSynapse.js';
return;
}
} catch (e) {
el('message').value = '';
if (uiModule.autoResize) uiModule.autoResize(el('message'));
addMessage('assistant',
'No chat session active. You can:\n\n' +
'- Open the model picker in the chat box and pick a model\n' +
@@ -512,6 +516,10 @@ import createResearchSynapse from './researchSynapse.js';
// Declare accumulated outside try block so it's accessible in catch
let accumulated = '';
// Are we currently inside an unclosed <think> block? Toggled per think/answer
// cycle so a multi-round agent response (one reasoning phase PER round) wraps each
// round's reasoning in its own <think>…</think> instead of leaking rounds 2+ as text.
let _thinkOpen = false;
let holder = null;
let finalMeta = null;
let finalModelName = null;
@@ -960,6 +968,11 @@ import createResearchSynapse from './researchSynapse.js';
return;
}
// Mark the chat log busy while streaming so screen readers wait for the
// settled response instead of announcing every token. Cleared in finally.
const _chatLog = document.getElementById('chat-history');
if (_chatLog) _chatLog.setAttribute('aria-busy', 'true');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
@@ -1357,12 +1370,15 @@ import createResearchSynapse from './researchSynapse.js';
if (_threadAbove && _threadAbove.classList.contains('agent-thread') && !_threadAbove.classList.contains('has-bottom')) {
_threadAbove.classList.add('has-bottom');
}
// VLLM reasoning tokens: wrap in <think> tags for the thinking UI
// VLLM reasoning tokens: wrap in <think> tags for the thinking UI.
// Stateful open/close (not a whole-message substring check) so each round
// of a multi-round agent response gets its own <think>…</think> — otherwise
// only round 1 is wrapped and rounds 2+ reasoning leaks into the answer.
let _delta = json.delta;
if (json.thinking) {
if (!accumulated.includes('<think>')) _delta = '<think>' + _delta;
} else if (accumulated.includes('<think>') && !accumulated.includes('</think>')) {
_delta = '</think>' + _delta;
if (!_thinkOpen) { _delta = '<think>' + _delta; _thinkOpen = true; }
} else if (_thinkOpen) {
_delta = '</think>' + _delta; _thinkOpen = false;
}
const wasEmpty = !accumulated;
accumulated += _delta;
@@ -1771,6 +1787,26 @@ import createResearchSynapse from './researchSynapse.js';
if (tsSpan) roleEl.appendChild(tsSpan);
}
}
} else if (json.type === 'fallback') {
// The selected model failed and another provider answered. Make
// it visible so a misconfigured provider is never silently
// masked under the selected model's name.
if (!_isBg) {
var _selM = _shortModel(json.selected_model || '');
var _ansM = _shortModel(json.answered_by || '');
uiModule.showToast('⚠ ' + _selM + ' failed — answered by ' + _ansM, 6000);
if (holder) {
var _rEl = holder.querySelector('.role');
if (_rEl) {
var _tsS = _rEl.querySelector('.role-timestamp');
_rEl.textContent = _ansM + ' (fallback) ';
_rEl.title = (json.selected_model || '') + ' failed' +
(json.reason ? ': ' + json.reason : '') + ' — answered by ' + (json.answered_by || '');
_applyModelColor(_rEl, json.answered_by);
if (_tsS) _rEl.appendChild(_tsS);
}
}
}
} else if (json.type === 'attachments') {
if (_isBg) continue;
// Update user bubble — replace file chips with image previews
@@ -2675,6 +2711,9 @@ import createResearchSynapse from './researchSynapse.js';
}
} finally {
clearProcessingProbe();
// Streaming done — let screen readers announce the settled response.
const _chatLogDone = document.getElementById('chat-history');
if (_chatLogDone) _chatLogDone.setAttribute('aria-busy', 'false');
// Always clean up research tracking regardless of background state
_researchingStreamIds.delete(streamSessionId);
if (_researchingStreamIds.size === 0) {
@@ -3389,7 +3428,7 @@ import createResearchSynapse from './researchSynapse.js';
// Also submit on Enter (without shift)
editor.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
saveBtn.click();
}
@@ -4002,8 +4041,11 @@ import createResearchSynapse from './researchSynapse.js';
const clickedIndex = allMsgs.indexOf(msgElement);
if (clickedIndex < 0) return;
// No early-out on a missing session: an output shown before any model was
// selected (issue #1428) has no session/persisted rows, but its "x" must
// still remove it. We only need the session id for the server-side delete
// below; without one we fall back to removing the DOM.
const sessionId = sessionModule.getCurrentSessionId();
if (!sessionId) return;
const clickedIsUser = msgElement.classList.contains('msg-user');
@@ -4079,8 +4121,10 @@ import createResearchSynapse from './researchSynapse.js';
}
}
if (!msgIds.length) {
// Fallback: just remove DOM elements if no DB IDs available
if (!msgIds.length || !sessionId) {
// No persisted rows to delete (no DB IDs, or no session at all — e.g. an
// error output shown before a model was selected, #1428). Just remove the
// DOM so the "x" works regardless.
domToRemove.forEach(el => el.remove());
if (uiModule) uiModule.showToast('Message deleted');
return;
+17
View File
@@ -659,6 +659,12 @@ export function isLocalEndpoint(url) {
if (!host) return true;
if (host === 'localhost' || host === '0.0.0.0' || host === 'host.docker.internal' || host.endsWith('.local')) return true;
if (typeof window !== 'undefined' && window.location && host === window.location.hostname) return true;
// A single-label hostname (no dot) is an internal/Docker service name
// (e.g. "nim-nano", "llamaswap", "nemotron-super-49b") or a LAN shortname —
// never a public API, which always needs an FQDN. Treat as local → free.
// (Without this, container-name endpoints get billed at cloud rates because
// the pricing table matches on a name substring, e.g. "nemotron".)
if (!host.includes('.')) return true;
if (/^127\./.test(host)) return true;
if (/^10\./.test(host)) return true;
if (/^192\.168\./.test(host)) return true;
@@ -1211,6 +1217,17 @@ export function showWelcomeScreen() {
const cc = document.getElementById('chat-container');
if (ws) ws.classList.remove('hidden');
if (cc) cc.classList.add('welcome-active');
// Entering the New Chat / welcome state: discard any stale draft left in the
// composer from the previous session so the input starts empty (issue #1343).
// Switching between existing sessions loads them directly and does NOT call
// this, so genuine drafts are not erased. Reset the autosized height and fire
// an `input` event so the send button + autosize listeners update.
const _msg = document.getElementById('message');
if (_msg) {
_msg.value = '';
_msg.style.height = '';
_msg.dispatchEvent(new Event('input', { bubbles: true }));
}
// Re-trigger the L→R clip-wipe reveal on the welcome name each time the
// welcome screen is shown (new session, deleted last session, etc.) — without
// this, the CSS animation only fires on initial DOM insertion.
+14
View File
@@ -0,0 +1,14 @@
// static/js/color/hex.js
//
// Parse a CSS hex color into {r, g, b}. Pure — no DOM — so it can be reused
// across modules and unit-tested under node.
// Accepts "#rgb", "#rrggbb" (with or without the leading '#'). Returns null
// for anything that isn't a valid 3- or 6-digit hex color.
export function hexToRgb(hex) {
let h = String(hex || '').trim().replace(/^#/, '');
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
if (!/^[0-9a-fA-F]{6}$/.test(h)) return null;
const n = parseInt(h, 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
+104 -15
View File
@@ -213,6 +213,8 @@ export function _renderGpuToggles(system) {
if (quantSel && quantSel.value !== '') {
if (count <= 1) {
quantSel.value = 'Q4_K_M'; // RAM or 1 GPU -> Q4 sweet spot
} else if (String(system?.backend || '').toLowerCase() === 'rocm') {
quantSel.value = 'Q4_K_M'; // ROCm default stays GGUF/local-safe; AWQ is explicit only
} else {
quantSel.value = 'AWQ-4bit'; // Multi-GPU -> AWQ for vLLM
}
@@ -244,11 +246,13 @@ function _ctxLabel(value) {
if (!n) return 'Max';
return n >= 1000 ? Math.round(n / 1000) + 'k' : String(n);
}
function _ctxValue() {
const slider = document.getElementById('hwfit-context');
const idx = Math.max(0, Math.min(_CTX_PRESETS.length - 1, Number(slider?.value ?? 3) || 0));
return _CTX_PRESETS[idx] || 0;
}
function _syncCtxControl() {
const slider = document.getElementById('hwfit-context');
const label = document.getElementById('hwfit-context-label');
@@ -359,6 +363,7 @@ function _scanSig() {
o: sortEl?.value || 'score',
r: sortEl?.dataset.reverse === '1' ? 1 : 0,
q: document.getElementById('hwfit-quant')?.value || '',
c: _ctxValue(),
g: (tc && typeof tc._activeCount === 'number') ? String(tc._activeCount) : '',
gg: (tc && tc._activeGroup) ? String(tc._activeGroup) : '',
m: _manualHwParams(),
@@ -408,6 +413,17 @@ function _hwfitShowError(list, host, detail) {
if (rb) rb.addEventListener('click', () => { _resetGpuToggleState(); _hwfitFetch(true); });
}
// Client-side "Engine" filter (llama.cpp / vLLM / SGLang). Empty = show all.
// Uses the same _detectBackend() the serve commands use, so what you filter to
// is exactly what would be launched. Pure view filter — no refetch needed.
function _applyEngineFilter(models) {
const want = document.getElementById('hwfit-engine')?.value || '';
if (!want || !Array.isArray(models)) return models || [];
return models.filter(m => {
try { return _detectBackend(m).backend === want; } catch { return true; }
});
}
export async function _hwfitFetch(fresh = false) {
const _tk = ++_hwfitFetchToken;
const useCase = document.getElementById('hwfit-usecase')?.value || '';
@@ -427,7 +443,7 @@ export async function _hwfitFetch(fresh = false) {
if (_cached) {
_hwfitCache = _cached;
_hwfitRenderHw(hw, _cached.system);
_hwfitRenderList(list, _cached.models);
_hwfitRenderList(list, _applyEngineFilter(_cached.models));
} else {
// Show spinner while scanning — stack the spinner above a text label
// (the .hwfit-loading class is a centered flex ROW, so force column here).
@@ -456,7 +472,9 @@ export async function _hwfitFetch(fresh = false) {
fetch(`/api/model/cached?${_cacheParams}`, { credentials: 'same-origin' })
.then(r => r.json())
.then(d => {
_cachedModelIds = new Set((d.models || []).map(m => m.repo_id));
// Exclude stalled (download-shell) entries — a 12 KB README-only
// folder shouldn't count as "downloaded" in the Scan/Download list.
_cachedModelIds = new Set((d.models || []).filter(m => m.status !== 'stalled').map(m => m.repo_id));
// Re-mark rows if already rendered
list.querySelectorAll('.hwfit-row[data-model]').forEach(row => {
const name = row.dataset.model;
@@ -472,6 +490,7 @@ export async function _hwfitFetch(fresh = false) {
try {
const sortBy = document.getElementById('hwfit-sort')?.value || 'score';
const quantPref = document.getElementById('hwfit-quant')?.value || '';
const targetCtx = _ctxValue();
// Get active GPU count from toggles
const toggleContainer = document.getElementById('hwfit-gpu-toggles');
let gpuCountOverride = '';
@@ -507,6 +526,7 @@ export async function _hwfitFetch(fresh = false) {
if (!isImageMode) {
if (useCase) params.set('use_case', useCase);
if (quantPref) params.set('quant', quantPref);
if (targetCtx) params.set('ctx', String(targetCtx));
}
const endpoint = isImageMode ? `/api/hwfit/image-models?${params}` : `/api/hwfit/models?${params}`;
const res = await fetch(endpoint);
@@ -562,13 +582,26 @@ export async function _hwfitFetch(fresh = false) {
const sortSel = document.getElementById('hwfit-sort');
const sortKey = sortSel?.value || 'score';
const asc = sortSel?.dataset.reverse === '1'; // reversed → ascending (lowest first)
const field = { score: 'score', vram: 'required_gb', speed: 'speed_tps', params: 'params_b', context: 'context' }[sortKey] || 'score';
data.models.sort((a, b) => {
const av = Number(a[field]) || 0, bv = Number(b[field]) || 0;
return asc ? av - bv : bv - av;
});
if (sortKey === 'fit') {
// fit_level is categorical (perfect→good→marginal→too_tight), not numeric,
// so rank it explicitly instead of falling through to the score column.
// Tie-break by score so rows within one fit tier stay meaningfully ordered.
const fitRank = { perfect: 4, good: 3, marginal: 2, too_tight: 1, no_fit: 0 };
data.models.sort((a, b) => {
const ar = fitRank[a.fit_level] ?? -1, br = fitRank[b.fit_level] ?? -1;
if (ar !== br) return asc ? ar - br : br - ar;
const as = Number(a.score) || 0, bs = Number(b.score) || 0;
return asc ? as - bs : bs - as;
});
} else {
const field = { score: 'score', vram: 'required_gb', speed: 'speed_tps', params: 'params_b', context: 'context' }[sortKey] || 'score';
data.models.sort((a, b) => {
const av = Number(a[field]) || 0, bv = Number(b[field]) || 0;
return asc ? av - bv : bv - av;
});
}
}
_hwfitRenderList(list, data.models);
_hwfitRenderList(list, _applyEngineFilter(data.models));
// Persist this result so the next page load can paint it instantly.
_writeScanCache(_sig, data);
// Render GPU toggles — only on first scan (no override active)
@@ -614,8 +647,36 @@ export function _hwfitRenderHw(el, sys) {
};
let gpuChip;
if (sys.gpu_name) {
const label = gpuCount > 1 ? `${gpuCount}x ${esc(sys.gpu_name)}` : esc(sys.gpu_name);
gpuChip = chip('gpu', label);
// Mixed-GPU boxes (#711): `${gpuCount}x ${gpu_name}` uses gpus[0].name for
// every card, so a 4090+3060 reads as "2x RTX 4090". Use gpu_groups (the
// backend already groups identical cards) to render each pool separately
// and put the per-card index+VRAM into the tooltip so it's actually
// useful for picking CUDA_VISIBLE_DEVICES.
const groups = Array.isArray(sys.gpu_groups) ? sys.gpu_groups : [];
// Shorten vendor prefixes so a mixed-GPU label fits in the chip row
// without overflowing. Single-GPU label still shows the full name
// (that's what users are used to seeing). Tooltip carries the full
// unmodified names regardless, so no information is lost.
const _shortGpuName = (n) => String(n || '')
.replace(/^NVIDIA\s+GeForce\s+/i, '')
.replace(/^NVIDIA\s+/i, '')
.replace(/^AMD\s+Radeon\s+/i, '')
.replace(/^AMD\s+/i, '')
.replace(/^Intel\s+/i, '');
let label;
if (groups.length > 1) {
// Heterogeneous: "1× RTX 4090 + 1× RTX 3060"
label = groups.map(g => `${g.count}× ${esc(_shortGpuName(g.name))}`).join(' + ');
} else if (gpuCount > 1) {
label = `${gpuCount}× ${esc(sys.gpu_name)}`;
} else {
label = esc(sys.gpu_name);
}
const gpus = Array.isArray(sys.gpus) ? sys.gpus : [];
const tip = gpus.length
? gpus.map(g => `GPU ${g.index}: ${g.name} · ${(+g.vram_gb).toFixed(1)} GB`).join('\n')
: 'Click to toggle off (X to hide)';
gpuChip = chip('gpu', label, tip);
} else if (sys.gpu_error) {
gpuChip = _removedHwChips.has('gpu')
? ''
@@ -761,8 +822,22 @@ function _wireManualHardwareControls(el) {
export const _fitColors = { perfect: 'var(--green, #50fa7b)', good: 'var(--yellow, #f1fa8c)', marginal: 'var(--orange, #ffb86c)', too_tight: 'var(--red, #ff5555)' };
function _requiresAcceleratorBackend(model) {
const q = String(model?.quant || model?.quantization || '').toUpperCase();
const text = `${model?.name || ''} ${model?.repo_id || ''} ${model?.path || ''}`.toLowerCase();
return /^AWQ|^GPTQ|^NVFP4/.test(q) || q === 'FP8' || /\b(awq|gptq|fp8|nvfp4)\b/i.test(text);
}
function _modeLabel(model) {
if (model?.is_image_gen) return 'image';
if (_requiresAcceleratorBackend(model)) return 'vLLM/SGLang';
const detected = _detectBackend(model);
if (detected?.label) return detected.label;
return String(model?.run_mode || '').replace('_', '+');
}
export const _hwfitColumns = [
{ key: 'score', label: 'Fit', cls: 'hwfit-fit' },
{ key: 'fit', label: 'Fit', cls: 'hwfit-fit' },
{ key: null, label: 'Model', cls: 'hwfit-name' },
{ key: 'params',label: 'Param', cls: 'hwfit-c-params' },
{ key: null, label: 'Quant', cls: 'hwfit-c-quant' },
@@ -783,9 +858,10 @@ export function _hwfitRenderList(el, models) {
const hasHw = sys && ((sys.gpu_vram_gb || 0) > 0 || (sys.total_ram_gb || 0) > 8);
const hasFilters = !!(document.getElementById('hwfit-search')?.value?.trim()
|| document.getElementById('hwfit-usecase')?.value
|| document.getElementById('hwfit-quant')?.value);
|| document.getElementById('hwfit-quant')?.value
|| document.getElementById('hwfit-engine')?.value);
let msg;
if (hasFilters) msg = 'No models match these filters — try clearing the search, use-case, or quant.';
if (hasFilters) msg = 'No models match these filters — try clearing the search, use-case, quant, or engine.';
else if (hasHw) msg = 'No models fit — the hardware probe may have under-reported. Try Rescan.';
else msg = 'No models fit your hardware';
el.innerHTML = `<div class="hwfit-loading">${msg}</div>`;
@@ -827,7 +903,7 @@ export function _hwfitRenderList(el, models) {
const pcount = m.parameter_count || '?';
const ctx = m.context ? (m.context >= 1024 ? (m.context / 1024).toFixed(0) + 'k' : m.context) : '?';
const fitLabel = (m.fit_level || '').replace('_', ' ');
const modeLabel = (m.run_mode || '').replace('_', '+');
const modeLabel = _modeLabel(m);
const vramLabel = m.required_gb ? m.required_gb.toFixed(1) + 'G' : '?';
const moeBadge = m.is_moe ? '<span class="hwfit-badge hwfit-moe">MoE</span>' : '';
const imgBadge = m.is_image_gen ? '<span class="hwfit-badge" style="background:color-mix(in srgb, var(--red) 20%, transparent);color:var(--red);font-size:8px;padding:1px 4px;border-radius:3px;margin-left:4px;">IMG</span>' : '';
@@ -841,7 +917,7 @@ export function _hwfitRenderList(el, models) {
html += `<span class="hwfit-col hwfit-c-ctx">${m.is_image_gen ? '\u2014' : ctx}</span>`;
html += `<span class="hwfit-col hwfit-c-speed">${m.is_image_gen ? '\u2014' : tps + ' t/s'}</span>`;
html += `<span class="hwfit-col hwfit-c-score">${score}</span>`;
html += `<span class="hwfit-col hwfit-c-mode">${m.is_image_gen ? 'image' : esc(modeLabel)}</span>`;
html += `<span class="hwfit-col hwfit-c-mode" title="${_requiresAcceleratorBackend(m) ? 'Requires vLLM or SGLang with a visible CUDA/ROCm accelerator. llama.cpp and Ollama need GGUF files.' : ''}">${esc(modeLabel)}</span>`;
html += `</div>`;
}
el.innerHTML = html;
@@ -941,6 +1017,8 @@ export function _expandModelRow(row, modelData) {
html += `</div>`;
if (modelData.is_image_gen) {
html += `<div style="font-size:10px;opacity:0.5;margin-top:4px;">${esc((modelData.capabilities || []).join(' \u00B7 ') || '')}${modelData.description ? ' \u2014 ' + esc(modelData.description) : ''}</div>`;
} else if (_requiresAcceleratorBackend(modelData)) {
html += `<div class="hwfit-panel-note">This is a safetensors GPU-serving format. Use vLLM/SGLang with a visible CUDA/ROCm accelerator, or pick a GGUF download for llama.cpp/Ollama.</div>`;
}
html += `</div>`;
@@ -1145,6 +1223,17 @@ export function _hwfitInit() {
if (uc) uc.addEventListener('change', () => _hwfitFetch());
if (sort) sort.addEventListener('change', () => _hwfitFetch());
if (qpref) qpref.addEventListener('change', () => _hwfitFetch());
// Engine filter is a pure client-side view filter over the already-fetched
// list, so just re-render from cache instead of re-probing hardware.
const engine = document.getElementById('hwfit-engine');
if (engine) engine.addEventListener('change', () => {
const list = document.getElementById('hwfit-list');
if (list && _hwfitCache && Array.isArray(_hwfitCache.models)) {
_hwfitRenderList(list, _applyEngineFilter(_hwfitCache.models));
} else {
_hwfitFetch();
}
});
if (ctx && !ctx.dataset.bound) {
ctx.dataset.bound = '1';
ctx.addEventListener('input', () => {
+229 -44
View File
@@ -223,11 +223,20 @@ function _detectModelOptimizations(modelName) {
return opts;
}
/** Detect the right vLLM tool-call-parser based on model name */
/** Detect the right vLLM tool-call-parser based on model name.
* Qwen tool-call formats split by generation:
* - Qwen3-Coder → qwen3_coder (XML <tool_call> with named params)
* - Qwen3 (non-coder) → qwen3_xml (reasoning/instruct, XML wrapper)
* - Qwen2.5 / Qwen2 / 1.5 → hermes (Qwen2.5 was trained on Hermes format)
* Catching "qwen" first and labelling everything qwen3_xml breaks tool
* calls on the Qwen2.5 line (the model emits hermes-style which the
* qwen3_xml parser doesn't recognise, so the call leaks through as text).
*/
export function _detectToolParser(modelName) {
const n = (modelName || '').toLowerCase();
if (n.includes('qwen3') && n.includes('coder')) return 'qwen3_coder';
if (n.includes('qwen')) return 'qwen3_xml';
if (n.includes('qwen3')) return 'qwen3_xml';
if (n.includes('qwen')) return 'hermes'; // Qwen2.5 / Qwen2 / Qwen1.5
if (n.includes('llama-4') || n.includes('llama4')) return 'llama4_json';
if (n.includes('llama') || n.includes('nemotron')) return 'llama3_json';
if (n.includes('mistral') || n.includes('mixtral')) return 'mistral';
@@ -251,37 +260,43 @@ export function _detectBackend(model) {
const q = (model.quant || '').toUpperCase();
const sysBackend = String(_hwfitCache?.system?.backend || '').toLowerCase();
const isRocm = sysBackend === 'rocm';
const isAppleSilicon = ['metal', 'mps', 'apple'].includes(sysBackend);
const _nm = `${model.repo_id || ''} ${model.path || ''} ${model.name || ''}`.toLowerCase();
if (/\bmlx\b|mlx-|_mlx/i.test(_nm) || q.startsWith('MLX')) {
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');
// Image gen models → diffusers
if (model.is_image_gen || model.is_diffusion || model._tag === 'image') {
return { backend: 'diffusers', label: 'Diffusers' };
}
// AWQ / GPTQ / FP8 are safetensors GPU-serving formats. Never route them
// through llama.cpp/Ollama just because the host is Mac/Windows; those engines
// need GGUF. The UI will warn/block on Metal where vLLM/SGLang aren't viable.
if (isAwqLike) {
return { backend: 'vllm', label: 'vLLM' };
}
// GGUF → llama.cpp/Ollama-compatible.
if (isGgufLike) {
return { backend: 'llamacpp', label: 'llama.cpp' };
}
// Windows → default to llama.cpp (no vLLM support on Windows)
if (_isWindows()) {
return { backend: 'llamacpp', label: 'llama.cpp' };
}
// Apple Silicon (Metal) → llama.cpp (GGUF). vLLM/SGLang are CUDA/ROCm-only and
// don't run on macOS; AWQ/GPTQ/FP8 (vLLM-only) models are already filtered out
// don't run on macOS; vLLM-native quantized models are already filtered out
// of metal Cookbook results, so llama.cpp is always the right engine here.
if (['metal', 'mps', 'apple'].includes(sysBackend)) {
return { backend: 'llamacpp', label: 'llama.cpp' };
}
// AWQ / GPTQ / FP8 → vLLM
if (/^AWQ|^GPTQ/.test(q) || q === 'FP8') {
return { backend: 'vllm', label: 'vLLM' };
}
// GGUF → llama.cpp. Match the quant tag OR a gguf hint in the repo/path/name:
// a raw .gguf file often has no quant field, which made it fall through to the
// vLLM default below.
const _nm = `${model.repo_id || ''} ${model.path || ''} ${model.name || ''}`.toLowerCase();
if (model.is_gguf || /^Q[2-8]/.test(q) || /^IQ/.test(q) || q === 'GGUF' || _nm.includes('gguf')) {
return { backend: 'llamacpp', label: 'llama.cpp' };
}
// ROCm/AMD machines should not blindly default HF safetensors models to
// vLLM. SGLang is the safer OpenAI-compatible default for plain HF text
// repos there; llama.cpp still wins above whenever the model is GGUF.
@@ -351,6 +366,8 @@ export function _buildServeCmd(f, modelName, backend) {
cmd += ` --gpu-memory-utilization ${f.gpu_mem || '0.90'}`;
if (f.swap && f.swap !== '0') cmd += ` --swap-space ${f.swap}`;
cmd += ` --dtype ${f.dtype || 'auto'}`;
const _kv = (f.vllm_kv_cache_dtype ?? '').toString().trim();
if (_kv === 'fp8') cmd += ' --kv-cache-dtype fp8';
if (f.max_seqs && f.max_seqs.toString().trim()) cmd += ` --max-num-seqs ${f.max_seqs.toString().trim()}`;
if (f.enforce_eager) cmd += ' --enforce-eager';
if (f.trust_remote) cmd += ' --trust-remote-code';
@@ -384,13 +401,17 @@ export function _buildServeCmd(f, modelName, backend) {
const ggufPath = f._gguf_path || 'model.gguf';
const gpuId = f.gpu_id?.trim() || '';
const py = _isWindows() ? 'python' : 'python3';
// CPU-only serve (-ngl 0): drop the GPU-only flags, otherwise the command
// mixes "zero GPU layers" with CUDA unified-memory + flash-attn and fails to
// start (issue #1291). Only affects the ngl=0 path; GPU serving is unchanged.
const _cpuOnly = String(f.ngl).trim() === '0';
const lcPrefix = (() => {
let p = '';
if (f.unified_mem && !_isWindows()) p += `GGML_CUDA_ENABLE_UNIFIED_MEMORY=1 `;
if (f.unified_mem && !_cpuOnly && !_isWindows()) p += `GGML_CUDA_ENABLE_UNIFIED_MEMORY=1 `;
if (gpuId && !_isWindows()) p += `CUDA_VISIBLE_DEVICES=${gpuId} `;
return p;
})();
if (f.unified_mem && _isWindows()) cmd += `$env:GGML_CUDA_ENABLE_UNIFIED_MEMORY="1"; `;
if (f.unified_mem && !_cpuOnly && _isWindows()) cmd += `$env:GGML_CUDA_ENABLE_UNIFIED_MEMORY="1"; `;
if (gpuId && _isWindows()) cmd += `$env:CUDA_VISIBLE_DEVICES="${gpuId}"; `;
if (!_isWindows()) {
// Resolve GGUF path once, fail loudly if nothing matched (prevents
@@ -402,16 +423,75 @@ export function _buildServeCmd(f, modelName, backend) {
// renders modern GGUF chat templates that the Python bindings' Jinja2
// rejects (do_tojson ensure_ascii). Fall back to llama_cpp.server.
// Don't suppress stderr — surface real errors (missing file, lib, OOM).
const _lcpServer = `${lcPrefix}${py} -m llama_cpp.server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} --n_gpu_layers ${f.ngl || '99'} --n_ctx ${f.ctx || '8192'}`;
// Optional perf/fit flags from a hardware profile (see services/hwfit/
// profiles.py). n_cpu_moe offloads MoE expert layers to CPU when the model
// is bigger than VRAM; flash-attn + a quantized KV cache cut KV memory and
// speed things up. Only emitted when set, so manual/older flows are unchanged.
const _ncm = (f.n_cpu_moe ?? '').toString().trim();
const _kv = (f.cache_type ?? '').toString().trim();
const _llamaNum = (v) => {
const s = String(v || '').trim();
return /^\d+$/.test(s) ? s : '';
};
const _llamaCsv = (v) => {
const s = String(v || '').replace(/\s+/g, '');
return /^\d+(?:\.\d+)?(?:,\d+(?:\.\d+)?)*$/.test(s) ? s : '';
};
let _lcExtra = '';
let _lcpExtra = '';
if (_ncm !== '' && Number(_ncm) > 0) {
_lcExtra += ` --n-cpu-moe ${_ncm}`;
_lcpExtra += ` --n_cpu_moe ${_ncm}`; // llama-cpp-python uses underscores
}
if (f.flash_attn && !_cpuOnly) {
_lcExtra += ' --flash-attn on';
_lcpExtra += ' --flash_attn true';
}
if (_kv) {
_lcExtra += ` --cache-type-k ${_kv} --cache-type-v ${_kv}`;
// llama-cpp-python exposes these as type_k/type_v; pass through best-effort.
_lcpExtra += ` --type_k ${_kv} --type_v ${_kv}`;
}
const _llamaFit = String(f.llama_fit || '').trim();
if (['on', 'off'].includes(_llamaFit)) _lcExtra += ` --fit ${_llamaFit}`;
if (f.llama_no_mmap) _lcExtra += ' --no-mmap';
if (f.llama_no_warmup) _lcExtra += ' --no-warmup';
const _llamaSplitMode = String(f.llama_split_mode || '').trim();
if (['none', 'layer', 'row', 'tensor'].includes(_llamaSplitMode)) _lcExtra += ` --split-mode ${_llamaSplitMode}`;
const _llamaTensorSplit = _llamaCsv(f.llama_tensor_split);
if (_llamaTensorSplit) _lcExtra += ` --tensor-split ${_llamaTensorSplit}`;
const _llamaMainGpu = _llamaNum(f.llama_main_gpu);
if (_llamaMainGpu) _lcExtra += ` --main-gpu ${_llamaMainGpu}`;
const _llamaParallel = _llamaNum(f.llama_parallel);
if (_llamaParallel) _lcExtra += ` --parallel ${_llamaParallel}`;
const _llamaBatch = _llamaNum(f.llama_batch_size);
if (_llamaBatch) _lcExtra += ` --batch-size ${_llamaBatch}`;
const _llamaUBatch = _llamaNum(f.llama_ubatch_size);
if (_llamaUBatch) _lcExtra += ` --ubatch-size ${_llamaUBatch}`;
if (f.llama_speculative_mtp) {
const specTokens = parseInt(f.llama_spec_tokens, 10);
const specN = Number.isFinite(specTokens) && specTokens > 0 ? specTokens : 3;
_lcExtra += ` --spec-type draft-mtp --spec-draft-n-max ${specN}`;
}
// Vision: serve the multimodal projector so the model can read images. The
// mmproj path is resolved at runtime (find mmproj-*.gguf next to the model);
// only emitted when the Vision toggle is on AND a projector was found.
if (f.vision && f._mmproj_path) {
_lcExtra += ` --mmproj "${f._mmproj_path}" --image-max-tokens 1024`;
// llama-cpp-python takes the projector via --clip_model_path.
_lcpExtra += ` --clip_model_path "${f._mmproj_path}"`;
}
const _lcpServer = `${lcPrefix}${py} -m llama_cpp.server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} --n_gpu_layers ${f.ngl || '99'} --n_ctx ${f.ctx || '8192'}${_lcpExtra}`;
if (_isWindows()) {
cmd += _lcpServer;
} else {
cmd += `${lcPrefix}llama-server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} -ngl ${f.ngl || '99'} -c ${f.ctx || '8192'}`;
cmd += `${lcPrefix}llama-server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} -ngl ${f.ngl || '99'} -c ${f.ctx || '8192'}${_lcExtra}`;
cmd += ` || ${_lcpServer}`;
}
} else if (backend === 'ollama') {
const ollamaPort = f.port || '11434';
const hostEnv = ollamaPort !== '11434' ? `OLLAMA_HOST=0.0.0.0:${ollamaPort} ` : '';
const bindHost = _envState.remoteHost ? '0.0.0.0' : '127.0.0.1';
const hostEnv = ollamaPort !== '11434' ? `OLLAMA_HOST=${bindHost}:${ollamaPort} ` : '';
cmd = `${hostEnv}ollama serve`;
} else if (backend === 'diffusers') {
const gpuStr = f.gpus?.trim();
@@ -542,6 +622,10 @@ 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) {
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>`;
}
if (pkg.installed) return `<button class="cookbook-dep-tag cookbook-dep-installed cookbook-dep-installed-btn" title="Installed — click for actions"><span class="cookbook-dep-installed-label">Installed</span><span class="cookbook-dep-caret">&#9662;</span></button>`;
if (isSystemDep) {
const depTip = esc(pkg.install_hint || 'Install this OS package on the selected server.');
@@ -556,11 +640,13 @@ async function _fetchDependencies() {
const isSystemDep = pkg.kind === 'system';
const winBlocked = !isLocal && _isWindows() && _winUnsupported.has(pkg.name);
const note = pkg.status_note ? `<div class="memory-item-meta" style="font-size:10px;opacity:0.65;margin-top:3px;">${esc(pkg.status_note)}</div>` : '';
const updateNote = pkg.installed && pkg.pip_update_available === false && pkg.update_note ? `<div class="memory-item-meta" style="font-size:10px;opacity:0.55;margin-top:3px;">${esc(pkg.update_note)}</div>` : '';
return `<div class="cookbook-dep-row${winBlocked ? ' cookbook-dep-blocked' : ''}" data-pkg-name="${esc(pkg.name)}" data-dep-pip="${esc(pkg.pip || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}" data-dep-kind="${esc(pkg.kind || 'python')}">`
+ `<div class="cookbook-dep-info">`
+ `<div class="memory-item-title">${esc(pkg.name)}</div>`
+ `<div class="memory-item-meta" style="font-size:10px;opacity:0.5;margin-top:2px;">${esc(pkg.desc)}</div>`
+ note
+ updateNote
+ `</div>`
+ `<span class="cookbook-dep-tag cookbook-dep-cat">${esc(pkg.category)}</span>`
+ _statusTag(pkg, isLocal, isSystemDep, winBlocked)
@@ -642,7 +728,7 @@ async function _fetchDependencies() {
}
// _dep flags this as a pip dependency/driver install (not a servable
// model) so the running-task card doesn't offer a "Serve →" button.
const payload = { repo_id: pipName, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true };
const payload = { repo_id: pipName, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true, env_path: _envState.envPath || '' };
_addTask(data.session_id, 'pip ' + pkgName, 'download', payload);
if (statusEl) { statusEl.textContent = upgrade ? 'Updating...' : 'Installing...'; statusEl.disabled = true; }
uiModule.showToast(`${upgrade ? 'Updating' : 'Installing'} ${pkgName} on ${targetHost}...`);
@@ -932,6 +1018,51 @@ function _wireTabEvents(body) {
});
}
// "Rebuild llama.cpp" clears the cached build so the next serve recompiles.
// The serve bootstrap only builds llama-server when it is missing from PATH,
// so a host that first built CPU-only (no nvcc at build time) keeps reusing
// that binary forever; this is the lever to force a fresh GPU build after a
// CUDA/ROCm toolkit is installed.
const rebuildBtn = document.getElementById('cookbook-rebuild-engine');
if (rebuildBtn && !rebuildBtn._wired) {
rebuildBtn._wired = true;
rebuildBtn.addEventListener('click', async () => {
// Match _installDep: honor the Dependencies server selector so the clear
// runs on the same host the build runs on.
const sel = document.getElementById('hwfit-deps-server');
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;
}
});
}
// Serve sort
const serveSort = document.getElementById('serve-sort');
if (serveSort) {
@@ -985,6 +1116,7 @@ function _wireTabEvents(body) {
document.getElementById('serve-bulk-cancel')?.addEventListener('click', () => {
selectBtn.classList.remove('active');
selectBtn.textContent = 'Select'; // reset label so the button doesn't stay reading "Cancel" after exit
bulkBar.classList.add('hidden');
document.querySelectorAll('.serve-select-cb').forEach(dot => { dot.style.display = 'none'; dot.classList.remove('selected'); });
});
@@ -1003,6 +1135,7 @@ function _wireTabEvents(body) {
if (item) await _deleteCachedModel(repo, item, true);
}
selectBtn.classList.remove('active');
selectBtn.textContent = 'Select'; // same reset as bulk-cancel
bulkBar.classList.add('hidden');
document.querySelectorAll('.serve-select-cb').forEach(dot => { dot.style.display = 'none'; dot.classList.remove('selected'); });
});
@@ -1011,6 +1144,16 @@ function _wireTabEvents(body) {
// Download input
const dlBtn = document.getElementById('cookbook-dl-btn');
const dlInput = document.getElementById('cookbook-dl-repo');
const dlCardToggle = document.getElementById('cookbook-download-card-toggle');
const dlCardBody = document.getElementById('cookbook-download-card-body');
const dlCardArrow = document.getElementById('cookbook-download-card-arrow');
if (dlCardToggle && dlCardBody) {
dlCardToggle.addEventListener('click', () => {
const isOpen = dlCardBody.style.display !== 'none';
dlCardBody.style.display = isOpen ? 'none' : 'block';
if (dlCardArrow) dlCardArrow.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)';
});
}
if (dlBtn && dlInput) {
function _stripHfUrl(input) {
let repo = input.trim();
@@ -1104,8 +1247,12 @@ function _wireTabEvents(body) {
if (hfToggle && hfList) {
let _loaded = false;
// Per-server VRAM cache so we don't re-probe on every expand
const _vramCache = {};
async function _getSelectedServerVram() {
const _hwCache = {};
function _hfModelLooksAwqLike(m) {
const text = `${m?.repo_id || ''} ${(m?.tags || []).join(' ')}`.toLowerCase();
return /\b(awq|gptq|fp8|4bit|int4)\b/.test(text);
}
async function _getSelectedServerHw() {
// Prefer the "What Fits" dropdown (the main control that shows hardware);
// fall back to the download dropdown. This is the server the list ranks for.
const dlSrv = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server');
@@ -1122,7 +1269,7 @@ function _wireTabEvents(body) {
}
}
const cacheKey = host || 'local';
if (_vramCache[cacheKey] !== undefined) return _vramCache[cacheKey];
if (_hwCache[cacheKey]) return _hwCache[cacheKey];
// Fetch system info for this server from hwfit
try {
const qp = new URLSearchParams();
@@ -1132,13 +1279,13 @@ function _wireTabEvents(body) {
const r = await fetch(`/api/hwfit/system?${qp}`);
if (r.ok) {
const sys = await r.json();
const v = sys?.gpu_vram_gb || 0;
_vramCache[cacheKey] = v;
return v;
const hw = { vram: sys?.gpu_vram_gb || 0, backend: String(sys?.backend || '').toLowerCase() };
_hwCache[cacheKey] = hw;
return hw;
}
} catch {}
_vramCache[cacheKey] = 0;
return 0;
_hwCache[cacheKey] = { vram: 0, backend: '' };
return _hwCache[cacheKey];
}
async function _loadLatest() {
// Match the Dependencies loader: whirlpool spinner + text label so the
@@ -1157,7 +1304,8 @@ function _wireTabEvents(body) {
} catch {
hfList.innerHTML = '<div class="hwfit-loading">Scanning models…</div>';
}
const vram = await _getSelectedServerVram();
const hwInfo = await _getSelectedServerHw();
const vram = hwInfo.vram || 0;
try {
let lastErr = '';
const _fetchLatest = async (v) => {
@@ -1173,6 +1321,9 @@ function _wireTabEvents(body) {
if (!models.length && vram > 0) {
models = await _fetchLatest(0);
}
if (['rocm', 'metal', 'mps', 'apple', 'generic', 'cpu'].includes(hwInfo.backend)) {
models = models.filter(m => !_hfModelLooksAwqLike(m));
}
if (!models.length) {
// Distinguish "the HF API failed" from "nothing matched" so an outage
// doesn't masquerade as no-fitting-models.
@@ -1254,9 +1405,32 @@ function _wireTabEvents(body) {
// HF token — save on change
const hfInput = document.getElementById('hwfit-hftoken');
if (hfInput) {
hfInput.addEventListener('change', () => {
_envState.hfToken = hfInput.value.trim();
_persistEnvState();
hfInput.addEventListener('change', async () => {
const val = hfInput.value.trim();
_envState.hfToken = val;
try { await _persistEnvState(); } catch {}
if (val) {
_envState.hfTokenConfigured = true;
const masked = val.length > 6 ? val.slice(0, 3) + '…' + val.slice(-3) : '••••';
_envState.hfTokenMasked = masked;
hfInput.placeholder = `Stored (${masked}) - enter a new token to replace`;
hfInput.value = '';
let check = hfInput.parentNode.querySelector('.hwfit-hf-check');
if (!check) {
check = document.createElement('span');
check.className = 'hwfit-hf-check';
check.title = 'Token stored';
check.textContent = '✓';
check.style.cssText = 'font-weight:800;color:var(--green,#50fa7b);font-size:15px;line-height:1;flex-shrink:0;position:relative;top:2px;';
hfInput.parentNode.insertBefore(check, hfInput);
}
const flash = document.createElement('span');
flash.textContent = 'Saved';
flash.style.cssText = 'margin-left:8px;font-size:11px;color:var(--green,#50fa7b);opacity:0;transition:opacity 0.18s;flex-shrink:0;position:relative;top:1px;';
hfInput.parentNode.appendChild(flash);
requestAnimationFrame(() => { flash.style.opacity = '1'; });
setTimeout(() => { flash.style.opacity = '0'; setTimeout(() => flash.remove(), 220); }, 1400);
}
});
}
}
@@ -1393,7 +1567,7 @@ function _renderRecipes() {
// silently sending downloads to the wrong server. An empty selection means Local; the user
// chooses a remote server explicitly via the dropdown.
// Download input
// Manual download input
html += `<div style="margin-top:7px;margin-bottom:2px;display:flex;gap:4px;align-items:center;">`;
if (_es.servers.length > 1) {
html += `<select class="cookbook-field-input hwfit-dl-server" id="hwfit-dl-server" style="height:28px;position:relative;top:0px;">`;
@@ -1409,7 +1583,7 @@ function _renderRecipes() {
html += `<button class="cookbook-btn cookbook-dl-btn" id="cookbook-dl-btn">Download</button>`;
html += `</div>`;
// Latest HF models that fit — collapsible card list
html += `<div style="margin-top:2px;position:relative;top:-8px;">`;
html += `<div style="margin-top:5px;position:relative;top:-3px;">`;
html += `<div style="display:flex;gap:4px;align-items:center;">`;
html += `<button type="button" class="memory-toolbar-btn" id="cookbook-hf-latest-toggle" style="flex:1;text-align:left;height:26px;display:flex;align-items:center;gap:6px;border-radius:4px;">`;
html += `<span id="cookbook-hf-latest-arrow" style="display:inline-block;transition:transform 0.15s;pointer-events:none;">\u25B8</span>`;
@@ -1422,7 +1596,7 @@ function _renderRecipes() {
html += `</div>`; // /#cookbook-dl-tab-fold-body (whole Download card body)
// Search section
html += '</div></div></div>';
html += '</div></div></div></div>';
html += '<div class="cookbook-group" data-backend-group="Search">';
html += '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">';
html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">';
@@ -1445,13 +1619,21 @@ function _renderRecipes() {
html += '<option value="Q4_K_M">Q4</option><option value="Q8_0">Q8</option>';
html += '<option value="Q6_K">Q6</option><option value="Q5_K_M">Q5</option>';
html += '<option value="Q3_K_M">Q3</option><option value="Q2_K">Q2</option>';
html += '<option value="AWQ-4bit">AWQ</option><option value="FP8">FP8</option></select>';
// Ctx slider — ported from origin/main. Lets you target a context length
// for fit estimates; the hwfit ranking uses _ctxValue() to factor that into
// VRAM math, so dragging this re-sorts the list toward models that fit
// your chosen ctx.
html += '<option value="AWQ-4bit">AWQ</option><option value="FP8">FP8</option><option value="FP4">FP4</option><option value="NVFP4">NVFP4</option></select>';
// Engine filter — show only models whose serve engine matches. Composes
// with quant / type / search filters.
html += '<select class="cookbook-field-input hwfit-engine" id="hwfit-engine" style="height:28px;" title="Filter by serving engine">';
html += '<option value="">Engine</option>';
html += '<option value="llamacpp">llama.cpp</option>';
html += '<option value="vllm">vLLM</option>';
html += '<option value="sglang">SGLang</option>';
html += '</select>';
html += '<span class="hwfit-help-chip" title="Higher numbers usually mean better quality, but they need more memory. Lower numbers fit on more hardware.">?</span>';
// Ctx slider — lets you target a context length for fit estimates; the
// hwfit ranking uses _ctxValue() to factor that into VRAM math, so
// dragging this re-sorts the list toward models that fit your chosen ctx.
html += '<label class="hwfit-ctx-control" title="Context length for fit estimates. Lower it to find more models that could fit your hardware.">';
html += '<span>Ctx</span><input type="range" id="hwfit-context" min="0" max="5" step="1" value="3" />';
html += '<span>Ctx</span><span class="hwfit-help-chip hwfit-help-chip-inline" title="Context length. Lower it to find more models that could fit your hardware; raise it when you need longer chats or documents.">?</span><input type="range" id="hwfit-context" min="0" max="5" step="1" value="3" />';
html += '<output id="hwfit-context-label">50k</output></label>';
html += '</div>';
html += '<div class="hwfit-toolbar" style="margin-top:7px;">';
@@ -1462,8 +1644,10 @@ function _renderRecipes() {
// Scan/refresh button (icon-only) where the quant dropdown used to sit.
html += '<button type="button" class="hwfit-gpu-btn" id="hwfit-rescan" title="Re-scan hardware" style="flex-shrink:0;position:relative;top:-3px;left:-1px;">↻ RESCAN</button>';
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-manual-btn" id="hwfit-hw-manual-btn" title="Set hardware manually" style="flex-shrink:0;position:relative;top:-3px;left:-1px;">EDIT</button>';
// Sort state — the clickable column headers read/write this (pewds' original
// sort paradigm). Newest is reachable by clicking the Model column header.
html += '<select class="cookbook-field-input hwfit-sort" id="hwfit-sort" style="display:none">';
html += '<option value="score">Score</option><option value="vram">VRAM</option>';
html += '<option value="fit">Fit</option><option value="score">Score</option><option value="vram">VRAM</option>';
html += '<option value="speed">Speed</option><option value="params">Params</option>';
html += '<option value="context">Context</option></select>';
html += '</div>';
@@ -1523,6 +1707,7 @@ function _renderRecipes() {
html += '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">';
html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">';
html += '<h2 style="margin:0;padding:0;line-height:1;">Dependencies</h2>';
html += '<button class="cookbook-field-input" 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)." style="height:24px;font-size:10px;padding:0 8px;cursor:pointer;width:auto;">Rebuild llama.cpp</button>';
html += '<span style="font-size:10px;opacity:0.5;margin-left:auto;">Server</span>';
html += '<select class="cookbook-field-input" id="hwfit-deps-server" style="height:28px;min-width:70px;">';
html += _buildServerOpts(false);
+24 -3
View File
@@ -86,6 +86,9 @@ function _ggufIncludePattern(model, source) {
function _missingGgufMessage(model) {
const name = model?.name || 'this model';
if (/\bnvfp4\b/i.test(name)) {
return `${name} is an NVIDIA NVFP4 checkpoint, not a GGUF download. Pick the base model row with an Unsloth GGUF source, or paste the GGUF repo directly.`;
}
return `No GGUF source is configured for ${name}. Pick a model with a GGUF source, or paste the GGUF repo in Download.`;
}
@@ -492,6 +495,10 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
const payload = { repo_id: repo };
if (include) payload.include = include;
// Large downloads are where hf_transfer most often dies near the end. Use the
// plain HuggingFace downloader up front for big model files; it is slower, but
// resumes cached partials more reliably.
if ((model.required_gb || 0) >= 10 || backend === 'llamacpp') payload.disable_hf_transfer = true;
if (_envState.hfToken) payload.hf_token = _envState.hfToken;
if (host) { payload.remote_host = host; const _sp = _getPort(host); if (_sp) payload.ssh_port = _sp; }
if (platform) payload.platform = platform;
@@ -516,6 +523,18 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
const targetHost = host || 'local';
const tasks = _loadTasks();
const sameDownload = (t) => {
if (!t || t.type !== 'download') return false;
const tRepo = t?.payload?.repo_id || t?.repo_id || t?.repo || t?.name || '';
const tHost = t?.remoteHost || t?.payload?.remote_host || 'local';
return String(tRepo) === String(payload.repo_id) && String(tHost || 'local') === String(targetHost);
};
const duplicate = tasks.find(t => sameDownload(t) && (t.status === 'running' || t.status === 'queued'));
if (duplicate) {
_renderRunningTab();
uiModule.showToast(`${shortName} is already ${duplicate.status === 'queued' ? 'queued' : 'downloading'}`);
return;
}
const activeOnHost = tasks.find(t => t.type === 'download' && (t.status === 'running' || t.status === 'queued') && (t.remoteHost || 'local') === targetHost);
if (activeOnHost) {
@@ -536,18 +555,20 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
body: JSON.stringify(payload),
});
if (!res.ok) {
uiModule.showToast('Download failed: HTTP ' + res.status);
// Errors carry actionable text (e.g. "tmux is required …"); keep them up
// long enough to read, matching the serve path's duration (issue #1355).
uiModule.showToast('Download failed: HTTP ' + res.status, 9000);
return;
}
const data = await res.json();
if (!data.ok) {
uiModule.showToast('Download failed: ' + (data.error || ''));
uiModule.showToast('Download failed: ' + (data.error || ''), 9000);
return;
}
_addTask(data.session_id, shortName, 'download', payload);
uiModule.showToast(`Downloading ${shortName}...`);
} catch (e) {
uiModule.showToast('Download failed: ' + e.message);
uiModule.showToast('Download failed: ' + e.message, 9000);
}
}
+29
View File
@@ -0,0 +1,29 @@
// static/js/cookbookProgressSignal.js
/**
* Liveness signal for a running cookbook download/install. The watchdog treats a
* task as stalled when this signal stays unchanged for too long, so it must move
* whenever the task is genuinely making progress.
*
* During a model DOWNLOAD the honest signal is the downloaded-byte counter
* ("1.81G" from "1.81G/2.49G"): it climbs while transferring and freezes when
* stuck and unlike a % bar or speed/ETA it doesn't keep animating on a frozen
* frame. That path is kept exactly as-is.
*
* But a dependency install (e.g. vllm) spends long stretches with NO byte
* counter pip dependency resolution and the native CUDA build/compile. A
* byte-only signal freezes there, so the watchdog falsely declares the install
* stale and restarts it mid-build, looping forever (#1568). When there's no byte
* counter, fall back to a fingerprint of the output tail: resolver/compile lines
* keep changing while the process is alive, and only a truly hung process leaves
* the tail frozen.
*
* Pure (string in, string out) so it's unit-testable; cookbookRunning.js pulls
* in browser-only modules and can't load under node.
*/
export function computeProgressSignal(bytes, dlAgg, lastPct, snapshot) {
if (bytes) return bytes;
const base = dlAgg != null ? String(dlAgg) : (lastPct || '0');
// No byte counter → use the output tail so a build/resolve phase that emits new
// lines counts as progress instead of a false stall (#1568).
return base + '|' + String(snapshot || '').slice(-300);
}
+552 -102
View File
@@ -7,6 +7,7 @@
import uiModule from './ui.js';
import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.js';
import { registerMenuDismiss } from './escMenuStack.js';
import { computeProgressSignal } from './cookbookProgressSignal.js';
// Human-friendly badge label for a task's internal status. Avoids surfacing
// the word "error" in the sidebar — a server the user stopped or one that
@@ -34,12 +35,105 @@ function _taskBadge(task) {
return { text: _statusLabel(task.status, task.type), cls: 'cookbook-task-' + task.status };
}
function _canClearTask(task) {
if (!task || task.status === 'running') return false;
if (task.type === 'serve' && (task.status === 'ready' || task._serveReady)) return false;
return ['done', 'stopped', 'error', 'crashed', 'failed'].includes(task.status);
}
function _clearPillLabel(task) {
return 'clear';
}
function _shouldOfferCrashReport(task) {
if (!task) return false;
if (task._unreachable && task.type === 'serve') return true;
return ['error', 'crashed', 'failed'].includes(task.status);
}
function _serveTaskLooksAwqOnLocalBackend(task, outputText = '') {
const repo = `${task?.payload?.repo_id || ''} ${task?.name || ''}`.toLowerCase();
const cmd = `${task?.payload?._cmd || ''} ${outputText || ''}`.toLowerCase();
return /\b(awq|gptq|fp8)\b/.test(repo) && /(llama-server|llama_cpp\.server|ollama|ggml_cuda_enable_unified_memory)/.test(cmd);
}
function _serveTaskLooksAwqWithoutUsableAccelerator(task, outputText = '') {
const repo = `${task?.payload?.repo_id || ''} ${task?.name || ''}`.toLowerCase();
const out = String(outputText || '').toLowerCase();
return /\b(awq|gptq|fp8)\b/.test(repo)
&& /(no accelerator|no cuda runtime|failed to infer device type|triton is not supported|0 active driver)/i.test(out);
}
async function _openDownloadForGgufTask(task) {
const raw = task?.payload?.repo_id || task?.name || '';
const modelName = String(raw)
.split('/').pop()
.replace(/[-_](?:AWQ|GPTQ|FP8|4bit|8bit|Int4|Int8).*$/i, '')
.replace(/[-_]+$/g, '')
|| String(raw).split('/').pop()
|| raw;
const cookbook = window.cookbookModule;
if (cookbook && typeof cookbook.open === 'function') {
cookbook.open({ tab: 'Search' });
} else {
document.getElementById('tool-cookbook-btn')?.click();
}
setTimeout(async () => {
const modal = document.getElementById('cookbook-modal');
const tab = modal?.querySelector('.cookbook-tab[data-backend="Search"]');
if (tab && !tab.classList.contains('active')) tab.click();
const search = document.getElementById('hwfit-search');
if (search) {
search.value = modelName;
search.dispatchEvent(new Event('input', { bubbles: true }));
search.focus();
}
const quant = document.getElementById('hwfit-quant');
if (quant) {
quant.value = 'Q4_K_M';
quant.dispatchEvent(new Event('change', { bubbles: true }));
}
try {
const hwfit = await import('./cookbook-hwfit.js');
if (typeof hwfit._hwfitFetch === 'function') hwfit._hwfitFetch(true);
} catch {}
}, 80);
}
function _terminalServeDiagnosis(task, outputText) {
const out = String(outputText || task?.output || '');
if (!task || task.type !== 'serve' || !['stopped', 'error', 'crashed', 'failed'].includes(task.status) || !out.trim()) return null;
if (_serveTaskLooksAwqOnLocalBackend(task, out)) {
return {
message: 'AWQ/GPTQ/FP8 cannot be served through llama.cpp/Ollama unified-memory mode.',
suggestion: 'Suggested action: use vLLM/SGLang on a compatible CUDA/ROCm GPU server, or download a GGUF version for llama.cpp/Ollama/unified-memory serving.',
fixes: [
{ label: 'Find GGUF download', action: () => _openDownloadForGgufTask(task) },
{ label: 'Edit serve', action: (panel) => _openServeEditForTask(task) },
],
};
}
if (_serveTaskLooksAwqWithoutUsableAccelerator(task, out)) {
return {
message: 'AWQ/GPTQ/FP8 needs a working vLLM/SGLang accelerator path; this server did not expose one.',
suggestion: 'Suggested action: choose a CUDA/ROCm server where vLLM/SGLang can see the GPU, or download a GGUF version and serve it with llama.cpp/Ollama.',
fixes: [
{ label: 'Find GGUF download', action: () => _openDownloadForGgufTask(task) },
{ label: 'Edit serve', action: (panel) => _openServeEditForTask(task) },
],
};
}
return _diagnose(out) || {
message: /Native llama-server not found|building llama-server|llama\.cpp/i.test(out)
? 'llama.cpp build stopped before the server became reachable.'
: 'Serve stopped before the model became reachable.',
suggestion: /Native llama-server not found|building llama-server|llama\.cpp/i.test(out)
? 'Suggested action: copy the troubleshooting bundle, then edit serve settings. For the quickest local/CPU path, use Ollama or a prebuilt llama-server; source builds can take several minutes and fail if build dependencies are incomplete.'
: 'Suggested action: copy the troubleshooting bundle, then edit serve settings or relaunch with a CPU/backend fallback.',
fixes: [{ label: 'Edit serve', action: (panel) => _openServeEditForTask(task) }],
};
}
function _redactCrashReportText(text) {
if (!text) return '';
return String(text)
@@ -136,6 +230,7 @@ const SERVE_STATE_KEY = 'cookbook-serve-state';
const TASK_POLL_INTERVAL_MS = 3000; // delay between reconnect-loop iterations
const BG_MONITOR_INTERVAL_MS = 10000; // background task status poll
const STALE_PROGRESS_MS = 5 * 60 * 1000; // download with no progress this long = stale
const STARTUP_STALE_PROGRESS_MS = 45 * 1000; // 0%-forever startup stall: retry much sooner
// ── Phase detection (mirrors Python _parse_serve_phase in cookbook_routes.py) ──
// Single source of truth for serve task status. KEEP IN SYNC with the Python version.
@@ -172,6 +267,23 @@ export function _parseServePhase(snapshot) {
if (/Ollama API ready on port\s+\d+/i.test(flat)) {
return { phase: 'ready', status: 'ready' };
}
const llamaBuildMatches = [...flat.matchAll(/\[\s*(\d{1,3})%\]\s*(?:Building|Linking)/gi)];
if (llamaBuildMatches.length) {
const pct = Math.min(100, parseInt(llamaBuildMatches[llamaBuildMatches.length - 1][1], 10));
return { phase: `building llama.cpp ${pct}%`, status: 'running', pct };
}
if (/Native llama-server not found|building from source/i.test(flat)) {
if (/Cloning into ['"]?llama\.cpp/i.test(flat) && !/Receiving objects:\s*100%/i.test(flat)) {
return { phase: 'cloning llama.cpp', status: 'running' };
}
if (/Configuring incomplete|CMake Error/i.test(flat)) {
return {};
}
if (/CMAKE_BUILD_TYPE|Detecting CXX|Found Threads|Including CPU backend|CUDA nvcc found|building llama-server/i.test(flat)) {
return { phase: 'configuring llama.cpp', status: 'running' };
}
return { phase: 'building llama.cpp', status: 'running' };
}
// HTTP access logs (e.g. GET /v1/models 200 OK) mean the server is up
if (/(?:GET|POST)\s+\/[^\s]*\s+HTTP\/[\d.]+"\s*\d{3}/.test(flat)) {
return { phase: 'idle', status: 'ready' };
@@ -264,10 +376,40 @@ function _refreshModelsAfterEndpointChange() {
}, 1500);
}
function _appendCookbookEndpointScope(fd, remoteHost) {
const host = String(remoteHost || '').trim();
if (!host || host === 'local' || host === 'localhost' || host === '127.0.0.1') {
fd.append('container_local', 'true');
}
}
function _connectHostFromRemote(remoteHost, fallback = 'localhost') {
const host = String(remoteHost || '').trim();
if (!host || host === 'local') return fallback;
return host.includes('@') ? host.split('@').pop() : host;
}
function _isAnyBindHost(host) {
const h = String(host || '').trim().toLowerCase();
return h === '0.0.0.0' || h === '::' || h === '[::]';
}
function _endpointFromAdvertisedUrl(rawUrl, currentHost, fallbackPort = '11434') {
try {
const u = new URL(rawUrl);
const host = _isAnyBindHost(u.hostname) ? currentHost : (u.hostname || currentHost);
const port = u.port || fallbackPort;
const bracketedHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
return { host, port, baseUrl: `${u.protocol}//${bracketedHost}${port ? `:${port}` : ''}/v1` };
} catch {
return null;
}
}
// ── Download queue — runs one at a time per server ──
function _processQueue() {
const tasks = _loadTasks();
const tasks = _loadPrunedTasks();
const running = tasks.filter(t => t.type === 'download' && t.status === 'running');
const queued = tasks.filter(t => t.type === 'download' && t.status === 'queued');
if (!queued.length) return;
@@ -321,14 +463,24 @@ async function _startQueuedDownload(task) {
return;
}
const oldId = task.sessionId;
const tasks = _loadTasks();
const t = tasks.find(t => t.sessionId === oldId);
if (t) {
t.sessionId = data.session_id;
t.id = data.session_id;
t.status = 'running';
_saveTasks(tasks);
}
const launchedTask = { ...task, sessionId: data.session_id, id: data.session_id, status: 'running' };
const key = _downloadDedupeKey(launchedTask);
let found = false;
const tasks = _loadTasks().filter(t => {
if (t.sessionId === oldId) {
found = true;
t.sessionId = data.session_id;
t.id = data.session_id;
t.status = 'running';
t._startLaunched = true;
return true;
}
if (t.sessionId === data.session_id) return false;
return !(key && t.type === 'download' && t.status === 'queued' && _downloadDedupeKey(t) === key);
});
if (!found) tasks.push(_stripTaskSecrets(launchedTask));
_saveTasks(tasks);
_renderRunningTab();
_startBackgroundMonitor();
await new Promise(r => setTimeout(r, 2000));
_renderRunningTab();
@@ -340,11 +492,74 @@ async function _startQueuedDownload(task) {
// ── Task CRUD ──
function _serveOutputLooksReady(task) {
const out = String(task?.output || '');
return !!task?._serveReady
|| /Application startup complete/i.test(out)
|| /Ollama API ready on port\s+\d+/i.test(out)
|| /(?:GET|POST)\s+\/[^\s]*\s+HTTP\/[\d.]+"\s*2\d\d/i.test(out);
}
function _normalizeTaskForDisplay(task) {
if (!task || typeof task !== 'object') return task;
if (task.type === 'serve' && task.status === 'done' && !_serveOutputLooksReady(task)) {
return { ...task, status: 'error' };
}
return task;
}
export function _loadTasks() {
try { return JSON.parse(localStorage.getItem(TASKS_KEY)) || []; }
try { return (JSON.parse(localStorage.getItem(TASKS_KEY)) || []).map(_normalizeTaskForDisplay); }
catch { return []; }
}
function _downloadRepoKey(task) {
return String(task?.payload?.repo_id || task?.repo_id || task?.repo || task?.name || '').trim();
}
function _downloadHostKey(task) {
return String(task?.remoteHost || task?.payload?.remote_host || 'local').trim() || 'local';
}
function _downloadDedupeKey(task) {
if (!task || task.type !== 'download') return '';
const repo = _downloadRepoKey(task);
if (!repo) return '';
return `${_downloadHostKey(task)}\n${repo}`;
}
function _pruneQueuedDownloadDuplicates(tasks) {
if (!Array.isArray(tasks) || !tasks.length) return tasks || [];
const launched = new Set();
for (const task of tasks) {
if (task?.type !== 'download' || task.status === 'queued') continue;
const key = _downloadDedupeKey(task);
if (key) launched.add(key);
}
let changed = false;
const seenQueued = new Set();
const next = tasks.filter(task => {
if (task?.type !== 'download' || task.status !== 'queued') return true;
const key = _downloadDedupeKey(task);
if (!key) return true;
if (launched.has(key) || seenQueued.has(key)) {
changed = true;
return false;
}
seenQueued.add(key);
return true;
});
return changed ? next : tasks;
}
function _loadPrunedTasks() {
const tasks = _loadTasks();
const pruned = _pruneQueuedDownloadDuplicates(tasks);
if (pruned !== tasks) _saveTasks(pruned);
return pruned;
}
// Tombstones for removed tasks. Without these, removing a task only deletes it
// locally — but the server still has it (its own POST guard even re-preserves
// recently-added ones), so the next sync/poll merges it right back ("I removed
@@ -407,6 +622,13 @@ export function _addTask(sessionId, name, type, payload) {
const _repoId = payload.repo_id;
tasks = tasks.filter(t => !(t.type === 'download' && t.status === 'done' && t.payload && t.payload.repo_id === _repoId));
}
if (type === 'download' && payload && payload.repo_id) {
const key = _downloadDedupeKey({ type: 'download', payload, remoteHost });
tasks = tasks.filter(t => {
if (t.sessionId === sessionId) return false;
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 });
tasks.push(task);
_saveTasks(tasks);
@@ -523,6 +745,52 @@ function _tmuxGracefulKill(task) {
return `tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null`;
}
function _shQuote(value) {
return "'" + String(value ?? '').replace(/'/g, "'\\''") + "'";
}
function _taskLooksOllama(task, outputText = '') {
const haystack = `${task?.payload?.backend || ''} ${task?.payload?._cmd || ''} ${task?.payload?._fields?.backend || ''} ${outputText || ''}`;
return /\bollama\b/i.test(haystack) || /Ollama API ready on port\s+\d+/i.test(haystack);
}
function _ollamaBaseUrlForTask(task, outputText = '') {
const out = String(outputText || '');
const ready = out.match(/Ollama API ready on port\s+\d+:\s*(http:\/\/[^\s]+)/i);
if (ready) return ready[1].replace(/\/+$/, '');
const cmd = String(task?.payload?._cmd || '');
const host = cmd.match(/OLLAMA_HOST=([^\s]+)/)?.[1] || '';
const port = host.match(/:(\d+)$/)?.[1] || '11434';
return `http://127.0.0.1:${port}`;
}
function _ollamaModelForTask(task) {
return String(task?.payload?.model || task?.payload?.repo_id || task?.name || '').trim();
}
function _ollamaUnloadCommand(task, outputText = '') {
if (!_taskLooksOllama(task, outputText)) return '';
const model = _ollamaModelForTask(task);
if (!model) return '';
const base = _ollamaBaseUrlForTask(task, outputText);
const body = JSON.stringify({ model, prompt: '', keep_alive: 0, stream: false });
const inner = `curl -sf -X POST ${_shQuote(base + '/api/generate')} -H 'Content-Type: application/json' -d ${_shQuote(body)} >/dev/null 2>&1 || true`;
if (task.remoteHost) {
return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(inner)}`;
}
return inner;
}
function _endpointUrlForTask(task, outputText = '') {
if (_taskLooksOllama(task, outputText)) {
return _ollamaBaseUrlForTask(task, outputText) + '/v1';
}
const host = _connectHostFromRemote(task.remoteHost);
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
const port = portMatch ? portMatch[1] : '8000';
return `http://${host}:${port}/v1`;
}
// ── Wave animation ──
const _waveFrames = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▅', '▆▅▄', '▅▄▃', '▄▃▂', '▃▂▁'];
@@ -781,17 +1049,23 @@ async function _retryTask(el, task) {
body: JSON.stringify({ command: _tmuxGracefulKill(task) }),
});
} catch {}
_removeTask(task.sessionId);
if (task.payload) {
if (task.type === 'serve' && task.payload._cmd) {
_removeTask(task.sessionId);
_launchServeTask(task.name, task.payload.repo_id, task.payload._cmd, task.payload._fields, task.remoteHost || '');
} else {
_retryDownload(task.name, task.payload);
uiModule.showToast('Retrying download — progress may look reset while HuggingFace checks cached files, then it should resume.', 7000);
_updateTask(task.sessionId, {
status: 'running',
output: `${task.output || ''}\n\n[odysseus] Retrying download. Progress may briefly look like a fresh download while HuggingFace checks cached/incomplete files; cached partial files will be reused when available.`.trim(),
_retrying: true,
});
_retryDownload(task.name, task.payload, task.sessionId);
}
}
}
async function _retryDownload(name, payload) {
async function _retryDownload(name, payload, replaceSessionId = '') {
try {
// A retry means the fast hf_transfer path already failed once — fall back to
// the plain, reliable downloader for this and any further attempt (it resumes
@@ -804,17 +1078,40 @@ async function _retryDownload(name, payload) {
});
if (!res.ok) {
uiModule.showToast('Download failed: HTTP ' + res.status);
if (replaceSessionId) _updateTask(replaceSessionId, { status: 'crashed', _retrying: false });
return;
}
const data = await res.json();
if (!data.ok) {
uiModule.showToast('Download failed: ' + (data.error || ''));
if (replaceSessionId) _updateTask(replaceSessionId, { status: 'crashed', _retrying: false });
return;
}
_addTask(data.session_id, name, 'download', _payload);
if (replaceSessionId) {
const tasks = _loadTasks();
const task = tasks.find(t => t.sessionId === replaceSessionId);
if (task) {
task.id = data.session_id;
task.sessionId = data.session_id;
task.status = 'running';
task.output = '';
task.ts = Date.now();
task.payload = _payload;
task._retrying = false;
_saveTasks(tasks);
_soloExpandTaskId = data.session_id;
_renderRunningTab();
_startBackgroundMonitor();
} else {
_addTask(data.session_id, name, 'download', _payload);
}
} else {
_addTask(data.session_id, name, 'download', _payload);
}
uiModule.showToast(`Downloading ${name}...`);
} catch (e) {
uiModule.showToast('Download failed: ' + e.message);
if (replaceSessionId) _updateTask(replaceSessionId, { status: 'crashed', _retrying: false });
}
}
@@ -875,7 +1172,7 @@ export async function _serveAutoFix(panel, envVar) {
// Edit button, but optionally with a modified command (used by the diagnosis
// "Retry with X" buttons so a retry lands in the editable Serve panel with the
// adjusted setting, instead of blindly relaunching).
async function _openServeEditForTask(task, cmdOverride) {
async function _openServeEditForTask(task, cmdOverride, fieldOverrides = null) {
const repo = task.payload?.repo_id;
if (!repo) { uiModule.showToast('No model info on this task'); return; }
const cmd = cmdOverride || task.payload?._cmd;
@@ -883,6 +1180,9 @@ async function _openServeEditForTask(task, cmdOverride) {
let fields = cmdOverride
? _parseServeCmdToFields(cmd)
: (task.payload?._fields || (cmd ? _parseServeCmdToFields(cmd) : null));
if (fieldOverrides && typeof fieldOverrides === 'object') {
fields = { ...(fields || {}), ...fieldOverrides };
}
// Switch the active server to the one this serve ran on (mirrors _openEdit).
const _tHost = task.remoteHost || '';
_envState.remoteHost = _tHost;
@@ -1062,12 +1362,27 @@ function _parseServeCmdToFields(cmd) {
gpu_mem: ex(/--gpu-memory-utilization\s+([\d.]+)/) || '0.90',
swap: ex(/--swap-space\s+(\d+)/) || '',
dtype: ex(/--dtype\s+(\w+)/) || 'auto',
vllm_kv_cache_dtype: ex(/--kv-cache-dtype\s+([\w.-]+)/) || 'auto',
max_seqs: ex(/--max-num-seqs\s+(\d+)/) || '',
gpus: ex(/CUDA_VISIBLE_DEVICES=(\S+)/) || '',
cache_type: ex(/(?:--cache-type-k|-ctk)\s+(\S+)/) || '',
llama_fit: ex(/(?:--fit|-fit)\s+(on|off)/) || '',
llama_split_mode: ex(/(?:--split-mode|-sm)\s+(none|layer|row|tensor)/) || '',
llama_tensor_split: ex(/(?:--tensor-split|-ts)\s+([0-9.,]+)/) || '',
llama_main_gpu: ex(/(?:--main-gpu|-mg)\s+(\d+)/) || '',
llama_parallel: ex(/(?:--parallel|-np)\s+(\d+)/) || '',
llama_batch_size: ex(/(?:--batch-size|-b)\s+(\d+)/) || '',
llama_ubatch_size: ex(/(?:--ubatch-size|-ub)\s+(\d+)/) || '',
llama_spec_tokens: ex(/--spec-draft-n-max\s+(\d+)/) || '3',
enforce_eager: cmd.includes('--enforce-eager'),
trust_remote: cmd.includes('--trust-remote-code'),
prefix_cache: cmd.includes('--enable-prefix-caching'),
auto_tool: cmd.includes('--enable-auto-tool-choice'),
flash_attn: /--flash-attn\s+on\b/.test(cmd),
unified_mem: /GGML_CUDA_ENABLE_UNIFIED_MEMORY=1/.test(cmd),
llama_no_mmap: /--no-mmap\b/.test(cmd),
llama_no_warmup: /--no-warmup\b/.test(cmd),
llama_speculative_mtp: /--spec-type\s+\S*draft-mtp/.test(cmd),
speculative: cmd.includes('--speculative-config'),
};
const spec = cmd.match(/--speculative-config\s+'?\{[^}]*"method"\s*:\s*"([^"]+)"[^}]*"num_speculative_tokens"\s*:\s*(\d+)/);
@@ -1181,7 +1496,7 @@ export function _renderRunningTab() {
// event but the matching clear only ran on modal-open, so the highlight
// persisted indefinitely after tasks finished in the background.
try {
const _activeTasks = _loadTasks().filter(t => t.status === 'running' || t.status === 'queued' || t.status === 'error');
const _activeTasks = _loadPrunedTasks().filter(t => t.status === 'running' || t.status === 'queued' || t.status === 'error');
if (!_activeTasks.length) _clearCookbookNotif();
} catch {}
@@ -1222,6 +1537,8 @@ export function _renderRunningTab() {
const tasks = _loadTasks();
const hasContent = tasks.length > 0;
const activeCount = tasks.filter(t => t.status === 'running' || t.status === 'queued').length;
const activeCountHtml = activeCount ? ` <span class="cookbook-tab-count">${activeCount}</span>` : '';
let tabBar = body.querySelector('.cookbook-tabs');
if (!tabBar) return;
@@ -1231,7 +1548,7 @@ export function _renderRunningTab() {
runTab.className = 'cookbook-tab';
runTab.dataset.backend = 'Running';
const _errCount = tasks.filter(t => t.status === 'error' || t.status === 'crashed').length;
runTab.innerHTML = `Running <span class="cookbook-tab-count">${tasks.length}</span>${_errCount ? `<span class="cookbook-tab-error-dot"></span>` : ''}`;
runTab.innerHTML = `Running${activeCountHtml}${_errCount ? `<span class="cookbook-tab-error-dot"></span>` : ''}`;
tabBar.insertBefore(runTab, tabBar.firstChild);
runTab.addEventListener('click', () => {
tabBar.querySelectorAll('.cookbook-tab').forEach(t => t.classList.remove('active'));
@@ -1242,7 +1559,7 @@ export function _renderRunningTab() {
});
} else if (runTab) {
const _errCount2 = tasks.filter(t => t.status === 'error' || t.status === 'crashed').length;
runTab.innerHTML = tasks.length ? `Running <span class="cookbook-tab-count">${tasks.length}</span>${_errCount2 ? '<span class="cookbook-tab-error-dot"></span>' : ''}` : 'Running';
runTab.innerHTML = tasks.length ? `Running${activeCountHtml}${_errCount2 ? '<span class="cookbook-tab-error-dot"></span>' : ''}` : 'Running';
if (!hasContent) {
if (runTab.classList.contains('active')) {
const wfTab = tabBar.querySelector('.cookbook-tab[data-backend="Search"]');
@@ -1259,7 +1576,7 @@ export function _renderRunningTab() {
group.dataset.backendGroup = 'Running';
group.innerHTML = '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">' +
'<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">' +
'<h2 style="margin:0;padding:0;line-height:1;">Running <span id="running-count" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal">' + tasks.length + '</span></h2>' +
'<h2 style="margin:0;padding:0;line-height:1;">Running <span id="running-count" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal">' + activeCount + '</span></h2>' +
'</div>' +
'<p class="memory-desc doclib-desc" style="margin-top:6px;">Active downloads and serving processes.</p>' +
'</div>';
@@ -1271,7 +1588,7 @@ export function _renderRunningTab() {
if (!group) return;
const countEl = group.querySelector('#running-count');
if (countEl) countEl.textContent = tasks.length;
if (countEl) countEl.textContent = activeCount;
if (!hasContent) {
group.remove();
@@ -1351,8 +1668,8 @@ export function _renderRunningTab() {
const host = btn.dataset.clearServer;
if (!await window.styledConfirm(`Clear finished tasks on ${_serverName(host)}?`, { confirmText: 'Clear' })) return;
const allTasks = _loadTasks();
const toRemove = allTasks.filter(t => (t.remoteHost || '') === host && t.status !== 'running');
const remaining = allTasks.filter(t => (t.remoteHost || '') !== host || t.status === 'running');
const toRemove = allTasks.filter(t => (t.remoteHost || '') === host && _canClearTask(t));
const remaining = allTasks.filter(t => (t.remoteHost || '') !== host || !_canClearTask(t));
_saveTasks(remaining);
// Fade/slide each finished card out (same exit as the per-card clear)
// instead of yanking them instantly.
@@ -1389,6 +1706,9 @@ export function _renderRunningTab() {
const running = _loadTasks().filter(t => (t.remoteHost || '') === 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
// download auto-retry logic never restarts a task the user just stopped.
running.forEach(t => _updateTask(t.sessionId, { _userStopped: true }));
// Reuse each task's own Stop action so it does the full teardown
// (send C-c, drop the endpoint, mark stopped) consistently.
running.forEach(t => {
@@ -1442,16 +1762,21 @@ export function _renderRunningTab() {
const _bdg = _taskBadge(task);
badge.textContent = _bdg.text;
badge.className = 'cookbook-task-status' + (_bdg.cls ? ' ' + _bdg.cls : '');
badge.style.display = isDone ? 'none' : ''; // hidden — type chip carries it
badge.style.display = '';
}
// Indicator: spinning wave while running, green check when finished.
const wave = el.querySelector('.cookbook-task-wave');
if (wave) wave.style.display = task.status === 'running' ? '' : 'none';
// Model downloads (which have a Serve → button) don't get a clear pill —
// pressing Serve clears them. Dep installs / serve tasks keep it.
const check = el.querySelector('.cookbook-task-check');
const _showClear = isDone && !(task.type === 'download' && !task.payload?._dep);
if (check) check.style.display = _showClear ? '' : 'none';
if (check) {
check.style.display = _canClearTask(task) ? '' : 'none';
const label = check.querySelector('.cookbook-task-done-label');
if (label) label.textContent = _clearPillLabel(task);
}
const startNow = el.querySelector('.cookbook-task-start-now');
if (startNow) startNow.style.display = (task.type === 'download' && task.status === 'queued') ? '' : 'none';
const terminalDiag = _terminalServeDiagnosis(task, el.querySelector('.cookbook-output-pre')?.textContent || task.output || '');
if (terminalDiag) _showDiagnosis(el, terminalDiag, el.querySelector('.cookbook-output-pre')?.textContent || task.output || '');
}
if (!task) {
if (el._uptimeInterval) { clearInterval(el._uptimeInterval); el._uptimeInterval = null; }
@@ -1475,20 +1800,21 @@ export function _renderRunningTab() {
<div class="cookbook-task-header">
<span class="cookbook-task-type${(task.status === 'done' && task.type === 'download') ? ' cookbook-task-type-done' : ''}" data-type="${esc(task.type)}">${esc((task.status === 'done' && task.type === 'download') ? 'finished' : task.type)}</span>
<span class="cookbook-task-name">${modelLogo(task.name)}${esc(task.name)}</span>
<span class="cookbook-task-status ${_bdg.cls}" style="display:${task.status === 'done' ? 'none' : ''}"${_bdgTitle}>${esc(_bdg.text)}</span>
${task.type === 'serve' && task.payload?._cmd ? '<button class="cookbook-task-edit-btn" title="Edit settings &amp; relaunch"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>' : ''}
${task.type === 'serve' && task.payload?._cmd ? '<button class="cookbook-task-save-btn" title="Save preset"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg></button>' : ''}
<span class="cookbook-task-indicator"><span class="cookbook-task-wave" style="display:${task.status === 'running' ? '' : 'none'}"></span><span class="cookbook-task-check" title="Clear" style="display:${(task.status === 'done' && !(task.type === 'download' && !task.payload?._dep)) ? '' : 'none'}"><svg class="cookbook-task-check-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><svg class="cookbook-task-clear-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span class="cookbook-task-done-label">done</span><span class="cookbook-task-clear-label">clear</span></span></span>
${task.type === 'download' && !task.payload?._dep && task.status === 'done' ? `<span class="cookbook-task-status cookbook-task-done">finished</span>` : ''}
<span class="cookbook-task-indicator"><span class="cookbook-task-wave" style="display:${task.status === 'running' ? '' : 'none'}"></span><span class="cookbook-task-check" title="Clear" style="display:${_canClearTask(task) ? '' : 'none'}"><svg class="cookbook-task-check-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><svg class="cookbook-task-clear-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span class="cookbook-task-done-label">${esc(_clearPillLabel(task))}</span><span class="cookbook-task-clear-label">clear</span></span></span>
<button type="button" class="cookbook-task-start-now" title="Start this queued download now" style="display:${(task.type === 'download' && task.status === 'queued') ? '' : 'none'}"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><polygon points="8 5 19 12 8 19 8 5"/></svg><span>start now</span></button>
<span class="cookbook-task-status ${_bdg.cls}"${_bdgTitle}>${esc(_bdg.text)}</span>
<button class="cookbook-task-menu-btn" title="Actions">&#8942;</button>
</div>
<div class="cookbook-task-sub"><span class="cookbook-task-session">${esc(task.sessionId)}</span><span class="cookbook-task-uptime" style="display:${((task.type === 'serve' || task.type === 'download') && task.status === 'running') ? '' : 'none'}"></span></div>
<div class="cookbook-task-sub"><span class="cookbook-task-session">${esc(task.sessionId)}</span><span class="cookbook-task-uptime" style="display:${((task.type === 'serve' || task.type === 'download') && task.status === 'running') ? '' : 'none'}"></span>${(task.type === 'download') ? `<span class="cookbook-task-dldir" title="Download destination" style="font-size:9px;color:var(--fg-muted);font-family:'Fira Code',monospace;opacity:0.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:40ch;">Dir: ${esc(task.payload?.local_dir || '~/.cache/huggingface/hub')}</span>` : ''}</div>
<div class="cookbook-output-wrap cookbook-task-collapsible${_mobileCollapseDefault ? ' cookbook-task-collapsed' : ''}"><pre class="cookbook-output-pre">${esc(task.output || '')}</pre><button type="button" class="copy-code cookbook-output-copy"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>
`;
const _waveEl = el.querySelector('.cookbook-task-wave');
if (_waveEl && task.status === 'running') _registerWaveEl(_waveEl);
const terminalDiag = _terminalServeDiagnosis(task, task.output || '');
if (terminalDiag) _showDiagnosis(el, terminalDiag, task.output || '');
const _uptimeEl = el.querySelector('.cookbook-task-uptime');
if (_uptimeEl && (task.type === 'serve' || task.type === 'download') && task.status === 'running') {
const _startedAt = task.ts || Date.now();
@@ -1505,35 +1831,12 @@ export function _renderRunningTab() {
}
// Re-open the Serve panel for this model, pre-filled with the EXACT
// settings this instance launched with, and on the SERVER it runs on
// shared by the edit icon button and the ⋮ "Edit settings" menu item.
// settings this instance launched with, and on the SERVER it runs on.
const _openEdit = () => _openServeEditForTask(task);
const editBtn = el.querySelector('.cookbook-task-edit-btn');
if (editBtn) {
editBtn.addEventListener('click', (e) => { e.stopPropagation(); _openEdit(); });
}
// Wire save icon button
const saveBtn = el.querySelector('.cookbook-task-save-btn');
if (saveBtn) {
saveBtn.addEventListener('click', async (e) => {
e.stopPropagation();
// Tell them it's already saved up front (often true now that working
// configs auto-save) instead of after they've typed a name.
if (_loadPresets().some(p => p.cmd === task.payload?._cmd)) {
uiModule.showToast('Already saved');
return;
}
const label = (await uiModule.styledPrompt('Name this config so you can recall it later.', {
title: 'Save Config', defaultValue: task.name, placeholder: 'e.g. 8-bit, fast', confirmText: 'Save',
}) || '').trim();
if (!label) return;
if (!_saveTaskAsPreset(task, label)) { uiModule.showToast('Already saved'); return; }
saveBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="2.5" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg>';
uiModule.showToast(`Saved "${label}"`);
setTimeout(() => { saveBtn.style.display = 'none'; }, 1500);
});
}
el.addEventListener('cookbook:edit-serve', (e) => {
e.stopPropagation();
_openServeEditForTask(task, null, e.detail?.fields || null);
});
// Finished download → an explicit "Serve →" button jumps straight to the
// Serve tab with this model pre-selected (on the server it downloaded to).
@@ -1571,10 +1874,30 @@ export function _renderRunningTab() {
if (_clearChk) {
_clearChk.addEventListener('click', (e) => {
e.stopPropagation();
// Belt-and-suspenders: kill the tmux session too. For a real-finished
// task the session is already gone and kill-session errors silently,
// but for a task that was falsely flagged done (the strict-finish
// bug), this guarantees the still-running download actually stops
// rather than continuing to write to disk after the row is removed.
try {
fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: _tmuxCmd(task, `kill-session -t ${task.sessionId}`) }),
}).catch(() => {});
} catch {}
_animateOutThenRemove(el, task.sessionId);
});
}
const _startNowBtn = el.querySelector('.cookbook-task-start-now');
if (_startNowBtn) {
_startNowBtn.addEventListener('click', (e) => {
e.stopPropagation();
_startQueuedDownload(task);
});
}
// Wire header click to collapse/expand output
el.querySelector('.cookbook-task-header').addEventListener('click', (e) => {
if (e.target.closest('button')) return;
@@ -1675,8 +1998,7 @@ export function _renderRunningTab() {
// serve to the model-endpoints list regardless of prior flag state.
if (task.type === 'serve' && task.payload?._cmd) {
items.push({ label: 'Register endpoint', action: 'register-endpoint', custom: async () => {
const rawHost = task.remoteHost || 'localhost';
const host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost;
const host = _connectHostFromRemote(task.remoteHost);
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
const port = portMatch ? portMatch[1] : '8000';
const baseUrl = `http://${host}:${port}/v1`;
@@ -1699,6 +2021,7 @@ export function _renderRunningTab() {
fd.append('base_url', baseUrl);
fd.append('name', task.name);
fd.append('skip_probe', 'true');
_appendCookbookEndpointScope(fd, task.remoteHost || '');
if (task.payload?._cmd?.includes('diffusion_server')) fd.append('model_type', 'image');
const res = await fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
if (res.ok) {
@@ -1859,13 +2182,21 @@ export function _renderRunningTab() {
const badge = el.querySelector('.cookbook-task-status');
if (badge) { badge.textContent = 'stopping...'; badge.className = 'cookbook-task-status cookbook-task-stopping'; }
el.dataset.status = 'stopped';
_updateTask(task.sessionId, { _userStopped: true });
const outputText = el.querySelector('.cookbook-output-pre')?.textContent || task.output || '';
// Drop the model endpoint so the picker stops listing it.
if (task.type === 'serve' && task.payload) {
const rawHost = task.remoteHost || 'localhost';
const host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost;
const portMatch = task.payload._cmd?.match(/--port\s+(\d+)/);
const port = portMatch ? portMatch[1] : '8000';
_removeEndpointByUrl(`http://${host}:${port}/v1`);
_removeEndpointByUrl(_endpointUrlForTask(task, outputText));
}
const ollamaUnload = _ollamaUnloadCommand(task, outputText);
if (ollamaUnload) {
try {
await fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: ollamaUnload }),
});
} catch {}
}
// Gracefully stop (C-c, then kill the session) so it's fully down...
try {
@@ -1882,23 +2213,29 @@ export function _renderRunningTab() {
// Wire kill
el.querySelector('.cookbook-task-action-kill').addEventListener('click', () => {
const outputText = el.querySelector('.cookbook-output-pre')?.textContent || task.output || '';
const ollamaUnload = _ollamaUnloadCommand(task, outputText);
if (ollamaUnload) {
fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: ollamaUnload }),
}).catch(() => {});
}
fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: _tmuxGracefulKill(task) }),
}).catch(() => {});
if (task.type === 'serve' && task.payload) {
const rawHost = task.remoteHost || 'localhost';
const host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost;
const portMatch = task.payload._cmd?.match(/--port\s+(\d+)/);
const port = portMatch ? portMatch[1] : '8000';
_removeEndpointByUrl(`http://${host}:${port}/v1`);
const endpointUrl = _endpointUrlForTask(task, outputText);
_removeEndpointByUrl(endpointUrl);
const modelName = task.payload.model || task.name || '';
if (modelName) {
fetch('/api/model-endpoints', { credentials: 'same-origin' })
.then(r => r.json())
.then(eps => {
const ep = eps.find(e => e.name === modelName || (e.base_url && e.base_url.includes(':' + port)));
const ep = eps.find(e => e.name === modelName || e.base_url === endpointUrl);
if (ep) fetch(`/api/model-endpoints/${ep.id}`, { method: 'DELETE', credentials: 'same-origin' }).then(() => _refreshModelsAfterEndpointChange());
}).catch(() => {});
}
@@ -2017,19 +2354,65 @@ async function _reconnectTask(el, task) {
if (badge) { badge.textContent = _statusLabel('error', task.type); badge.className = 'cookbook-task-status cookbook-task-error'; }
_showCookbookNotif(true);
} else {
const looksSuccessful = !lastOutput.includes('DOWNLOAD_FAILED') && (lastOutput.includes('DONE') || lastOutput.includes('100%') || lastOutput.includes('Application startup complete') || lastOutput.includes('/snapshots/') || lastOutput.includes('Download complete') || lastOutput.includes('DOWNLOAD_OK'));
if (!lastOutput.trim() || (task.type === 'download' && !looksSuccessful)) {
const downloadLooksSuccessful = !lastOutput.includes('DOWNLOAD_FAILED')
&& (lastOutput.includes('DONE') || lastOutput.includes('100%') || lastOutput.includes('/snapshots/') || lastOutput.includes('Download complete') || lastOutput.includes('DOWNLOAD_OK'));
const serveLooksReady = task.type === 'serve' && _serveOutputLooksReady({ ...task, output: lastOutput });
const looksSuccessful = task.type === 'download' ? downloadLooksSuccessful : serveLooksReady;
if (!lastOutput.trim() || !looksSuccessful) {
_updateTask(task.sessionId, { status: 'crashed' });
el.dataset.status = 'crashed';
const badge = el.querySelector('.cookbook-task-status');
if (badge) { badge.textContent = _statusLabel('crashed', task.type); badge.className = 'cookbook-task-status cookbook-task-crashed'; }
if (task.type === 'serve') {
const diag = _diagnose(lastOutput) || {
message: _serveTaskLooksAwqOnLocalBackend(task, lastOutput)
? 'AWQ/GPTQ/FP8 cannot be served through llama.cpp/Ollama unified-memory mode.'
: /Native llama-server not found|building llama-server|llama\.cpp/i.test(lastOutput)
? 'llama.cpp build stopped before the server became reachable.'
: 'Serve stopped before the model became reachable.',
suggestion: _serveTaskLooksAwqOnLocalBackend(task, lastOutput)
? 'Suggested action: use vLLM/SGLang on a compatible CUDA/ROCm GPU server, or download a GGUF version for llama.cpp/Ollama/unified-memory serving.'
: /Native llama-server not found|building llama-server|llama\.cpp/i.test(lastOutput)
? 'Suggested action: copy the troubleshooting bundle, then edit serve settings. For the quickest local/CPU path, use Ollama or a prebuilt llama-server; source builds can take several minutes and fail if build dependencies are incomplete.'
: 'Suggested action: copy the troubleshooting bundle, then edit serve settings or relaunch with a CPU/backend fallback.',
fixes: [{ label: 'Edit serve', action: (panel) => _openServeEditForTask(task) }],
};
_showDiagnosis(el, diag, lastOutput);
} else if (task.type === 'download') {
const isDisk = /no space left|disk quota|enospc/i.test(lastOutput);
const isNetwork = /connection|timeout|timed out|incompleteread|chunkedencoding|reset by peer|protocolerror|all connection attempts failed/i.test(lastOutput);
const progressMatch = String(lastOutput || '').match(/(\d+)%\|/);
const nearDone = progressMatch && Number(progressMatch[1]) >= 80;
const diag = {
message: isDisk
? 'Download stopped because this server ran out of disk space.'
: isNetwork
? 'Download stopped after the HuggingFace connection was interrupted.'
: nearDone
? 'Download stopped near the end before the final completion marker was captured.'
: 'Download stopped before HuggingFace reported completion.',
suggestion: isDisk
? 'Suggested action: free disk space, then retry the download. HuggingFace resumes incomplete files when possible.'
: nearDone
? 'Suggested action: retry the download. It may briefly look like it restarted while cached files are checked, then it should reuse incomplete files.'
: 'Suggested action: retry the download. HuggingFace resumes incomplete files when possible.',
fixes: [
{ label: 'Retry download', action: () => _retryTask(el, task) },
{ label: 'Copy last 50 lines', action: () => {
const last = String(lastOutput || '').split('\n').slice(-50).join('\n');
_copyText(last || 'No download log available.');
} },
],
};
_showDiagnosis(el, diag, lastOutput);
}
_showCookbookNotif(true);
} else {
_updateTask(task.sessionId, { status: 'done' });
el.dataset.status = 'done';
const badge = el.querySelector('.cookbook-task-status');
if (badge) { badge.textContent = _statusLabel('done', task.type); badge.className = 'cookbook-task-status cookbook-task-done'; }
const _chk = el.querySelector('.cookbook-task-check'); if (_chk && task.type !== 'download') _chk.style.display = '';
const _chk = el.querySelector('.cookbook-task-check'); if (_chk) _chk.style.display = '';
const _sb = el.querySelector('.cookbook-task-serve-btn'); if (_sb) _sb.style.display = '';
_showCookbookNotif();
_refreshDepsAfterInstall(task);
@@ -2071,10 +2454,17 @@ async function _reconnectTask(el, task) {
// stale speed/ETA — so keying off speed masked real stalls (that's why a
// 97%-stuck download went undetected). Bytes are the honest signal; fall
// back to %/aggregate only when no byte counter is present.
const _STALE_TIMEOUT = STALE_PROGRESS_MS;
const _byteMatches = [...snapshot.matchAll(/([\d.]+\s?[KMGT])B?\s*\/\s*[\d.]+\s?[KMGT]B?/gi)];
const _bytes = _byteMatches.length ? _byteMatches[_byteMatches.length - 1][1].replace(/\s/g, '') : null;
const curProgress = _bytes || (_dlAgg != null ? String(_dlAgg) : (lastPct || '0'));
// When there's no byte counter (pip resolve / native build phase of a
// dependency install), key off the output tail so new build lines count
// as progress — otherwise a long quiet build is falsely declared stale
// and restarted mid-build, looping forever (#1568).
const curProgress = computeProgressSignal(_bytes, _dlAgg, lastPct, snapshot);
const _fetchPctMatches = [...snapshot.matchAll(/Fetching\s+\d+\s+files:\s*(\d+)%/g)];
const _fetchPct = _fetchPctMatches.length ? parseInt(_fetchPctMatches[_fetchPctMatches.length - 1][1]) : null;
const _startupStalled = !_bytes && ((_dlAgg === 0) || (_fetchPct === 0)) && curProgress === '0';
const _STALE_TIMEOUT = _startupStalled ? STARTUP_STALE_PROGRESS_MS : STALE_PROGRESS_MS;
if (!el._lastProgress) { el._lastProgress = curProgress; el._lastProgressTime = Date.now(); }
if (curProgress !== el._lastProgress) {
el._lastProgress = curProgress;
@@ -2095,7 +2485,7 @@ async function _reconnectTask(el, task) {
} else if (Date.now() - (el._lastProgressTime || 0) > _STALE_TIMEOUT && !task._autoRestarted) {
task._autoRestarted = true;
_updateTask(task.sessionId, { _autoRestarted: true });
badge.textContent = 'stale — restarting';
badge.textContent = _startupStalled ? '0% stall — retrying' : 'stale — restarting';
badge.className = 'cookbook-task-status cookbook-task-error';
_showCookbookNotif(true);
try {
@@ -2139,14 +2529,37 @@ async function _reconnectTask(el, task) {
break;
}
// When the snapshot includes a shard-of-N marker (e.g.
// "model-00006-of-00082.safetensors"), TRUE overall progress is
// ((shard-1) + currentShardFraction) / totalShards. Before, _dlAgg
// (hf_transfer's per-current-shard aggregate, e.g. 53% of shard 6)
// was treated as overall and the row read "53%" while only 5 of
// 82 shards were actually done.
const _shardPat = [...snapshot.matchAll(/model-(\d+)-of-(\d+)\.(?:safetensors|bin)/g)];
const _lastShard = _shardPat.length ? _shardPat[_shardPat.length - 1] : null;
const _curShardNum = _lastShard ? parseInt(_lastShard[1], 10) : null;
const _totalShards = _lastShard ? parseInt(_lastShard[2], 10) : null;
const _useShardAgg = _curShardNum && _totalShards && _totalShards > 1;
// HF's own "Fetching N files: X%" aggregate counts ALL files,
// including ones already finished in a previous session (resume) —
// so on a resumed download it reflects the true overall progress,
// whereas completed/totalFiles only see this session's files (→ 0%).
// Take the higher of the two so resume doesn't read as 0%.
const _fetchPctMatches = [...snapshot.matchAll(/Fetching\s+\d+\s+files:\s*(\d+)%/g)];
const _fetchPct = _fetchPctMatches.length ? parseInt(_fetchPctMatches[_fetchPctMatches.length - 1][1]) : null;
if (_dlAgg != null) {
if (_useShardAgg) {
// Multi-shard download: compute TRUE overall as completed shards
// plus the current shard's fraction. _dlAgg / lastPct represent
// *this shard's* progress, not the whole download.
const curShardFrac = (_dlAgg != null)
? _dlAgg / 100
: (lastPct ? parseInt(lastPct, 10) / 100 : 0);
let overallPct = Math.round((((_curShardNum - 1) + curShardFrac) / _totalShards) * 100);
if (_fetchPct != null) overallPct = Math.max(overallPct, _fetchPct);
let text = `${overallPct}%`;
if (lastSpeed) text += ` · ${lastSpeed}`;
badge.textContent = text;
badge.className = 'cookbook-task-status cookbook-task-running';
} else if (_dlAgg != null) {
// Real aggregate byte progress — most accurate; take the max of all signals.
let pct = _dlAgg;
if (_fetchPct != null) pct = Math.max(pct, _fetchPct);
@@ -2182,7 +2595,7 @@ async function _reconnectTask(el, task) {
const _accessDenied = /Access to model.*is restricted|gated repo|GatedRepoError|401 Unauthorized|403 Forbidden|not in the authorized list|awaiting a review|must (?:be authenticated|have access)/i.test(snapshot);
const _dlKey = task.payload?.repo_id || task.name;
const _dlN = _dlRetryCount.get(_dlKey) || 0;
if (!_accessDenied && task.type === 'download' && task.payload && _dlN < _DL_MAX_AUTO_RETRY) {
if (!_accessDenied && !task._userStopped && task.type === 'download' && task.payload && _dlN < _DL_MAX_AUTO_RETRY) {
// Auto-retry: kill the dead session and re-launch (resumes from
// the cached .incomplete files) after a short delay.
_dlRetryCount.set(_dlKey, _dlN + 1);
@@ -2297,8 +2710,7 @@ async function _reconnectTask(el, task) {
// first one's dedup check can observe the newly-added row.
if (task.type === 'serve' && !task._endpointAdded && !task._endpointAddInFlight && task._serveReady) {
task._endpointAddInFlight = true;
const rawHost = task.remoteHost || 'localhost';
let host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost;
let host = _connectHostFromRemote(task.remoteHost);
const portMatch = task.payload?._cmd?.match(/--port[=\s]+(\d+)/)
|| task.payload?._cmd?.match(/(?:^|\s)-p[=\s]+(\d+)/)
|| snapshot.match(/Uvicorn running on\D*?:(\d+)/i)
@@ -2309,12 +2721,8 @@ async function _reconnectTask(el, task) {
let baseUrl = `http://${host}:${port}/v1`;
const ollamaUrlMatch = snapshot.match(/Ollama API ready on port\s+\d+:\s*(http:\/\/[^\s]+)/i);
if (ollamaUrlMatch) {
try {
const u = new URL(ollamaUrlMatch[1]);
host = u.hostname || host;
port = u.port || '11434';
baseUrl = `${u.origin}/v1`;
} catch {}
const endpoint = _endpointFromAdvertisedUrl(ollamaUrlMatch[1], host, '11434');
if (endpoint) ({ host, port, baseUrl } = endpoint);
}
fetch('/api/model-endpoints', { credentials: 'same-origin' })
.then(r => r.json())
@@ -2342,6 +2750,7 @@ async function _reconnectTask(el, task) {
fd.append('base_url', baseUrl);
fd.append('name', task.name);
fd.append('skip_probe', 'true');
_appendCookbookEndpointScope(fd, task.remoteHost || '');
if (_isDiffusion) fd.append('model_type', 'image');
return fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
})
@@ -2445,8 +2854,7 @@ async function _checkServeReachability() {
]);
} catch { return; }
for (const task of serveTasks) {
const rawHost = task.remoteHost || 'localhost';
const host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost;
const host = _connectHostFromRemote(task.remoteHost);
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
const port = portMatch ? portMatch[1] : '8000';
const baseUrl = `http://${host}:${port}/v1`;
@@ -2641,6 +3049,52 @@ async function _pollBackgroundStatus() {
const data = await res.json();
const tasks = data.tasks || [];
// Reconcile the authoritative tmux/process status back into the persisted
// client task list. The Running-tab reconnect loop also does this, but it
// only exists while cards are rendered; after a page refresh or closed modal
// dependency installs could finish server-side while localStorage stayed
// stuck at "running".
try {
const statusById = new Map(tasks.map(t => [t.session_id, t]));
const localTasks = _loadTasks();
let changed = false;
const completedDeps = [];
for (const task of localTasks) {
const live = statusById.get(task.sessionId);
if (!live) continue;
const updates = {};
const nextStatus = live.status === 'completed'
? 'done'
: (live.status === 'error'
? 'error'
: (live.status === 'stopped' ? (task.type === 'download' ? 'crashed' : 'stopped') : null));
if (nextStatus && task.status !== nextStatus) {
updates.status = nextStatus;
if (nextStatus === 'done' && task.payload?._dep) completedDeps.push(task);
}
if ((live.status === 'running' || live.status === 'ready') && task.status !== live.status) {
updates.status = live.status === 'ready' ? 'ready' : 'running';
}
if (live.progress && live.progress !== task.progress) updates.progress = live.progress;
if (live.output_tail) {
const previous = String(task.output || '');
const tail = String(live.output_tail || '');
if (tail && !previous.endsWith(tail)) {
updates.output = `${previous ? `${previous}\n` : ''}${tail}`.slice(-5000);
}
}
if (Object.keys(updates).length) {
Object.assign(task, updates);
changed = true;
}
}
if (changed) {
_saveTasks(localTasks);
_renderRunningTab();
completedDeps.forEach(t => _refreshDepsAfterInstall(t));
}
} catch (_) { /* non-fatal: background status should never break polling */ }
const statusEl = document.getElementById('cookbook-bg-status');
const activeTasks = tasks.filter(t => t.status === 'running' || t.status === 'ready');
const errorTasks = tasks.filter(t => t.status === 'error');
@@ -2653,8 +3107,7 @@ async function _pollBackgroundStatus() {
const localTask = localTasks.find(lt => lt.sessionId === t.session_id);
if (localTask && localTask._endpointAdded) continue;
const rawHost = localTask?.remoteHost || t.remote || 'localhost';
let host = rawHost.includes('@') ? rawHost.split('@').pop() : (rawHost === 'local' ? 'localhost' : rawHost);
let host = _connectHostFromRemote(localTask?.remoteHost || t.remote);
const portMatch = localTask?.payload?._cmd?.match(/--port\s+(\d+)/)
|| localTask?.payload?._cmd?.match(/OLLAMA_HOST=[^\s:]+:(\d+)/);
let port = portMatch ? portMatch[1] : '8000';
@@ -2662,12 +3115,8 @@ async function _pollBackgroundStatus() {
const snapshot = t.output || localTask?.output || '';
const ollamaUrlMatch = snapshot.match(/Ollama API ready on port\s+\d+:\s*(http:\/\/[^\s]+)/i);
if (ollamaUrlMatch) {
try {
const u = new URL(ollamaUrlMatch[1]);
host = u.hostname || host;
port = u.port || '11434';
baseUrl = `${u.origin}/v1`;
} catch {}
const endpoint = _endpointFromAdvertisedUrl(ollamaUrlMatch[1], host, '11434');
if (endpoint) ({ host, port, baseUrl } = endpoint);
}
const _isDiffusion = localTask?.payload?._cmd?.includes('diffusion_server');
@@ -2698,6 +3147,7 @@ async function _pollBackgroundStatus() {
fd.append('base_url', baseUrl);
fd.append('name', t.model);
fd.append('skip_probe', 'true');
_appendCookbookEndpointScope(fd, localTask?.remoteHost || t.remote || '');
if (_isDiffusion) fd.append('model_type', 'image');
if (_supportsTools) fd.append('supports_tools', 'true');
return fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
+477 -20
View File
@@ -41,6 +41,48 @@ const SERVE_STATE_KEY = 'cookbook-serve-state';
let _cachedAllModels = [];
function _repoLooksAwqLike(model, repo) {
const q = String(model?.quant || '').toUpperCase();
const n = `${repo || ''} ${model?.repo_id || ''} ${model?.name || ''} ${model?.path || ''}`.toLowerCase();
return /^AWQ|^GPTQ/.test(q) || q === 'FP8' || /\b(awq|gptq|fp8)\b/i.test(n);
}
function _repoLooksGgufLike(model, repo) {
const q = String(model?.quant || '').toUpperCase();
const n = `${repo || ''} ${model?.repo_id || ''} ${model?.name || ''} ${model?.path || ''}`.toLowerCase();
return !!model?.is_gguf || /^Q[2-8]/.test(q) || /^IQ/.test(q) || q === 'GGUF' || n.includes('gguf');
}
function _serveBackendWarning(model, repo, backend, fields = {}) {
const awqLike = _repoLooksAwqLike(model, repo);
const ggufLike = _repoLooksGgufLike(model, repo);
if (awqLike && (backend === 'llamacpp' || backend === 'ollama')) {
return {
title: 'AWQ needs vLLM or SGLang',
body: 'This model looks like AWQ/GPTQ/FP8 safetensors. llama.cpp and Ollama need GGUF files, so this backend cannot serve it. Choose vLLM/SGLang on a CUDA/ROCm GPU server, or download a GGUF version for llama.cpp/Ollama.',
};
}
if (awqLike && _isMetal() && (backend === 'vllm' || backend === 'sglang')) {
return {
title: 'AWQ is not a unified-memory path',
body: 'This model looks like AWQ/GPTQ/FP8 safetensors. AWQ is for vLLM/SGLang on CUDA/ROCm-style GPU servers, not local unified-memory llama.cpp/Ollama serving. For unified memory, download a GGUF model and use llama.cpp/Ollama.',
};
}
if (awqLike && fields.unified_mem) {
return {
title: 'AWQ is not a unified-memory path',
body: 'This model looks like AWQ/GPTQ/FP8 safetensors, but unified-memory local serving expects GGUF. Use vLLM/SGLang on a compatible GPU server, or download a GGUF version for llama.cpp/Ollama.',
};
}
if (ggufLike && (backend === 'vllm' || backend === 'sglang')) {
return {
title: 'GGUF needs llama.cpp or Ollama',
body: 'This model looks like GGUF. vLLM/SGLang expect HuggingFace safetensors-style repos. Choose llama.cpp/Ollama for GGUF, or download a safetensors model for vLLM/SGLang.',
};
}
return null;
}
function _hasOwn(obj, key) {
return Object.prototype.hasOwnProperty.call(obj || {}, key);
}
@@ -51,6 +93,67 @@ function _allGpuIds(count) {
return Array.from({ length: Math.floor(n) }, (_, i) => String(i)).join(',');
}
function _selectedServeTarget(panel) {
const select = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server');
const servers = Array.isArray(_envState.servers) ? _envState.servers : [];
let host = _envState.remoteHost || '';
let server = host ? servers.find(s => s.host === host) : null;
if (select && select.value != null) {
if (select.value === 'local') {
host = '';
server = servers.find(s => !s.host || s.host === 'local') || null;
} else {
const idx = /^\d+$/.test(String(select.value)) ? parseInt(select.value, 10) : -1;
server = servers.find(s => s.host === select.value) || (idx >= 0 ? servers[idx] : null) || null;
host = server?.host || '';
}
}
const venv = panel?.querySelector('[data-field="venv"]')?.value?.trim() || server?.envPath || _envState.envPath || '';
const label = host
? (server?.name ? `${server.name} (${host})` : host)
: (server?.name || 'local server');
return {
host,
port: host ? (_getPort(host) || server?.port || '') : '',
venv,
label,
};
}
async function _fetchServeRuntimePackage(panel, backend) {
const packageByBackend = {
vllm: 'vllm',
sglang: 'sglang',
llamacpp: 'llama_cpp',
diffusers: 'diffusers',
};
const packageName = packageByBackend[backend];
if (!packageName) return null;
const target = _selectedServeTarget(panel);
const params = new URLSearchParams();
if (target.host) {
params.set('host', target.host);
if (target.port) params.set('ssh_port', target.port);
if (target.venv) params.set('venv', target.venv);
}
const res = await fetch('/api/cookbook/packages' + (params.toString() ? '?' + params.toString() : ''), { credentials: 'same-origin' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const pkg = (data.packages || []).find(p => p.name === packageName);
return { pkg, target };
}
function _runtimeNoteText(backend, pkg, target) {
const labels = { vllm: 'vLLM', sglang: 'SGLang', llamacpp: 'llama.cpp', diffusers: 'Diffusers' };
const label = labels[backend] || backend;
if (!pkg) return `${label} readiness unavailable for ${target.label}.`;
const note = pkg.status_note || pkg.update_note || '';
if (pkg.installed) {
return note ? `${label} ready on ${target.label}: ${note}` : `${label} ready on ${target.label}.`;
}
return note ? `${label} missing on ${target.label}: ${note}` : `${label} missing on ${target.label}.`;
}
// ── Filter/sort cached model list ──
function _filterCachedList() {
@@ -99,6 +202,64 @@ function _isActivelyServing(repoId) {
} catch { return false; }
}
function _formatGgufSize(bytes) {
const n = Number(bytes || 0);
if (!Number.isFinite(n) || n <= 0) return '';
if (n >= 1024 ** 3) return `${(n / (1024 ** 3)).toFixed(1)} GB`;
if (n >= 1024 ** 2) return `${Math.round(n / (1024 ** 2))} MB`;
return `${Math.max(1, Math.round(n / 1024))} KB`;
}
function _ggufFilesForModel(model) {
return Array.isArray(model?.gguf_files)
? model.gguf_files.filter(f => f && typeof f.rel_path === 'string' && f.rel_path)
: [];
}
function _runnableGgufFiles(model) {
const files = _ggufFilesForModel(model);
const primary = files.filter(f => (f.role || 'model') === 'model');
return primary.length ? primary : files;
}
function _ggufFileLabel(file) {
const base = (file.name || file.rel_path || '').split('/').pop();
const size = _formatGgufSize(file.size_bytes);
const quant = file.quant ? `${file.quant} ` : '';
const parts = Number(file.parts || 0);
const split = parts > 1 ? `, ${parts} parts` : '';
const role = file.role && file.role !== 'model' ? ` ${file.role}` : '';
return `${quant}${base}${size || split ? ` (${[size, split.replace(/^, /, '')].filter(Boolean).join(', ')})` : ''}${role}`;
}
function _shellPathExpr(path) {
const s = String(path || '');
if (s === '~') return '${HOME}';
if (s.startsWith('~/')) return '${HOME}' + _shellQuote(s.slice(1));
return _shellQuote(s);
}
function _selectedGgufExpr(model, repo, relPath) {
const rel = String(relPath || '').replace(/^\/+/, '');
if (!rel) return '';
if (model.is_local_dir && model.path) {
const base = String(model.path || '').replace(/\/+$/, '');
return `$(printf %s ${_shellPathExpr(`${base}/${repo}/${rel}`)})`;
}
if (model.path) {
const base = String(model.path || '').replace(/\/+$/, '');
return `$(printf %s ${_shellPathExpr(`${base}/models--${repo.replace(/\//g, '--')}/snapshots/${rel}`)})`;
}
const cacheRepo = repo.replace(/\//g, '--');
return `$(printf %s \${HOME}${_shellQuote(`/.cache/huggingface/hub/models--${cacheRepo}/snapshots/${rel}`)})`;
}
function _ggufSearchDirExpr(model, repo) {
if (model.is_local_dir && model.path) return _shellQuote(`${String(model.path || '').replace(/\/+$/, '')}/${repo}`);
if (model.path) return _shellQuote(`${String(model.path || '').replace(/\/+$/, '')}/models--${repo.replace(/\//g, '--')}/snapshots`);
return `"$HOME/.cache/huggingface/hub/models--${repo.replace(/\//g, '--')}/snapshots"`;
}
function _rerenderCachedModels() {
const list = document.getElementById('hwfit-cached-list');
const tagContainer = document.getElementById('serve-tags');
@@ -131,6 +292,8 @@ function _rerenderCachedModels() {
if (m.path) {
metaParts.push(`<span style="opacity:0.7;">${esc(m.path)}</span>`);
}
const ggufCount = _runnableGgufFiles(m).length;
if (ggufCount > 1) metaParts.push(`${ggufCount} GGUFs`);
if (m.status === 'downloading') {
const _active = _isActivelyDownloading(m.repo_id);
metaParts.push(`<span class="cookbook-dl-status" style="color:var(--accent,var(--red));">${_active ? 'downloading' : 'download stalled'}</span>`);
@@ -307,7 +470,9 @@ function _rerenderCachedModels() {
// Toggle — close if already open
if (item.classList.contains('doclib-card-expanded')) {
item.querySelector('.hwfit-serve-panel')?.remove();
const existingPanel = item.querySelector('.hwfit-serve-panel');
existingPanel?._cleanupRuntimeReadiness?.();
existingPanel?.remove();
item.classList.remove('doclib-card-expanded');
item.style.flexDirection = '';
item.style.alignItems = '';
@@ -318,18 +483,14 @@ function _rerenderCachedModels() {
// Collapse any other expanded
list.querySelectorAll('.doclib-card-expanded').forEach(c => {
c.querySelector('.hwfit-serve-panel')?.remove();
const openPanel = c.querySelector('.hwfit-serve-panel');
openPanel?._cleanupRuntimeReadiness?.();
openPanel?.remove();
c.classList.remove('doclib-card-expanded');
c.style.flexDirection = '';
c.style.alignItems = '';
});
// Capture grid height
const _tb = list.closest('.admin-card')?.querySelector('.memory-toolbar');
const _tbH = _tb ? _tb.offsetHeight : 0;
list.style.minHeight = (list.offsetHeight + _tbH) + 'px';
list.style.maxHeight = (list.offsetHeight + _tbH) + 'px';
const shortName = repo.split('/').pop();
const _es = _envState;
// The venv set per-server in Settings (server.envPath). Used as the venv
@@ -350,8 +511,13 @@ function _rerenderCachedModels() {
? _byRepo[repo]
: (_lastUsed || (_isLegacyFlat ? _allSs : {}));
const detectedBackend = _detectBackend(m).backend;
const defaultBackend = detectedBackend;
const savedMatchesBackend = (ss.backend || 'vllm') === detectedBackend;
const _allowedBackends = new Set(_isWindows()
? ['llamacpp']
: (_isMetal() ? ['llamacpp', 'ollama'] : ['vllm', 'sglang', 'llamacpp', 'ollama', 'diffusers']));
const defaultBackend = (ss._forceBackend && ss.backend && _allowedBackends.has(ss.backend))
? ss.backend
: detectedBackend;
const savedMatchesBackend = !!ss._forceBackend || (ss.backend || 'vllm') === detectedBackend;
const sv = (k, def) => (ss[k] !== undefined && savedMatchesBackend) ? ss[k] : def;
const defaultTp = defaultBackend === 'llamacpp' ? '1' : sv('tp', '1');
const detectedGpuIds = _allGpuIds(_getGpuToggleTotal?.());
@@ -362,7 +528,16 @@ function _rerenderCachedModels() {
: (_es.gpus || detectedGpuIds));
const tpOpts = [1,2,4,8].map(n => `<option${defaultTp==String(n)?' selected':''}>${n}</option>`).join('');
const dtypeOpts = ['auto','float16','bfloat16'].map(d => `<option value="${d}"${sv('dtype','auto')===d?' selected':''}>${d}</option>`).join('');
const vllmKvCacheOpts = ['auto','fp8'].map(d => `<option value="${d}"${sv('vllm_kv_cache_dtype','auto')===d?' selected':''}>${d}</option>`).join('');
const _l = (name, tip) => `<span>${name}<span class="hwfit-hint" title="${tip}">?</span></span>`;
const _ggufChoices = _runnableGgufFiles(m);
const _savedGguf = String(sv('gguf_file', '') || '');
const _defaultGguf = _ggufChoices.some(f => f.rel_path === _savedGguf)
? _savedGguf
: (_ggufChoices[0]?.rel_path || '');
const _ggufOptions = _ggufChoices.map(f =>
`<option value="${esc(f.rel_path)}"${f.rel_path === _defaultGguf ? ' selected' : ''}>${esc(_ggufFileLabel(f))}</option>`
).join('');
// Build save slots
const _allPresets = _loadPresets();
const _repoShort = repo.split('/').pop();
@@ -372,10 +547,16 @@ function _rerenderCachedModels() {
// load, × to delete) plus a "Save current config" row — see _showSavedConfigMenu.
// Split button: "Save" saves the current config directly; the arrow opens
// the dropdown of saved configs (load / delete). Arrow shows the count.
// The arrow button shows just the saved-config count next to a "▾".
// Spell out what the number means in the tooltip so users don't have
// to click it to find out the badge isn't a notification dot.
const _arrowLabel = _modelPresets.length > 0 ? `${_modelPresets.length}` : '▾';
const _arrowTitle = _modelPresets.length > 0
? `${_modelPresets.length} saved launch config${_modelPresets.length === 1 ? '' : 's'} for ${_repoShort} — click ▾ to load or delete`
: `No saved launch configs for ${_repoShort} yet — click Save to add one`;
let _slotsHtml = `<div class="cookbook-serve-slots cookbook-saved-split">`
+ `<button type="button" class="cookbook-slot-btn cookbook-saved-save" title="Save current config"><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="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>`
+ `<button type="button" class="cookbook-slot-btn cookbook-saved-arrow" title="Saved launch configs">${_arrowLabel}</button>`
+ `<button type="button" class="cookbook-slot-btn cookbook-saved-arrow" title="${esc(_arrowTitle)}">${_arrowLabel}</button>`
+ `</div>`;
let panelHtml = `<div class="hwfit-serve-panel">${_slotsHtml}`;
@@ -403,6 +584,14 @@ function _rerenderCachedModels() {
}
panelHtml += `<label>${_l('GPUs','Toggle which GPUs to use')}<div class="cookbook-gpu-group">${_gpuBtnsHtml}</div><input type="hidden" class="hwfit-sf" data-field="gpus" value="${esc(defaultGpus)}" /></label>`;
panelHtml += `</div>`;
panelHtml += `<div class="hwfit-serve-runtime-note" style="display:none;font-size:11px;line-height:1.35;color:var(--fg-muted);margin-top:-4px;"></div>`;
if (_ggufChoices.length > 1) {
panelHtml += `<div class="hwfit-serve-row hwfit-backend-llamacpp">`;
panelHtml += `<label class="hwfit-backend-llamacpp">${_l('GGUF File','Choose the exact GGUF artifact to serve from this cached model folder.')}<select class="hwfit-sf hwfit-sf-wide" data-field="gguf_file">${_ggufOptions}</select></label>`;
panelHtml += `</div>`;
} else if (_defaultGguf) {
panelHtml += `<input type="hidden" class="hwfit-sf" data-field="gguf_file" value="${esc(_defaultGguf)}" />`;
}
// Row 2: Core settings
panelHtml += `<div class="hwfit-serve-row hwfit-backend-vllm hwfit-backend-sglang hwfit-backend-llamacpp">`;
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('TP','Tensor Parallelism — split model across N GPUs')}<select class="hwfit-sf" data-field="tp">${tpOpts}</select></label>`;
@@ -414,6 +603,7 @@ function _rerenderCachedModels() {
panelHtml += `<label class="hwfit-backend-vllm">${_l('Swap','CPU swap space in GB. Leave empty to omit (removed in newer vLLM)')}<input type="text" class="hwfit-sf" data-field="swap" value="${esc(sv('swap', ''))}" placeholder="off" /></label>`;
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('Max Seqs','Maximum concurrent requests. Lower = less memory. Default 8 — prosumer GPUs often OOM on vLLM default 256 during CUDA graph capture.')}<input type="text" class="hwfit-sf" data-field="max_seqs" value="${esc(sv('max_seqs', '8'))}" placeholder="8" /></label>`;
panelHtml += `<label>${_l('Dtype','Data type for weights. auto picks best for GPU')}<select class="hwfit-sf" data-field="dtype">${dtypeOpts}</select></label>`;
panelHtml += `<label class="hwfit-backend-vllm">${_l('KV Cache','vLLM --kv-cache-dtype. auto uses the model/runtime default; fp8 reduces KV memory for long context.')}<select class="hwfit-sf" data-field="vllm_kv_cache_dtype">${vllmKvCacheOpts}</select></label>`;
panelHtml += `</div>`;
// Row 2b: Diffusers settings
const diffDtypeOpts = ['bfloat16','float16','float32'].map(d => `<option value="${d}"${sv('diff_dtype','bfloat16')===d?' selected':''}>${d}</option>`).join('');
@@ -432,9 +622,47 @@ function _rerenderCachedModels() {
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="prefix_cache"${sv('prefix_cache',false)?' checked':''} /> Prefix Caching${_h('Cache shared prompt prefixes across requests')}</label>`;
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm"><input type="checkbox" class="hwfit-sf" data-field="auto_tool"${sv('auto_tool',false)?' checked':''} /> Auto Tool Choice${_h('Enable function/tool calling for agent mode')}</label>`;
panelHtml += `</div>`;
// Row 2c: llama.cpp fit/perf flags (set by Auto profiles, editable by hand)
const _kvOpts = ['', 'q4_0', 'q8_0', 'f16'].map(k => `<option value="${k}"${sv('cache_type','')===k?' selected':''}>${k||'default'}</option>`).join('');
const llamaFitOpts = ['', 'off', 'on'].map(d => `<option value="${d}"${sv('llama_fit','')===d?' selected':''}>${d||'default'}</option>`).join('');
const llamaSplitModeOpts = ['', 'layer', 'tensor', 'row', 'none'].map(d => `<option value="${d}"${sv('llama_split_mode','')===d?' selected':''}>${d||'default'}</option>`).join('');
panelHtml += `<div class="hwfit-serve-row hwfit-backend-llamacpp">`;
panelHtml += `<label>${_l('CPU MoE','n-cpu-moe: number of MoE expert layers to run on CPU when the model is bigger than VRAM. 0 = all on GPU. Set automatically by the Auto profiles below.')}<input type="text" class="hwfit-sf" data-field="n_cpu_moe" value="${esc(sv('n_cpu_moe',''))}" placeholder="0" style="width:54px;" /></label>`;
panelHtml += `<label>${_l('KV Cache','cache-type-k/v: quantize the KV cache. q4_0 = smallest (more context), q8_0 = sharp long-context, f16 = full. Blank = llama.cpp default.')}<select class="hwfit-sf" data-field="cache_type">${_kvOpts}</select></label>`;
panelHtml += `<label class="hwfit-sf-cb" style="align-self:end;"><input type="checkbox" class="hwfit-sf" data-field="flash_attn"${sv('flash_attn',false)?' checked':''} /> Flash Attn${_h('--flash-attn on: faster attention + needed for quantized KV cache.')}</label>`;
panelHtml += `<label class="hwfit-sf-cb" style="align-self:end;"><input type="checkbox" class="hwfit-sf" data-field="vision"${sv('vision',false)?' checked':''} /> Vision${_h('Serve with the vision encoder so the model can read images. Auto-finds an mmproj-*.gguf next to the model (download one into the model folder). Adds ~1 GB VRAM + a small per-image cost.')}</label>`;
panelHtml += `<label>${_l('Fit','llama.cpp --fit. Leave default unless you need explicit off/on behavior for a preset.')}<select class="hwfit-sf" data-field="llama_fit">${llamaFitOpts}</select></label>`;
panelHtml += `</div>`;
// Row 2d: native llama-server placement/runtime controls. These are
// explicit overrides for known-good advanced presets; blank keeps
// llama.cpp/profile defaults.
panelHtml += `<div class="hwfit-serve-row hwfit-backend-llamacpp">`;
panelHtml += `<label>${_l('Split Mode','llama.cpp GPU placement. layer is the usual default; tensor splits weights and KV across GPUs.')}<select class="hwfit-sf" data-field="llama_split_mode">${llamaSplitModeOpts}</select></label>`;
panelHtml += `<label>${_l('Tensor Split','GPU proportions for llama.cpp, e.g. 50,50 across two visible GPUs. Leave blank for auto.')}<input type="text" class="hwfit-sf" data-field="llama_tensor_split" value="${esc(sv('llama_tensor_split', ''))}" placeholder="50,50" /></label>`;
panelHtml += `<label>${_l('Main GPU','llama.cpp --main-gpu index inside the visible GPU set. Mostly useful for split mode none/row.')}<input type="text" class="hwfit-sf" data-field="llama_main_gpu" value="${esc(sv('llama_main_gpu', ''))}" placeholder="auto" /></label>`;
panelHtml += `<label>${_l('Parallel','llama.cpp parallel slots. Leave blank for llama.cpp default; 1 matches single-lane presets.')}<input type="text" class="hwfit-sf" data-field="llama_parallel" value="${esc(sv('llama_parallel', ''))}" placeholder="1" /></label>`;
panelHtml += `<label>${_l('Batch','llama.cpp prompt batch size. Leave blank for llama.cpp default.')}<input type="text" class="hwfit-sf" data-field="llama_batch_size" value="${esc(sv('llama_batch_size', ''))}" placeholder="2048" /></label>`;
panelHtml += `<label>${_l('UBatch','llama.cpp physical micro-batch size. Leave blank for llama.cpp default.')}<input type="text" class="hwfit-sf" data-field="llama_ubatch_size" value="${esc(sv('llama_ubatch_size', ''))}" placeholder="512" /></label>`;
panelHtml += `</div>`;
// Row 2d: Auto profiles — computed from detected hardware (see profiles.py).
// Buttons are injected after the panel mounts (needs an async fetch).
panelHtml += `<div class="hwfit-serve-row hwfit-backend-llamacpp hwfit-serve-profiles" style="align-items:center;gap:8px;">`;
panelHtml += `<span style="opacity:0.7;font-size:11px;">Auto profiles:</span>`;
panelHtml += `<span class="hwfit-profile-btns" style="display:flex;gap:6px;flex-wrap:wrap;"><span style="opacity:0.5;font-size:11px;">computing…</span></span>`;
panelHtml += `</div>`;
// Live VRAM / RAM-spillover monitor for the serve target's GPU. Polls
// /api/cookbook/gpus while the panel is open so you can SEE whether the
// config fits VRAM (fast) or spills to system RAM (slow). Populated after mount.
panelHtml += `<div class="hwfit-serve-row hwfit-backend-llamacpp hwfit-vram-monitor" style="align-items:center;gap:8px;font-size:11px;">`;
panelHtml += `<span style="opacity:0.7;">GPU memory:</span>`;
panelHtml += `<span class="hwfit-vram-readout" style="opacity:0.5;">checking…</span>`;
panelHtml += `</div>`;
// Row 3a: Checkboxes (llama.cpp-only)
panelHtml += `<div class="hwfit-serve-checks hwfit-backend-llamacpp">`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="unified_mem"${sv('unified_mem',false)?' checked':''} /> Unified Memory${_h('For AMD APUs / Strix Halo: exports GGML_CUDA_ENABLE_UNIFIED_MEMORY=1 so llama.cpp can address the full BIOS VRAM carveout instead of the default ~28 GB cap. No-op on discrete GPUs.')}</label>`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="llama_no_mmap"${sv('llama_no_mmap',false)?' checked':''} /> No mmap${_h('Adds --no-mmap for native llama-server. Useful for some high-context/local-storage setups, but not a universal default.')}</label>`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="llama_no_warmup"${sv('llama_no_warmup',false)?' checked':''} /> Skip warmup${_h('Adds --no-warmup. Can reduce startup memory spikes for tight launches, but llama.cpp defaults to warming up.')}</label>`;
panelHtml += `<label class="hwfit-sf-cb hwfit-spec-group"><input type="checkbox" class="hwfit-sf" data-field="llama_speculative_mtp"${sv('llama_speculative_mtp',false)?' checked':''} /> MTP Spec${_h('llama.cpp native MTP speculative decoding: --spec-type draft-mtp. Requires a GGUF with MTP heads and a recent llama-server build.')} <span class="hwfit-numstep"><button type="button" class="hwfit-numstep-btn" data-step="-1" tabindex="-1" aria-label="Decrease"></button><input type="number" class="hwfit-sf hwfit-spec-tokens" data-field="llama_spec_tokens" value="${esc(sv('llama_spec_tokens', '3'))}" min="1" max="10" title="--spec-draft-n-max" /><button type="button" class="hwfit-numstep-btn" data-step="1" tabindex="-1" aria-label="Increase"></button></span></label>`;
panelHtml += `</div>`;
// Row 3b: Checkboxes (diffusers)
panelHtml += `<div class="hwfit-serve-checks hwfit-backend-diffusers">`;
@@ -500,9 +728,10 @@ function _rerenderCachedModels() {
item.classList.add('doclib-card-expanded');
item.style.flexDirection = 'column';
item.style.alignItems = 'stretch';
if (list) list.scrollTop = 0;
item.insertAdjacentHTML('beforeend', panelHtml);
const panel = item.querySelector('.hwfit-serve-panel');
// Scroll the serve panel into view within its nearest scrollable ancestor
requestAnimationFrame(() => panel.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
// Build command preview
function updateCmd() {
@@ -514,19 +743,27 @@ function _rerenderCachedModels() {
const backend = f.backend || 'vllm';
const serveModel = m.is_local_dir && m.path ? `${m.path}/${repo}` : repo;
if (backend === 'llamacpp') {
const ggufChoices = _runnableGgufFiles(m);
const selectedGguf = ggufChoices.find(file => file.rel_path === f.gguf_file);
// For multi-part GGUFs, llama.cpp requires the first split
// (-00001-of-NNNNN.gguf). Prefer it (sorted, so UD-IQ4_XS/001 comes
// before Q4_K_M/001 etc); fall back to any single GGUF sorted.
// Use $HOME (not ~) so tilde survives variable interpolation inside $(...).
const dir = `"$HOME/.cache/huggingface/hub/models--${repo.replace(/\//g, '--')}/snapshots"`;
const dir = _ggufSearchDirExpr(m, repo);
// GGUF needs the actual .gguf FILE, not the folder. For a custom-dir
// model the file lives under "<path>/<repo>" — search there just like we
// search the HF snapshots dir, so serving a GGUF from a custom dir works
// instead of handing llama.cpp a directory (which fails).
const _ldir = `"${m.path}/${repo}"`;
f._gguf_path = m.is_local_dir && m.path
const _ldir = m.path ? _shellQuote(`${m.path}/${repo}`) : '""';
f._gguf_path = selectedGguf
? _selectedGgufExpr(m, repo, selectedGguf.rel_path)
: m.is_local_dir && m.path
? `$({ find ${_ldir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${_ldir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`
: `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`;
// Vision: auto-find the mmproj (CLIP/projector) file in the same dir.
// Resolved at runtime so the toggle just works if an mmproj-*.gguf is
// present (downloaded alongside the model). Empty if none → cmd omits it.
const _vsearchdir = (m.is_local_dir && m.path) ? _ldir : dir;
f._mmproj_path = `$(find ${_vsearchdir} -iname 'mmproj*.gguf' 2>/dev/null | sort | head -1)`;
}
if (f.reasoning_parser) {
const _rpEl2 = panel.querySelector('[data-field="reasoning_parser"]');
@@ -541,6 +778,151 @@ function _rerenderCachedModels() {
}
updateCmd();
// Context clamp. Two ceilings:
// - ABSOLUTE_CTX_MAX: a hard sanity cap (no LLM trains past ~1M tokens),
// so an obvious typo like 16000000 can never reach llama.cpp even when
// we don't know the model's real limit (not in catalog / profiles
// fetch failed). This is what stops the radv ErrorDeviceLost crash.
// - panel._modelCtxMax: the model's actual trained limit (set by the
// profiles fetch below) — a tighter, model-specific cap when known.
const ABSOLUTE_CTX_MAX = 1048576; // 1M tokens — above any real n_ctx_train
const _ctxEl0 = panel.querySelector('[data-field="ctx"]');
function _clampCtx(announce) {
if (!_ctxEl0) return;
const cap = panel._modelCtxMax > 0 ? panel._modelCtxMax : ABSOLUTE_CTX_MAX;
const v = parseInt(_ctxEl0.value, 10);
if (Number.isFinite(v) && v > cap) {
_ctxEl0.value = String(cap);
_ctxEl0.title = `Capped to ${panel._modelCtxMax > 0 ? "this model's trained limit" : "the maximum sane context"} (${cap}).`;
if (announce) uiModule.showToast(`Context capped to ${cap}`);
updateCmd();
}
}
if (_ctxEl0) {
_ctxEl0.addEventListener('change', () => _clampCtx(false));
_ctxEl0.addEventListener('blur', () => _clampCtx(false));
_clampCtx(false); // fix any stale/preset value already present
}
// Auto profiles — fetch hardware-computed llama.cpp profiles and render
// them as clickable chips. Clicking one fills the ctx/CPU-MoE/KV/flash
// fields and rebuilds the command. Computed from detected VRAM (see
// services/hwfit/profiles.py); rough on t/s, accurate on fit.
async function _loadServeProfiles() {
const wrap = panel.querySelector('.hwfit-profile-btns');
if (!wrap) return;
try {
const host = (_es.remoteHost || '').trim();
const params = new URLSearchParams({ model: repo });
if (host) {
params.set('host', host);
const _sp = (_es.servers || []).find(s => s.host === host)?.port;
if (_sp) params.set('ssh_port', _sp);
}
// SERVE mode: this is a specific GGUF file already on disk, so its quant
// is fixed — tell the profiler the file's real size + quant so it varies
// only the serving knobs (KV/ctx/offload), not the quant. Parse the size
// from m.size (e.g. "20.6 GB") and the quant from the file/repo name.
const _sizeMatch = String(m.size || '').match(/([\d.]+)\s*GB/i);
if (_sizeMatch) params.set('serve_weights_gb', _sizeMatch[1]);
const _qMatch = String(repo).match(/(Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP8)/i);
if (_qMatch) params.set('serve_quant', _qMatch[1]);
const res = await fetch(`/api/hwfit/profiles?${params}`);
const data = await res.json();
// Remember the model's trained context limit and clamp the ctx field
// to it — asking llama.cpp for ctx > n_ctx_train overflows and, with a
// quantized KV cache, can crash the GPU (radv ErrorDeviceLost).
const ctxMax = Number(data && data.model_ctx_max) || 0;
if (ctxMax > 0) {
panel._modelCtxMax = ctxMax; // tighten the clamp to the real limit
_clampCtx(false); // re-apply now that we know the model's max
}
const profs = (data && Array.isArray(data.profiles)) ? data.profiles : [];
if (!profs.length) { wrap.innerHTML = `<span style="opacity:0.5;font-size:11px;">no auto profile for this model</span>`; return; }
wrap.innerHTML = '';
for (const p of profs) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'cookbook-btn hwfit-profile-chip';
b.style.cssText = 'height:24px;padding:0 9px;font-size:11px;';
const off = p.offloads ? `, ncm${p.n_cpu_moe}` : ', all-GPU';
b.textContent = `${p.label} · ${p.quant} · ${Math.round(p.ctx/1024)}k${off}`;
b.title = `${p.note}\nKV ${p.cache_type}, ~${p.est_vram_gb} GB VRAM`;
b.addEventListener('click', () => {
const set = (field, val) => {
const el = panel.querySelector(`[data-field="${field}"]`);
if (!el) return;
if (el.type === 'checkbox') el.checked = !!val; else el.value = val;
};
set('ctx', p.ctx);
set('n_cpu_moe', p.n_cpu_moe || '');
set('cache_type', p.cache_type || '');
set('flash_attn', true); // required for a quantized KV cache
wrap.querySelectorAll('.hwfit-profile-chip').forEach(x => x.classList.remove('cookbook-btn-active'));
b.classList.add('cookbook-btn-active');
updateCmd();
});
wrap.appendChild(b);
}
} catch {
wrap.innerHTML = `<span style="opacity:0.5;font-size:11px;">profile compute failed</span>`;
}
}
_loadServeProfiles();
// Live GPU-memory monitor: poll /api/cookbook/gpus and show VRAM usage +
// RAM-spillover, with a plain-language health/speed hint. Lets you tell at
// a glance whether the chosen config fits VRAM (fast) or is paging into
// system RAM over PCIe (slow). AMD sysfs reports gtt_used_mb for spillover.
async function _refreshVramMonitor() {
const el = panel.querySelector('.hwfit-vram-readout');
if (!el || !document.body.contains(el)) return false; // panel closed → stop
try {
const host = (_es.remoteHost || '').trim();
const params = new URLSearchParams();
if (host) {
params.set('host', host);
const _sp = (_es.servers || []).find(s => s.host === host)?.port;
if (_sp) params.set('ssh_port', _sp);
}
const res = await fetch('/api/cookbook/gpus' + (params.toString() ? '?' + params : ''));
const data = await res.json();
const gpus = Array.isArray(data) ? data : (data.gpus || []);
if (!gpus.length) { el.textContent = 'no GPU detected'; el.style.color = ''; return true; }
const g = gpus[0];
const usedG = (g.used_mb / 1024), totG = (g.total_mb / 1024);
const pct = totG ? Math.round((usedG / totG) * 100) : 0;
const freeG = Math.max(0, totG - usedG);
const spillG = (g.gtt_used_mb || 0) / 1024;
// Color: green < 85%, amber 85-97%, red > 97% or spilling.
const spilling = spillG > 0.5 && !g.unified_memory; // unified APUs always use GTT; not a spill
let color = 'var(--green, #50fa7b)';
if (pct >= 97 || spilling) color = 'var(--red, #ff5555)';
else if (pct >= 85) color = 'var(--orange, #ffb86c)';
let txt = `${usedG.toFixed(1)} / ${totG.toFixed(1)} GB (${pct}%) · ${freeG.toFixed(1)} GB free`;
if (spilling) {
txt += ` · ⚠ ${spillG.toFixed(1)} GB spilled to RAM — slow (raise CPU MoE or lower context)`;
} else if (pct >= 90) {
txt += ` · tight — risk of OOM/spill on long context or images`;
} else {
txt += ` · healthy`;
}
el.textContent = txt;
el.style.color = color;
return true;
} catch {
el.textContent = 'unavailable';
el.style.color = '';
return true;
}
}
_refreshVramMonitor();
// Poll every 4s while the panel is open; stop when it's removed from the DOM.
const _vramTimer = setInterval(async () => {
const ok = await _refreshVramMonitor();
if (ok === false) clearInterval(_vramTimer);
}, 4000);
// Show/hide backend-specific sections
function updateBackendVisibility() {
const b = panel.querySelector('[data-field="backend"]')?.value || 'vllm';
@@ -551,6 +933,38 @@ function _rerenderCachedModels() {
}
updateBackendVisibility();
async function updateRuntimeReadinessNote() {
const note = panel.querySelector('.hwfit-serve-runtime-note');
if (!note) return;
const backend = panel.querySelector('[data-field="backend"]')?.value || 'vllm';
if (!['vllm', 'sglang', 'llamacpp', 'diffusers'].includes(backend)) {
note.style.display = 'none';
note.textContent = '';
return;
}
const seq = (panel._runtimeReadinessSeq || 0) + 1;
panel._runtimeReadinessSeq = seq;
note.style.display = '';
note.textContent = 'Checking runtime on selected server...';
try {
const { pkg, target } = await _fetchServeRuntimePackage(panel, backend);
if (panel._runtimeReadinessSeq !== seq) return;
note.textContent = _runtimeNoteText(backend, pkg, target);
note.style.color = pkg?.installed ? 'var(--fg-muted)' : 'var(--red)';
} catch (err) {
if (panel._runtimeReadinessSeq !== seq) return;
note.textContent = `Runtime readiness unavailable: ${err?.message || err}`;
note.style.color = 'var(--fg-muted)';
}
}
updateRuntimeReadinessNote();
const runtimeServerSelect = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server');
if (runtimeServerSelect) {
const refreshRuntimeOnServerChange = () => updateRuntimeReadinessNote();
runtimeServerSelect.addEventListener('change', refreshRuntimeOnServerChange);
panel._cleanupRuntimeReadiness = () => runtimeServerSelect.removeEventListener('change', refreshRuntimeOnServerChange);
}
// Wire save slots
function _loadSlotIntoPanel(slotIdx) {
const presets = _loadPresets();
@@ -580,7 +994,17 @@ function _rerenderCachedModels() {
gpu_mem: _ex(/--gpu-memory-utilization\s+([\d.]+)/) || '0.90',
swap: _ex(/--swap-space\s+(\d+)/) || '',
dtype: _ex(/--dtype\s+(\w+)/) || 'auto',
vllm_kv_cache_dtype: _ex(/--kv-cache-dtype\s+([\w.-]+)/) || 'auto',
max_seqs: _ex(/--max-num-seqs\s+(\d+)/) || '',
cache_type: _ex(/(?:--cache-type-k|-ctk)\s+(\S+)/) || '',
llama_fit: _ex(/(?:--fit|-fit)\s+(on|off)/) || '',
llama_split_mode: _ex(/(?:--split-mode|-sm)\s+(none|layer|row|tensor)/) || '',
llama_tensor_split: _ex(/(?:--tensor-split|-ts)\s+([0-9.,]+)/) || '',
llama_main_gpu: _ex(/(?:--main-gpu|-mg)\s+(\d+)/) || '',
llama_parallel: _ex(/(?:--parallel|-np)\s+(\d+)/) || '',
llama_batch_size: _ex(/(?:--batch-size|-b)\s+(\d+)/) || '',
llama_ubatch_size: _ex(/(?:--ubatch-size|-ub)\s+(\d+)/) || '',
llama_spec_tokens: _ex(/--spec-draft-n-max\s+(\d+)/) || '3',
venv: p.envPath || '',
};
const checks = {
@@ -588,6 +1012,11 @@ function _rerenderCachedModels() {
trust_remote: cmd.includes('--trust-remote-code'),
prefix_cache: cmd.includes('--enable-prefix-caching'),
auto_tool: cmd.includes('--enable-auto-tool-choice'),
flash_attn: /--flash-attn\s+on\b/.test(cmd),
unified_mem: /GGML_CUDA_ENABLE_UNIFIED_MEMORY=1/.test(cmd),
llama_no_mmap: /--no-mmap\b/.test(cmd),
llama_no_warmup: /--no-warmup\b/.test(cmd),
llama_speculative_mtp: /--spec-type\s+\S*draft-mtp/.test(cmd),
speculative: cmd.includes('--speculative-config'),
};
const _specMatch = cmd.match(/--speculative-config\s+'?\{[^}]*"method"\s*:\s*"([^"]+)"[^}]*"num_speculative_tokens"\s*:\s*(\d+)/);
@@ -619,16 +1048,21 @@ function _rerenderCachedModels() {
const _gf = panel.querySelector('[data-field="gpus"]');
if (_gf) _gf.value = activeGpus.join(',');
updateBackendVisibility();
updateRuntimeReadinessNote();
updateCmd();
panel.querySelectorAll('.cookbook-slot-btn').forEach(b => b.classList.remove('active'));
panel.querySelector(`.cookbook-slot-btn[data-slot="${slotIdx}"]`)?.classList.add('active');
}
// Keep the arrow button's count in sync with the stored presets.
// Keep the arrow button's count + tooltip in sync with stored presets.
function _updateSavedToggleLabel() {
const n = _presetsForModel(_loadPresets(), repo).length;
const t = panel.querySelector('.cookbook-saved-arrow');
if (t) t.textContent = n > 0 ? `${n}` : '▾';
if (!t) return;
t.textContent = n > 0 ? `${n}` : '▾';
t.title = n > 0
? `${n} saved launch config${n === 1 ? '' : 's'} for ${_repoShort} — click ▾ to load or delete`
: `No saved launch configs for ${_repoShort} yet — click Save to add one`;
}
// Save the current panel fields as a new named preset (shared by the menu's
@@ -1154,6 +1588,10 @@ function _rerenderCachedModels() {
const extraEl = panel.querySelector('[data-field="extra"]');
if (extraEl) extraEl.value = '';
updateBackendVisibility();
updateRuntimeReadinessNote();
}
if (e.target.dataset.field === 'venv') {
updateRuntimeReadinessNote();
}
updateCmd();
});
@@ -1185,6 +1623,7 @@ function _rerenderCachedModels() {
// "back out" affordance next to Launch.
panel.querySelector('.hwfit-serve-cancel')?.addEventListener('click', (ev) => {
ev.stopPropagation();
panel._cleanupRuntimeReadiness?.();
panel.remove();
item.classList.remove('doclib-card-expanded');
item.style.flexDirection = '';
@@ -1195,6 +1634,12 @@ function _rerenderCachedModels() {
// Launch button
panel.querySelector('.hwfit-serve-launch').addEventListener('click', async (ev) => {
const _launchBtn = ev.currentTarget;
// Final safety net: never launch with ctx beyond the model's trained
// limit (or the absolute sanity ceiling when the limit is unknown). A
// stale preset or typo (e.g. 16000000) overflows and, with a quantized
// KV cache, can crash the GPU. Skip only if the user hand-edited the raw
// command (then we respect their literal text).
if (!_cmdManuallyEdited) _clampCtx(true);
if (!_cmdManuallyEdited) updateCmd();
const launchCmd = _cmdTextarea ? _cmdTextarea.value.trim() : panel._cmd;
const serveState = {};
@@ -1202,7 +1647,16 @@ function _rerenderCachedModels() {
if (el.type === 'checkbox') serveState[el.dataset.field] = el.checked;
else serveState[el.dataset.field] = el.value;
});
serveState.backend = (_detectBackend(m).backend) || serveState.backend || 'vllm';
serveState.backend = serveState.backend || (_detectBackend(m).backend) || 'vllm';
const backendWarning = _serveBackendWarning(m, repo, serveState.backend, serveState);
if (backendWarning) {
await window.styledConfirm(backendWarning.body, {
title: backendWarning.title,
confirmText: 'Edit settings',
cancelText: 'Close',
});
return;
}
// Save in the { _byRepo, _lastUsed } schema — no legacy flat keys at
// the root so per-model state doesn't leak between models.
try {
@@ -1515,7 +1969,10 @@ export async function _fetchCachedModels() {
const data = await res.json();
_dlWp.destroy();
const ready = data.models.filter(m => m.status === 'ready' && (m.backend === 'ollama' || !m.size.includes('MB')));
// CHANGELOG: 'ready' already excludes partial downloads;
// show every complete model regardless of size/backend.
const ready = data.models.filter(m => m.status === 'ready');
const downloading = data.models.filter(m => m.status === 'downloading');
const allModels = [...ready, ...downloading];
_cachedAllModels = allModels;
+338 -34
View File
@@ -29,6 +29,7 @@ import * as Modals from './modalManager.js';
let _htmlPreviewActive = false; // true when inline HTML preview iframe is showing
let _emailAccountsCache = null;
let _emailAccountsCacheAt = 0;
let _emailHeaderManualExpandUntil = 0;
// Diff mode state
let _diffModeActive = false;
@@ -152,6 +153,8 @@ import * as Modals from './modalManager.js';
addDocToTabs,
syncDocIndicator: _syncDocIndicator,
});
_maybeOpenDocFromHash();
window.addEventListener('hashchange', _maybeOpenDocFromHash);
}
/** Update overflow-doc-btn accent indicator, toolbar indicator, and session list icon */
@@ -2306,6 +2309,53 @@ import * as Modals from './modalManager.js';
return r && r.style.display !== 'none' ? r : null;
}
function _captureEmailBodyFocusState() {
const rich = _emailRichbodyActive();
const ta = document.getElementById('doc-editor-textarea');
const active = document.activeElement;
if (rich && (active === rich || rich.contains(active))) {
const sel = window.getSelection();
const range = sel && sel.rangeCount ? sel.getRangeAt(0) : null;
return {
type: 'rich',
range: range && rich.contains(range.commonAncestorContainer) ? range.cloneRange() : null,
};
}
if (ta && active === ta) {
return {
type: 'textarea',
start: ta.selectionStart,
end: ta.selectionEnd,
};
}
return null;
}
function _restoreEmailBodyFocusState(state) {
if (!state) return;
requestAnimationFrame(() => {
if (state.type === 'rich') {
const rich = _emailRichbodyActive();
if (!rich) return;
rich.focus({ preventScroll: true });
if (state.range) {
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
sel.addRange(state.range);
}
}
} else if (state.type === 'textarea') {
const ta = document.getElementById('doc-editor-textarea');
if (!ta) return;
ta.focus({ preventScroll: true });
if (Number.isFinite(state.start) && Number.isFinite(state.end)) {
try { ta.setSelectionRange(state.start, state.end); } catch (_) {}
}
}
});
}
function _stripEmailReplyQuoteText(text) {
const original = String(text || '');
if (!original) return { body: '', stripped: false };
@@ -2367,6 +2417,48 @@ import * as Modals from './modalManager.js';
}
}
function _syncEmailHeaderSummary() {
const to = document.getElementById('doc-email-to')?.value?.trim() || 'No recipient';
const subject = document.getElementById('doc-email-subject')?.value?.trim() || 'No subject';
const cc = document.getElementById('doc-email-cc')?.value?.trim() || '';
const bcc = document.getElementById('doc-email-bcc')?.value?.trim() || '';
const summary = document.getElementById('doc-email-collapse-summary');
if (!summary) return;
const extras = [];
if (cc) extras.push('Cc');
if (bcc) extras.push('Bcc');
summary.textContent = `${to} · ${subject}${extras.length ? ` · ${extras.join('/')}` : ''}`;
summary.title = summary.textContent;
}
function _setEmailHeaderCollapsed(collapsed, { manual = true } = {}) {
const header = document.getElementById('doc-email-header');
const btn = document.getElementById('doc-email-collapse-btn');
if (!header) return;
if (window.innerWidth > 768) collapsed = false;
header.classList.toggle('doc-email-header-collapsed', !!collapsed);
if (btn) {
btn.setAttribute('aria-expanded', String(!collapsed));
btn.title = collapsed ? 'Show email fields' : 'Hide email fields';
}
const doc = activeDocId && docs.get(activeDocId);
if (doc && manual) doc._emailHeaderCollapsed = !!collapsed;
if (manual && !collapsed) _emailHeaderManualExpandUntil = Date.now() + 1400;
_syncEmailHeaderSummary();
}
function _shouldAutoCollapseEmailHeader() {
return window.innerWidth <= 768;
}
function _maybeAutoCollapseEmailHeader() {
const doc = activeDocId && docs.get(activeDocId);
if (!doc || doc.language !== 'email') return;
if (Date.now() < _emailHeaderManualExpandUntil) return;
if (document.activeElement?.closest?.('#doc-email-fields')) return;
if (_shouldAutoCollapseEmailHeader()) _setEmailHeaderCollapsed(true, { manual: false });
}
function _showEmailFields(doc) {
const emailHeader = document.getElementById('doc-email-header');
const emailActions = document.getElementById('doc-email-actions');
@@ -2405,6 +2497,7 @@ import * as Modals from './modalManager.js';
const textarea = document.getElementById('doc-editor-textarea');
if (toInput) toInput.value = fields.to;
if (subjectInput) subjectInput.value = fields.subject;
_setEmailHeaderCollapsed(!!(doc && doc._emailHeaderCollapsed), { manual: false });
if (subjectInput && !subjectInput._emailTabBodyBound) {
subjectInput._emailTabBodyBound = true;
subjectInput.addEventListener('keydown', (e) => {
@@ -2546,6 +2639,7 @@ import * as Modals from './modalManager.js';
if (ccRow) ccRow.style.display = hasCcBcc ? '' : 'none';
if (bccRow) bccRow.style.display = hasCcBcc ? '' : 'none';
if (ccToggle) ccToggle.style.display = hasCcBcc ? 'none' : '';
_syncEmailHeaderSummary();
}
async function _uploadComposeFiles(files) {
@@ -3060,19 +3154,22 @@ import * as Modals from './modalManager.js';
saveCurrentToMap();
const doc = docs.get(docId);
const snapshot = { id: docId, doc: { ...doc } };
saveDocument({ silent: true }).catch(() => {});
const wasActive = activeDocId === docId;
if (wasActive) saveDocument({ silent: true }).catch(() => {});
const visibleBefore = _visibleDocIdsForCurrentSession();
const idx = visibleBefore.indexOf(docId);
docs.delete(docId);
if (activeDocId === docId) activeDocId = null;
if (wasActive) activeDocId = null;
const remaining = visibleBefore.filter(id => id !== docId && docs.has(id));
const nextId = remaining[idx] || remaining[idx - 1] || remaining[0] || null;
if (nextId) {
switchToDoc(nextId);
} else {
closePanel();
if (wasActive) {
const remaining = visibleBefore.filter(id => id !== docId && docs.has(id));
const nextId = remaining[idx] || remaining[idx - 1] || remaining[0] || null;
if (nextId) {
switchToDoc(nextId);
} else {
closePanel();
}
}
renderTabs();
_syncDocIndicator();
@@ -3746,25 +3843,31 @@ import * as Modals from './modalManager.js';
</div>
<div class="doc-tab-bar" id="doc-tab-bar"></div>
<div id="doc-email-header" class="doc-email-header" style="display:none">
<div class="email-field" style="position:relative">
<label>To</label>
<input type="text" id="doc-email-to" placeholder="recipient@example.com" autocomplete="off" />
<div id="doc-email-to-suggestions" class="email-autocomplete" style="display:none"></div>
<button type="button" id="doc-email-show-cc" class="email-cc-toggle" title="Show Cc/Bcc">Cc</button>
<button type="button" id="doc-email-collapse-btn" class="doc-email-collapse-btn" title="Hide email fields" aria-expanded="true">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 15 12 9 18 15"/></svg>
<span id="doc-email-collapse-summary" class="doc-email-collapse-summary">No recipient · No subject</span>
</button>
<div id="doc-email-fields" class="doc-email-fields">
<div class="email-field" style="position:relative">
<label>To</label>
<input type="text" id="doc-email-to" placeholder="recipient@example.com" autocomplete="off" />
<div id="doc-email-to-suggestions" class="email-autocomplete" style="display:none"></div>
<button type="button" id="doc-email-show-cc" class="email-cc-toggle" title="Show Cc/Bcc">Cc</button>
</div>
<div class="email-field" id="doc-email-cc-row" style="display:none;position:relative">
<label>Cc</label>
<input type="text" id="doc-email-cc" placeholder="cc@example.com" autocomplete="off" />
<div id="doc-email-cc-suggestions" class="email-autocomplete" style="display:none"></div>
</div>
<div class="email-field" id="doc-email-bcc-row" style="display:none;position:relative">
<label>Bcc</label>
<input type="text" id="doc-email-bcc" placeholder="bcc@example.com" autocomplete="off" />
<div id="doc-email-bcc-suggestions" class="email-autocomplete" style="display:none"></div>
</div>
<div class="email-field"><label>Subject</label><input type="text" id="doc-email-subject" placeholder="Subject" /></div>
<div id="doc-email-attachments" class="email-attachments" style="display:none"></div>
<div id="doc-email-compose-atts" class="email-compose-atts" style="display:none"></div>
</div>
<div class="email-field" id="doc-email-cc-row" style="display:none;position:relative">
<label>Cc</label>
<input type="text" id="doc-email-cc" placeholder="cc@example.com" autocomplete="off" />
<div id="doc-email-cc-suggestions" class="email-autocomplete" style="display:none"></div>
</div>
<div class="email-field" id="doc-email-bcc-row" style="display:none;position:relative">
<label>Bcc</label>
<input type="text" id="doc-email-bcc" placeholder="bcc@example.com" autocomplete="off" />
<div id="doc-email-bcc-suggestions" class="email-autocomplete" style="display:none"></div>
</div>
<div class="email-field"><label>Subject</label><input type="text" id="doc-email-subject" placeholder="Subject" /></div>
<div id="doc-email-attachments" class="email-attachments" style="display:none"></div>
<div id="doc-email-compose-atts" class="email-compose-atts" style="display:none"></div>
<input type="hidden" id="doc-email-in-reply-to" />
<input type="hidden" id="doc-email-references" />
<input type="hidden" id="doc-email-source-uid" />
@@ -4306,6 +4409,33 @@ import * as Modals from './modalManager.js';
});
document.getElementById('doc-email-ai-reply-btn')?.addEventListener('click', _aiReply);
const collapseBtn = document.getElementById('doc-email-collapse-btn');
if (collapseBtn && !collapseBtn._emailCollapseWired) {
collapseBtn._emailCollapseWired = true;
collapseBtn.addEventListener('pointerdown', (e) => {
e.preventDefault();
e.stopPropagation();
const focusState = _captureEmailBodyFocusState();
const header = document.getElementById('doc-email-header');
const nextCollapsed = !header?.classList.contains('doc-email-header-collapsed');
_setEmailHeaderCollapsed(nextCollapsed);
if (!nextCollapsed) _restoreEmailBodyFocusState(focusState);
});
collapseBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
});
}
['doc-email-to', 'doc-email-cc', 'doc-email-bcc', 'doc-email-subject'].forEach(id => {
document.getElementById(id)?.addEventListener('input', _syncEmailHeaderSummary);
document.getElementById(id)?.addEventListener('focus', () => _setEmailHeaderCollapsed(false, { manual: false }));
});
document.getElementById('doc-email-richbody')?.addEventListener('focus', _maybeAutoCollapseEmailHeader);
if (window.visualViewport && !window._docEmailViewportCollapseBound) {
window._docEmailViewportCollapseBound = true;
window.visualViewport.addEventListener('resize', _maybeAutoCollapseEmailHeader);
}
// Split-button caret toggles the send-options menu (drops up).
document.getElementById('doc-email-send-caret')?.addEventListener('click', (e) => {
e.stopPropagation();
@@ -4348,11 +4478,13 @@ import * as Modals from './modalManager.js';
// Cc/Bcc toggle
document.getElementById('doc-email-show-cc')?.addEventListener('click', () => {
_setEmailHeaderCollapsed(false, { manual: false });
const ccRow = document.getElementById('doc-email-cc-row');
const bccRow = document.getElementById('doc-email-bcc-row');
if (ccRow) ccRow.style.display = '';
if (bccRow) bccRow.style.display = '';
document.getElementById('doc-email-show-cc').style.display = 'none';
_syncEmailHeaderSummary();
});
// Autocomplete for To / Cc / Bcc — typed fragment after the last
@@ -5811,16 +5943,31 @@ import * as Modals from './modalManager.js';
}
try {
const res = await fetch(`${API_BASE}/api/document/${docId}`);
if (!res.ok) throw new Error('Not found');
if (!res.ok) throw new Error(res.status === 404 ? 'Not found' : `HTTP ${res.status}`);
const doc = await res.json();
addDocToTabs(doc, doc.session_id);
_ensureDocPaneMounted();
switchToDoc(doc.id);
} catch (e) {
console.error('Failed to load document:', e);
if (uiModule) {
const msg = e.message === 'Not found'
? 'Document not found — try opening it from the Library.'
: 'Could not open document.';
uiModule.showError(msg);
}
}
}
// Deep-link: #document-<id> opens that document on load / URL-bar nav.
// Clicks on in-chat document anchors are handled separately (they call
// preventDefault, so they don't change the hash); this covers refresh
// and pasted/typed document URLs, which previously did nothing.
function _maybeOpenDocFromHash() {
const m = (window.location.hash || '').match(/^#document-(.+)$/);
if (m) loadDocument(m[1]);
}
/** Open panel and ensure a document exists, creating a session if needed */
export async function ensureDocPanel() {
let sessionId = _lastSessionId
@@ -6175,13 +6322,170 @@ import * as Modals from './modalManager.js';
}
/** Update the line number gutter */
function updateLineNumbers(text) {
let _lineNumberResizeObserver = null;
let _lineNumberObservedTextarea = null;
let _lineNumberResizeRaf = null;
function _lineNumberContentEl(gutter) {
let inner = gutter.querySelector('.doc-line-number-content');
if (!inner) {
inner = document.createElement('div');
inner.className = 'doc-line-number-content';
gutter.textContent = '';
gutter.appendChild(inner);
}
return inner;
}
function _lineNumberStyleSignature(style) {
return [
style.fontFamily,
style.fontSize,
style.fontWeight,
style.fontStyle,
style.lineHeight,
style.letterSpacing,
style.tabSize,
style.fontFeatureSettings,
style.fontVariantLigatures,
style.fontKerning,
].join('|');
}
function _textareaTextWidth(textarea, style) {
const paddingLeft = parseFloat(style.paddingLeft) || 0;
const paddingRight = parseFloat(style.paddingRight) || 0;
return Math.max(0, textarea.clientWidth - paddingLeft - paddingRight);
}
function _lineHeightPx(style) {
const parsed = parseFloat(style.lineHeight);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
const fontSize = parseFloat(style.fontSize) || 11;
return fontSize * 1.45;
}
function _lineNumberMeasureEl(textarea) {
const wrap = document.getElementById('doc-editor-wrap') || textarea.parentElement || document.body;
let probe = wrap.querySelector('.doc-line-number-measure');
if (!probe) {
probe = document.createElement('textarea');
probe.className = 'doc-line-number-measure';
probe.setAttribute('aria-hidden', 'true');
probe.tabIndex = -1;
probe.readOnly = true;
probe.wrap = 'soft';
wrap.appendChild(probe);
}
return probe;
}
function _syncLineNumberMeasureStyle(probe, style, textWidth) {
probe.style.width = textWidth + 'px';
probe.style.fontFamily = style.fontFamily;
probe.style.fontSize = style.fontSize;
probe.style.fontWeight = style.fontWeight;
probe.style.fontStyle = style.fontStyle;
probe.style.lineHeight = style.lineHeight;
probe.style.letterSpacing = style.letterSpacing;
probe.style.tabSize = style.tabSize;
probe.style.fontFeatureSettings = style.fontFeatureSettings;
probe.style.fontVariantLigatures = style.fontVariantLigatures;
probe.style.fontKerning = style.fontKerning;
probe.style.textRendering = style.textRendering;
probe.style.whiteSpace = style.whiteSpace;
probe.style.wordWrap = style.wordWrap;
probe.style.overflowWrap = style.overflowWrap;
}
function _measureLineNumberHeights(textarea, lines, textWidth, style) {
const probe = _lineNumberMeasureEl(textarea);
_syncLineNumberMeasureStyle(probe, style, textWidth);
const lineHeight = _lineHeightPx(style);
return lines.map(line => {
probe.value = line || ' ';
const visualRows = Math.max(1, Math.round(probe.scrollHeight / lineHeight));
return visualRows * lineHeight;
});
}
function _renderLineNumberRows(inner, heights) {
const frag = document.createDocumentFragment();
for (let i = 0; i < heights.length; i++) {
const row = document.createElement('div');
row.className = 'doc-line-number-row';
row.style.height = `${heights[i]}px`;
const label = document.createElement('span');
label.className = 'doc-line-number-label';
label.textContent = String(i + 1);
row.appendChild(label);
frag.appendChild(row);
}
inner.textContent = '';
inner.appendChild(frag);
}
function _scheduleLineNumberRerender() {
if (_lineNumberResizeRaf) return;
const run = () => {
_lineNumberResizeRaf = null;
const textarea = document.getElementById('doc-editor-textarea');
if (textarea) updateLineNumbers(textarea.value, true);
};
if (typeof requestAnimationFrame === 'function') {
_lineNumberResizeRaf = requestAnimationFrame(run);
} else {
run();
}
}
function _ensureLineNumberResizeObserver(textarea) {
if (typeof ResizeObserver === 'undefined') return;
if (!_lineNumberResizeObserver) {
_lineNumberResizeObserver = new ResizeObserver(_scheduleLineNumberRerender);
}
if (_lineNumberObservedTextarea === textarea) return;
if (_lineNumberObservedTextarea) {
_lineNumberResizeObserver.unobserve(_lineNumberObservedTextarea);
}
_lineNumberObservedTextarea = textarea;
_lineNumberResizeObserver.observe(textarea);
}
if (typeof window !== 'undefined') {
window.addEventListener('resize', _scheduleLineNumberRerender);
}
function updateLineNumbers(text, force = false) {
const textarea = document.getElementById('doc-editor-textarea');
const gutter = document.getElementById('doc-line-numbers');
if (!gutter) return;
const count = (text || '').split('\n').length;
let html = '';
for (let i = 1; i <= count; i++) html += i + '\n';
gutter.textContent = html;
if (!textarea || !gutter) return;
const value = text || '';
const lines = value.split('\n');
const inner = _lineNumberContentEl(gutter);
const style = getComputedStyle(textarea);
const textWidth = _textareaTextWidth(textarea, style);
const styleSig = _lineNumberStyleSignature(style);
_ensureLineNumberResizeObserver(textarea);
if (
!force &&
inner._lineNumberText === value &&
inner._lineNumberWidth === textWidth &&
inner._lineNumberStyleSig === styleSig
) {
syncGutterScroll();
return;
}
const heights = _measureLineNumberHeights(textarea, lines, textWidth, style);
_renderLineNumberRows(inner, heights);
inner._lineNumberText = value;
inner._lineNumberWidth = textWidth;
inner._lineNumberStyleSig = styleSig;
syncGutterScroll();
}
/** Sync line number gutter scroll with textarea */
@@ -6189,7 +6493,7 @@ import * as Modals from './modalManager.js';
const textarea = document.getElementById('doc-editor-textarea');
const gutter = document.getElementById('doc-line-numbers');
if (textarea && gutter) {
gutter.scrollTop = textarea.scrollTop;
_lineNumberContentEl(gutter).style.transform = `translateY(${-textarea.scrollTop}px)`;
}
}
+15 -9
View File
@@ -652,9 +652,10 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
if (doc.session_id) {
openItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryOpenInSession(doc); });
} else {
openItem.disabled = true;
openItem.style.opacity = '0.35';
openItem.title = 'Not linked to a session';
// Orphaned doc (closed / session detached) is still openable in the editor
// by id — libraryOpenDocument handles the no-session case (#1602).
openItem.title = 'Open in the editor';
openItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryOpenDocument(doc); });
}
dropdown.appendChild(openItem);
@@ -772,10 +773,10 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
openBtn.title = 'Open in original session';
openBtn.addEventListener('click', (e) => { e.stopPropagation(); libraryOpenInSession(doc); });
} else {
openBtn.disabled = true;
openBtn.style.opacity = '0.35';
openBtn.style.cursor = 'not-allowed';
openBtn.title = 'This document is not linked to a session';
// Orphaned doc (closed / session detached) is still openable in the editor
// by id — libraryOpenDocument handles the no-session case (#1602).
openBtn.title = 'Open in the editor';
openBtn.addEventListener('click', (e) => { e.stopPropagation(); libraryOpenDocument(doc); });
}
const cloneBtn = document.createElement('button');
@@ -2059,6 +2060,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
{ label: 'Copy', action: () => _copyChatById(s.id) },
{ label: 'Archive', action: async () => { await fetch(API_BASE + '/api/session/' + s.id + '/archive', { method: 'POST', headers: {'Content-Type':'application/json'} }); _renderLibChats(); } },
{ label: 'Delete', action: async () => {
if (!await window.styledConfirm('Delete this chat?', { confirmText: 'Delete', danger: true })) return;
await fetch(API_BASE + '/api/session/' + s.id, { method: 'DELETE' });
card.style.maxHeight = `${Math.max(card.getBoundingClientRect().height, card.scrollHeight)}px`;
card.classList.add('memory-tidy-removing');
@@ -2412,7 +2414,11 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
{ label: 'Open', action: () => { if (window.sessionModule) window.sessionModule.selectSession(s.id); } },
{ label: 'Copy', action: () => _copyChatById(s.id) },
{ label: 'Restore', action: async () => { await fetch(API_BASE + '/api/session/' + s.id + '/unarchive', { method: 'POST' }); _renderLibArchive(); } },
{ label: 'Delete', action: async () => { await fetch(API_BASE + '/api/session/' + s.id, { method: 'DELETE' }); _renderLibArchive(); }, danger: true },
{ label: 'Delete', action: async () => {
if (!await window.styledConfirm('Delete this chat permanently?', { confirmText: 'Delete', danger: true })) return;
await fetch(API_BASE + '/api/session/' + s.id, { method: 'DELETE' });
_renderLibArchive();
}, danger: true },
], { onSelect: () => {
_arcSelectMode = true;
_arcSelected.add('chats:' + s.id);
@@ -3130,7 +3136,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
importFileBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', async () => {
if (fileInput.files.length === 0) return;
const files = fileInput.files;
const files = Array.from(fileInput.files);
fileInput.value = '';
// Swap the import icon for a whirlpool while files upload.
const _orig = importFileBtn.innerHTML;
+6 -1
View File
@@ -50,6 +50,7 @@
* }} deps
*/
import { state } from './state.js';
import { isAltGrEvent } from '../platform.js';
export function wireKeyboardShortcuts(deps) {
const {
@@ -79,7 +80,11 @@ export function wireKeyboardShortcuts(deps) {
return;
}
if (e.key === 'Escape') return;
if (e.ctrlKey || e.metaKey) {
// Skip the Ctrl+Alt editor chords for an AltGr keystroke (see platform.js);
// only the chord block is skipped, so the layout-character handlers below
// still act — AltGr+5 / AltGr+8 stay as the [ ] brush-size shortcut on
// AZERTY / QWERTZ.
if ((e.ctrlKey || e.metaKey) && !isAltGrEvent(e)) {
if (e.key === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); }
// Ctrl+Shift+D = Deselect: clears the wand selection (and
// lasso if active) without affecting layers.
+2 -1
View File
@@ -37,7 +37,8 @@ export function computeSnap(layer, nx, ny, ctx) {
{ y: ch, label: 'canvas-b' },
{ y: ch / 2, label: 'canvas-cy' },
];
for (const other of ctx.otherLayers) {
const otherLayers = Array.isArray(ctx.otherLayers) ? ctx.otherLayers : [];
for (const other of otherLayers) {
if (!other.visible || other.id === layer.id) continue;
const o = other.offset || { x: 0, y: 0 };
const ow = other.canvas.width, oh = other.canvas.height;
+7 -5
View File
@@ -722,10 +722,12 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') {
em.is_read = true;
if (itemEl) itemEl.classList.remove('email-unread');
// Get my own address to exclude from Reply All. window._myEmailAddress
// is populated from the configured account on init; the empty fallback
// simply means "no exclusion" — better than baking in a real address.
const myAddress = (window._myEmailAddress || '').toLowerCase();
// Addresses to exclude from Reply All. Prefer the full set of configured
// accounts (so a multi-account user's other mailboxes are excluded too),
// falling back to the single active address. Empty ⇒ no exclusion.
const myAddresses = (Array.isArray(window._myEmailAddresses) && window._myEmailAddresses.length)
? window._myEmailAddresses
: (window._myEmailAddress ? [window._myEmailAddress] : []);
let toAddress = data.from_address;
let ccAddresses = '';
@@ -733,7 +735,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') {
if (mode === 'reply-all') {
// Build reply-all: TO = original sender, CC = everyone else (To + Cc minus me)
ccAddresses = buildReplyAllCc(data, myAddress);
ccAddresses = buildReplyAllCc(data, myAddresses);
} else if (mode === 'forward') {
toAddress = '';
subjectPrefix = 'Fwd: ';
+384 -66
View File
@@ -27,6 +27,183 @@ const API_BASE = window.location.origin;
let _emailUnreadChipClickWired = false;
let _libLoadSeq = 0;
let _libFolderSeq = 0;
let _libSearchSeq = 0;
let _libSearchHadResults = false;
let _activeEmailReaderForSelectAll = null;
function _isEmailTypingTarget(t) {
return !!(t && (
t.tagName === 'INPUT' ||
t.tagName === 'TEXTAREA' ||
t.tagName === 'SELECT' ||
t.isContentEditable
));
}
function _selectEmailReaderContents(reader) {
if (!reader || !reader.isConnected) return false;
const hiddenModal = reader.closest('.modal.hidden');
if (hiddenModal) return false;
const range = document.createRange();
range.selectNodeContents(reader);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
return true;
}
function _markEmailReaderActive(reader) {
if (!reader) return;
_activeEmailReaderForSelectAll = reader;
if (reader.dataset.selectAllWired === '1') return;
reader.dataset.selectAllWired = '1';
reader.addEventListener('pointerdown', () => { _activeEmailReaderForSelectAll = reader; }, true);
reader.addEventListener('focusin', () => { _activeEmailReaderForSelectAll = reader; }, true);
}
const _COPY_EMAIL_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
function _decodeAttrValue(v) {
const tmp = document.createElement('textarea');
tmp.innerHTML = v || '';
return tmp.value;
}
function _emailAddressFromRecipientText(text) {
const raw = String(text || '').trim();
const angle = raw.match(/<\s*([^<>@\s]+@[^<>\s]+)\s*>/);
if (angle) return angle[1].trim();
const any = raw.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
return any ? any[0].trim() : raw;
}
function _splitRecipientList(raw) {
const out = [];
let cur = '';
let quote = false;
let angle = false;
const s = String(raw || '');
for (let i = 0; i < s.length; i += 1) {
const ch = s[i];
if (ch === '"' && s[i - 1] !== '\\') quote = !quote;
else if (ch === '<' && !quote) angle = true;
else if (ch === '>' && !quote) angle = false;
if (ch === ',' && !quote && !angle) {
const part = cur.trim();
if (part) out.push(part);
cur = '';
continue;
}
cur += ch;
}
const tail = cur.trim();
if (tail) out.push(tail);
return out;
}
async function _copyTextToClipboard(text) {
const value = String(text || '');
if (!value) return false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return true;
}
} catch (_) {}
try {
const ta = document.createElement('textarea');
ta.value = value;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.top = '0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return !!ok;
} catch (_) {
return false;
}
}
function _recipientChipHtml(full, label, extraClass = '') {
const fullText = String(full || '').trim();
const addr = _emailAddressFromRecipientText(fullText);
const labelText = String(label || addr || fullText || '').trim();
const cls = `recipient-chip${extraClass ? ` ${extraClass}` : ''}`;
return `<span class="${cls}" data-full="${_esc(fullText || labelText)}" data-email="${_esc(addr)}" title="Click for details"><span class="recipient-chip-label">${_esc(labelText)}</span><button type="button" class="recipient-chip-copy" title="Copy email" aria-label="Copy email" hidden>${_COPY_EMAIL_ICON}</button></span>`;
}
function _wireRecipientChips(root) {
if (!root || root.dataset.recipientChipsWired === '1') return;
root.dataset.recipientChipsWired = '1';
root.addEventListener('click', async (ev) => {
const copyBtn = ev.target.closest?.('.recipient-chip-copy');
if (copyBtn && root.contains(copyBtn)) {
ev.stopPropagation();
ev.preventDefault();
const chip = copyBtn.closest('.recipient-chip');
const email = chip?.dataset.email || _emailAddressFromRecipientText(_decodeAttrValue(chip?.dataset.full || ''));
if (!email) return;
try {
const copied = await _copyTextToClipboard(email);
if (!copied) throw new Error('copy failed');
copyBtn.classList.add('copied');
copyBtn.title = 'Copied';
showToast?.('Email copied');
setTimeout(() => {
copyBtn.classList.remove('copied');
copyBtn.title = 'Copy email';
}, 900);
} catch (_) {
showToast?.('Copy failed');
}
return;
}
const chip = ev.target.closest?.('.recipient-chip');
if (!chip || !root.contains(chip)) return;
ev.stopPropagation();
ev.preventDefault();
const label = chip.querySelector('.recipient-chip-label');
const copy = chip.querySelector('.recipient-chip-copy');
if (chip.classList.contains('expanded')) {
chip.classList.remove('expanded');
if (label) label.textContent = chip.dataset.name || label.textContent;
if (copy) copy.hidden = true;
} else {
if (!chip.dataset.name && label) chip.dataset.name = label.textContent.trim();
chip.classList.add('expanded');
const expandedText = _decodeAttrValue(chip.dataset.full || '').trim()
|| chip.dataset.name
|| chip.dataset.email
|| label?.textContent?.trim()
|| '';
if (label && expandedText) label.textContent = expandedText;
if (copy) copy.hidden = false;
}
});
}
function _emailReaderForSelectAllTarget(target) {
if (_isEmailTypingTarget(target)) return null;
const direct = target?.closest?.('.email-card-reader, #email-lib-modal .doclib-card.doclib-card-expanded');
if (direct) return direct.querySelector?.('.email-card-reader') || direct;
const expanded = document.querySelector('#email-lib-modal:not(.hidden) .doclib-card.doclib-card-expanded .email-card-reader');
if (expanded) return expanded;
return _activeEmailReaderForSelectAll;
}
document.addEventListener('keydown', (e) => {
if (!(e.ctrlKey || e.metaKey) || String(e.key || '').toLowerCase() !== 'a') return;
const reader = _emailReaderForSelectAllTarget(e.target);
if (!_selectEmailReaderContents(reader)) return;
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation?.();
}, true);
function _syncEmailReadState(uid, isRead = true) {
if (uid == null) return;
@@ -532,6 +709,15 @@ function _publishActiveAccount() {
|| accts.find(a => a && a.is_default)
|| accts[0];
window._myEmailAddress = (active && (active.from_address || active.imap_user)) || '';
// Also publish every configured address so reply-all can exclude all of
// the user's own mailboxes, not just the active one (multi-account users
// were getting their other addresses added to Cc).
const all = [];
for (const a of accts) {
if (a && a.from_address) all.push(a.from_address);
if (a && a.imap_user) all.push(a.imap_user);
}
window._myEmailAddresses = all;
} catch (_) {}
}
@@ -1038,10 +1224,26 @@ export function openEmailLibrary(opts = {}) {
_bulkAction('delete');
});
const selectExpandedEmailText = () => {
const expanded = document.querySelector('#email-lib-modal .doclib-card.doclib-card-expanded');
const reader = expanded?.querySelector('.email-card-reader') || expanded;
return _selectEmailReaderContents(reader);
};
// ESC to close + Arrow nav + Delete on the selected / currently-expanded email.
state._libEscHandler = (e) => {
const modal = document.getElementById('email-lib-modal');
if (!modal || modal.classList.contains('hidden')) return;
if ((e.ctrlKey || e.metaKey) && String(e.key || '').toLowerCase() === 'a') {
const t = e.target;
if (_isEmailTypingTarget(t)) return;
if (selectExpandedEmailText()) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation?.();
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
@@ -1058,7 +1260,7 @@ export function openEmailLibrary(opts = {}) {
}
// Don't hijack arrows / delete while the user is typing somewhere.
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
if (_isEmailTypingTarget(t)) return;
const isDeleteKey = e.key === 'Delete' || e.key === 'Backspace';
if (isDeleteKey && state._selectMode && state._selectedUids.size > 0) {
e.preventDefault();
@@ -1184,6 +1386,23 @@ function _makeDraggable(content, modal, fsClass) {
fsClass,
skipSelector: '.close-btn, .modal-close',
enableLeftDock: true, // park the email on the left while replying on the right
onDragStart: ({ rect }) => {
if (!modal.classList.contains('email-snap-left')) return;
modal.classList.remove('email-snap-left');
_clearEmailDocumentSplit();
content.style.position = 'fixed';
content.style.left = `${Math.round(rect.left)}px`;
content.style.top = `${Math.round(rect.top)}px`;
content.style.right = '';
content.style.bottom = '';
content.style.width = `${Math.max(420, Math.round(rect.width || 560))}px`;
content.style.maxWidth = '';
content.style.height = `${Math.max(320, Math.round(rect.height || 620))}px`;
content.style.maxHeight = '85vh';
content.style.borderRadius = '';
content.style.transform = 'none';
content.style.margin = '0';
},
onEnterFullscreen: fsClass ? enterFullscreen : null,
onExitFullscreen: fsClass ? exitFullscreen : null,
});
@@ -1307,22 +1526,43 @@ function _crossFolderCandidates() {
}
async function _doSearch() {
const seq = ++_libSearchSeq;
const q = state._libSearch.trim();
if (q.length < 2) {
// Empty or too short — show regular loaded emails
// Empty or too short — restore the normal folder if a previous search
// had replaced the grid contents.
if (_libSearchHadResults) {
_libSearchHadResults = false;
state._libOffset = 0;
await _loadEmails({ useCache: true });
return;
}
_renderGrid();
return;
}
const grid = document.getElementById('email-lib-grid');
if (!grid) return;
const sp = _renderEmailLoading(grid);
const accountAtStart = state._libAccountId || '';
const folderAtStart = state._libFolder || 'INBOX';
try {
const res = await fetch(`${API_BASE}/api/email/search?folder=${encodeURIComponent(state._libFolder)}${_acct()}&q=${encodeURIComponent(q)}&limit=100`);
const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
const res = await fetch(`${API_BASE}/api/email/search?folder=${encodeURIComponent(folderAtStart)}${accountQS}&q=${encodeURIComponent(q)}&limit=100`);
const data = await res.json();
sp.destroy();
if (
seq !== _libSearchSeq ||
q !== state._libSearch.trim() ||
accountAtStart !== (state._libAccountId || '') ||
folderAtStart !== (state._libFolder || 'INBOX')
) {
return;
}
if (data.error) throw new Error(data.error);
const results = data.emails || [];
_libSearchHadResults = true;
state._libEmails = results; // temporarily replace with search results
_renderGrid();
@@ -1481,7 +1721,7 @@ async function _loadEmails({ force = false, useCache = true } = {}) {
async function _loadScheduled(grid, sp) {
const res = await fetch(`${API_BASE}/api/email/scheduled`);
const data = await res.json();
sp.destroy();
if (sp) sp.destroy();
const items = data.scheduled || [];
grid.innerHTML = '';
const stats = document.getElementById('email-lib-stats');
@@ -1886,8 +2126,9 @@ function _syncCardNavArrows(card) {
}
const _emailReadPrefetching = new Set();
let _emailReadPrefetchTimer = null;
function _prefetchAdjacentEmails(card, count = 3) {
function _prefetchAdjacentEmails(card, count = 1) {
if (!card || state._libFolder === '__scheduled__') return;
const grid = card.closest('.doclib-grid');
if (!grid) return;
@@ -1901,16 +2142,19 @@ function _prefetchAdjacentEmails(card, count = 3) {
if (targets.length < count) {
for (let i = 1; targets.length < count && cards[idx - i]; i++) targets.push(cards[idx - i]);
}
for (const target of targets) {
const uid = target.dataset.uid;
if (!uid) continue;
const key = `${state._libAccountId || ''}|${state._libFolder}|${uid}`;
if (_emailReadPrefetching.has(key)) continue;
const target = targets.find(t => t?.dataset?.uid);
const uid = target?.dataset?.uid;
if (!uid) return;
const key = `${state._libAccountId || ''}|${state._libFolder}|${uid}`;
if (_emailReadPrefetching.has(key) || _emailReadPrefetching.size > 0) return;
if (_emailReadPrefetchTimer) clearTimeout(_emailReadPrefetchTimer);
_emailReadPrefetchTimer = setTimeout(() => {
_emailReadPrefetchTimer = null;
_emailReadPrefetching.add(key);
fetch(`${API_BASE}/api/email/read/${encodeURIComponent(uid)}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&mark_seen=false`)
.catch(() => {})
.finally(() => _emailReadPrefetching.delete(key));
}
}, 900);
}
async function _toggleCardPreview(card, em) {
@@ -1978,6 +2222,7 @@ async function _toggleCardPreview(card, em) {
loadingWrap.appendChild(sp.element);
reader.appendChild(loadingWrap);
card.appendChild(reader);
_markEmailReaderActive(reader);
try {
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`);
@@ -2023,16 +2268,16 @@ async function _toggleCardPreview(card, em) {
// Build recipient chip group from a comma-separated address list
const buildRecipients = (str) => {
if (!str) return '';
const addrs = str.split(',').map(s => s.trim()).filter(Boolean);
const addrs = _splitRecipientList(str);
if (addrs.length === 0) return '';
return addrs.map(a => {
const name = _extractName(a);
return `<span class="recipient-chip" data-full="${_esc(a)}" title="Click for details">${_esc(name)}</span>`;
return _recipientChipHtml(a, name);
}).join('');
};
// Build the From chip too — single chip with name, click reveals address
const fromChip = `<span class="recipient-chip from-chip" data-full="${_esc(data.from_name)} &lt;${_esc(data.from_address)}&gt;" title="Click for details">${_esc(data.from_name || data.from_address)}</span>`;
const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
reader.innerHTML = `
<div class="email-reader-header">
@@ -2060,6 +2305,7 @@ async function _toggleCardPreview(card, em) {
${attsHtml}
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
`;
_markEmailReaderActive(reader);
reader.classList.remove('email-card-reader-loading');
reader.style.minHeight = '';
@@ -2209,32 +2455,9 @@ async function _toggleCardPreview(card, em) {
_showCachedSummary(reader, data.cached_summary, sumBtn);
}
// Event delegation for recipient chip clicks (toggle expand)
reader.addEventListener('click', (ev) => {
const chip = ev.target.closest('.recipient-chip');
if (chip && reader.contains(chip)) {
ev.stopPropagation();
ev.preventDefault();
const full = chip.getAttribute('data-full') || '';
if (chip.classList.contains('expanded')) {
chip.classList.remove('expanded');
const name = chip.getAttribute('data-name');
if (name != null) chip.textContent = name;
} else {
if (!chip.hasAttribute('data-name')) {
chip.setAttribute('data-name', chip.textContent.trim());
}
chip.classList.add('expanded');
// Decode HTML entities from the data-full attribute
const tmp = document.createElement('textarea');
tmp.innerHTML = full;
chip.textContent = tmp.value;
}
return;
}
// Always stop bubbling so the card's click doesn't fire
ev.stopPropagation();
});
_wireRecipientChips(reader);
// Always stop bubbling so the card's click doesn't fire while reading.
reader.addEventListener('click', (ev) => { ev.stopPropagation(); });
} catch (e) {
reader.innerHTML = `<div style="padding:20px;color:var(--red,#e55)">Failed to load email</div>`;
}
@@ -3707,6 +3930,7 @@ async function _openEmailAsTab(em, folder) {
// Fetch + render the email body using the exact same template as
// _toggleCardPreview so the visuals match perfectly.
const reader = modal.querySelector('.email-card-reader');
_markEmailReaderActive(reader);
const sp = spinnerModule.createWhirlpool(28);
const loading = modal.querySelector('.email-reader-tab-loading');
if (loading) loading.appendChild(sp.element);
@@ -3720,12 +3944,12 @@ async function _openEmailAsTab(em, folder) {
_syncEmailReadState(em.uid, true);
const buildChips = (str) => {
if (!str) return '';
return str.split(',').map(s => s.trim()).filter(Boolean).map(a => {
return _splitRecipientList(str).map(a => {
const name = _extractName(a);
return `<span class="recipient-chip" data-full="${_esc(a)}" title="Click for details">${_esc(name)}</span>`;
return _recipientChipHtml(a, name);
}).join('');
};
const fromChip = `<span class="recipient-chip from-chip" data-full="${_esc(data.from_name)} &lt;${_esc(data.from_address)}&gt;" title="Click for details">${_esc(data.from_name || data.from_address)}</span>`;
const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
let attsHtml = '';
try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {}
reader.innerHTML = `
@@ -3754,6 +3978,8 @@ async function _openEmailAsTab(em, folder) {
${attsHtml}
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
`;
_markEmailReaderActive(reader);
_wireRecipientChips(reader);
try { _wireAttachmentHandlers(reader, useFolder); } catch {}
const attsWrap = reader.querySelector('.email-reader-atts-wrap');
if (attsWrap) {
@@ -3866,18 +4092,19 @@ async function _openEmailWindow(em, folder) {
// standalone viewer looks/feels exactly like a real email view.
const _chipsFor = (addrs) => {
if (!addrs) return '';
const list = addrs.split(',').map(s => s.trim()).filter(Boolean);
const list = _splitRecipientList(addrs);
return list.map(a => {
const name = _extractName(a);
return `<span class="recipient-chip" data-full="${_esc(a)}" title="Click for details">${_esc(name)}</span>`;
return _recipientChipHtml(a, name);
}).join('');
};
const fromChip = `<span class="recipient-chip from-chip" data-full="${_esc(data.from_name)} &lt;${_esc(data.from_address)}&gt;" title="Click for details">${_esc(data.from_name || data.from_address)}</span>`;
const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
let attsHtml = '';
try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {}
// Repurpose bodyEl as a full email-card-reader so the inline reader's
// CSS applies (sized header, action buttons in two rows, etc.).
bodyEl.classList.add('email-card-reader');
_markEmailReaderActive(bodyEl);
bodyEl.style.padding = '0';
bodyEl.innerHTML = `
<div class="email-reader-header">
@@ -3905,6 +4132,8 @@ async function _openEmailWindow(em, folder) {
${attsHtml}
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
`;
_markEmailReaderActive(bodyEl);
_wireRecipientChips(bodyEl);
// Wire all the same action handlers the inline reader has.
try { _wireAttachmentHandlers(bodyEl, useFolder); } catch {}
const attsWrap = bodyEl.querySelector('.email-reader-atts-wrap');
@@ -3977,11 +4206,22 @@ async function _swapReaderToUid(reader, uid, folder) {
if (headerMeta) {
const subj = data.subject || '(no subject)';
const date = data.date ? new Date(data.date).toLocaleString() : '';
const chipsFor = (addrs) => {
if (!addrs) return '';
return _splitRecipientList(addrs).map(a => {
const name = _extractName(a);
return _recipientChipHtml(a, name);
}).join('');
};
const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
headerMeta.innerHTML = `
<div class="email-reader-meta-row"><strong>Subject:</strong> ${_esc(subj)}</div>
<div class="email-reader-meta-row"><strong>From:</strong> ${_esc(data.from_name || data.from_address)} &lt;${_esc(data.from_address)}&gt;</div>
<div class="email-reader-meta-row"><strong>From:</strong><span class="recipient-chips">${fromChip}</span></div>
${data.to ? `<div class="email-reader-meta-row"><strong>To:</strong><span class="recipient-chips">${chipsFor(data.to)}</span></div>` : ''}
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${chipsFor(data.cc)}</span></div>` : ''}
${date ? `<div class="email-reader-meta-row"><strong>Date:</strong> ${_esc(date)}</div>` : ''}
`;
_wireRecipientChips(reader);
}
// Refresh the attachments block to match the new email. Build fresh HTML
// and either replace the existing block, remove it (if the new email has
@@ -4218,6 +4458,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
const _deleteForeverIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="14" y2="15"/><line x1="14" y1="11" x2="10" y2="15"/></svg>';
const _bellIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
const _newTabIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
const _checkIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const closeAndRemove = async () => {
// Pick the next neighbour BEFORE we re-render so we know which email to
@@ -4300,6 +4541,24 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
_renderGrid();
},
},
{
label: em.is_answered ? 'Not Done' : 'Done',
icon: _checkIcon,
action: async () => {
const newState = !em.is_answered;
em.is_answered = newState;
if (newState) _syncEmailReadState(em.uid, true);
try {
if (newState) {
await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
} else {
await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
}
} catch (e) { console.error('Failed to toggle done:', e); }
_renderGrid();
},
},
{
label: 'Archive',
icon: _archIcon,
@@ -4441,7 +4700,7 @@ function _showCardMenu(em, anchor) {
const _checkForLabel = _cardForLabel ? _cardForLabel.querySelector('.email-card-done') : null;
const _currentlyDone = _checkForLabel ? _checkForLabel.classList.contains('active') : !!em.is_answered;
actions.push({
label: _currentlyDone ? 'Mark Not Done' : 'Mark Done',
label: _currentlyDone ? 'Not Done' : 'Done',
icon: _checkIcon,
action: async () => {
const card = anchor.closest('.doclib-card');
@@ -4570,7 +4829,9 @@ function _showBulkActionsMenu(anchor) {
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:160px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:${rect.left}px;`;
const _readIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7z"/></svg>';
const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const items = [
{ label: 'Done', icon: _doneIco, action: () => _bulkAction('done') },
{ label: 'Mark Read', icon: _readIco, action: () => _bulkAction('read') },
{ label: 'Mark Unread', icon: _unreadIco, action: () => _bulkAction('unread') },
];
@@ -4631,6 +4892,7 @@ function _updateBulkBar() {
async function _bulkAction(action) {
const uids = Array.from(state._selectedUids);
if (uids.length === 0) return;
let failedReadSync = 0;
if (action === 'delete') {
const ok = await styledConfirm(
`Delete ${uids.length} selected email${uids.length === 1 ? '' : 's'}?`,
@@ -4639,31 +4901,87 @@ async function _bulkAction(action) {
if (!ok) return;
}
for (const uid of uids) {
try {
if (action === 'archive') {
await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
} else if (action === 'delete') {
await fetch(`${API_BASE}/api/email/delete/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
} else if (action === 'read' || action === 'unread') {
// Local toggle for now (no backend endpoint yet)
const em = state._libEmails.find(e => e.uid === uid);
if (em) em.is_read = (action === 'read');
}
} catch (e) { console.error(`Failed to ${action} ${uid}:`, e); }
const deleteBtn = action === 'delete' ? document.getElementById('email-lib-bulk-delete') : null;
const actionsBtn = document.getElementById('email-lib-bulk-actions');
const cancelBtn = document.getElementById('email-lib-bulk-cancel');
const selectAll = document.getElementById('email-lib-select-all');
const countEl = document.getElementById('email-lib-selected-count');
const originalDeleteHtml = deleteBtn?.innerHTML || '';
const originalCountText = countEl?.textContent || '';
let busySpinner = null;
if (action === 'delete') {
if (deleteBtn) {
deleteBtn.disabled = true;
deleteBtn.classList.add('email-bulk-loading');
deleteBtn.innerHTML = '<span class="email-bulk-loading-label">Deleting</span>';
busySpinner = spinnerModule.create('', 'clean', 'whirlpool');
const spEl = busySpinner.createElement();
spEl.classList.add('email-bulk-whirlpool');
deleteBtn.appendChild(spEl);
busySpinner.start();
}
if (actionsBtn) actionsBtn.disabled = true;
if (cancelBtn) cancelBtn.disabled = true;
if (selectAll) selectAll.disabled = true;
if (countEl) countEl.textContent = `Deleting ${uids.length}...`;
}
if (action === 'archive' || action === 'delete') {
await _animateEmailCardRemoval(uids);
const removed = new Set(uids.map(uid => String(uid)));
state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
try {
for (const uid of uids) {
try {
if (action === 'archive') {
await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
} else if (action === 'delete') {
await fetch(`${API_BASE}/api/email/delete/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
} else if (action === 'done') {
const em = state._libEmails.find(e => e.uid === uid);
if (em) {
em.is_answered = true;
em.is_read = true;
}
await fetch(`${API_BASE}/api/email/mark-answered/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
await fetch(`${API_BASE}/api/email/mark-read/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
} else if (action === 'read' || action === 'unread') {
const endpoint = action === 'read' ? 'mark-read' : 'mark-unread';
const res = await fetch(`${API_BASE}/api/email/${endpoint}/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
let data = null;
try { data = await res.json(); } catch (_) {}
if (!res.ok || data?.success === false) {
throw new Error(data?.error || `HTTP ${res.status}`);
}
_syncEmailReadState(uid, action === 'read');
}
} catch (e) {
if (action === 'read' || action === 'unread') failedReadSync += 1;
console.error(`Failed to ${action} ${uid}:`, e);
}
}
if (action === 'archive' || action === 'delete') {
await _animateEmailCardRemoval(uids);
const removed = new Set(uids.map(uid => String(uid)));
state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
}
} finally {
if (busySpinner) busySpinner.destroy();
if (deleteBtn) {
deleteBtn.disabled = false;
deleteBtn.classList.remove('email-bulk-loading');
deleteBtn.innerHTML = originalDeleteHtml;
}
if (actionsBtn) actionsBtn.disabled = false;
if (cancelBtn) cancelBtn.disabled = false;
if (selectAll) selectAll.disabled = false;
if (countEl) countEl.textContent = originalCountText;
}
state._selectedUids.clear();
state._selectMode = false;
_updateBulkBar();
_renderGrid();
// Sync the local mutation (delete/archive, or in-place read/unread
// flag flips on email objects) into the SWR cache so reopen doesn't
if (failedReadSync > 0) {
showToast(`Failed to update ${failedReadSync} email${failedReadSync === 1 ? '' : 's'}`);
}
// Sync successful local mutations into the SWR cache so reopen doesn't
// briefly show the pre-bulk state.
_libCacheWriteBack();
}
+10 -8
View File
@@ -12,14 +12,16 @@ export function extractEmail(addr) {
// Reply-all CC = everyone on the original To + Cc, minus ourselves, with the
// original "Name <email>" form preserved.
//
// `myAddress` empty/unknown ⇒ no exclusion. Comparing by exact extracted email
// (not a substring `includes`) is what fixes issue #360: an empty self address
// made `"...".includes("")` true for every recipient, so reply-all dropped the
// entire Cc list and kept only the original sender.
export function buildReplyAllCc(data, myAddress) {
const me = (myAddress || '').toLowerCase();
const split = (s) => (s || '').split(',').map((x) => x.trim()).filter(Boolean);
// `mine` is a single address or a list of the user's own addresses (a
// multi-account user has more than one). Empty/unknown ⇒ no exclusion.
// Comparing by exact extracted email (not a substring `includes`) is what
// fixes issue #360: an empty self address made `"...".includes("")` true for
// every recipient, so reply-all dropped the entire Cc list.
export function buildReplyAllCc(data, mine) {
const list = Array.isArray(mine) ? mine : [mine];
const me = new Set(list.map((a) => (a || '').toLowerCase()).filter(Boolean));
const split = (s) => (typeof s === 'string' ? s : '').split(',').map((x) => x.trim()).filter(Boolean);
return [...split(data && data.to), ...split(data && data.cc)]
.filter((addr) => !me || extractEmail(addr) !== me)
.filter((addr) => !me.has(extractEmail(addr)))
.join(', ');
}
+7 -3
View File
@@ -133,7 +133,7 @@ export function _foldSummary(label, iconSvg, meta) {
// "On <date>, <addr> wrote:". Returns a display string like
// "Jane Doe · Mon, Apr 18, 2026 at 9:31 AM" or `''`.
export function _extractQuoteMeta(html) {
if (!html) return '';
if (typeof html !== 'string' || !html) return '';
const txt = html
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
@@ -154,7 +154,11 @@ export function _extractQuoteMeta(html) {
let date = sentMatch ? sentMatch[1].trim() : '';
if (!from && !date) {
const gmail = txt.match(/On\s+([^,]+?,[^,]+?\d{4}[^,]*),?\s+(.+?)\s+wrote\s*:/i);
// The date may carry up to three commas before the year: the standard
// US Gmail attribution is "On Mon, Apr 18, 2026 at 9:31 AM, Jane wrote:"
// (weekday and day-of-month each add one). A single-comma pattern never
// reached the year there, so the fold lost its sender/date headline.
const gmail = txt.match(/On\s+((?:[^,]*,){0,3}?[^,]*?\d{4}[^,]*),?\s+(.+?)\s+wrote\s*:/i);
if (gmail) { date = gmail[1].trim(); from = gmail[2].trim(); }
}
@@ -298,7 +302,7 @@ export function _foldSignature(html, hintSig) {
m = html.match(/<div[^>]*id=["'](?:Signature|signature|divRplyFwdMsg)["'][\s\S]*$/i);
if (m) return wrap(html.slice(0, html.length - m[0].length), '', m[0]);
m = html.match(/(<br>|\n)\s*--\s*(<br>|\n)([\s\S]*)$/i);
m = html.match(/(<br\s*\/?>|\n)\s*--\s*(<br\s*\/?>|\n)([\s\S]*)$/i);
if (m) {
const idx = html.lastIndexOf(m[0]);
return wrap(html.slice(0, idx), m[1], m[3]);
+1 -1
View File
@@ -15,7 +15,7 @@ export const _TALON_FROM = '(?:From|Från|Von|De|Da|От|Od|Van|差出人|发件
export const _TALON_SENT = '(?:Sent|Skickat|Gesendet|Envoy[ée]|Inviato|Enviado|Verzonden|Отправлено|Wysłane|Date|送信日時|发送时间|寄件日期|Sendt|Lähetetty|Tarih|Datum|Data|Datum)';
export const _TALON_SUBJ = '(?:Subject|Ämne|Betreff|Objet|Oggetto|Asunto|Onderwerp|Тема|Temat|件名|主题|主旨|Emne|Aihe|Onderwerp|Konu)';
export const _TALON_TO = '(?:To|Till|An|À|A|Voor|Para|Naar|Кому|Do|宛先|收件人|Emri|Komu)';
export const _TALON_ORIG_RE = /(?:^|\n)[\s>]*[-_=]{3,}\s*(?:Original\s+Message|Ursprüngliche\s+Nachricht|Mensaje\s+original|Messaggio\s+originale|Message\s+d[']origine|Oorspronkelijk\s+bericht|Original\s+meddelande|Vor[ ]asal[a]\s+meddelande|原文|原始邮件|転送)\s*[-_=]{3,}/i;
export const _TALON_ORIG_RE = /(?:^|\n)[\s>]*[-_=]{3,}\s*(?:Original\s+Message|Forwarded\s+message|Ursprüngliche\s+Nachricht|Mensaje\s+original|Messaggio\s+originale|Message\s+d[']origine|Oorspronkelijk\s+bericht|Original\s+meddelande|Vor[ ]asal[a]\s+meddelande|原文|原始邮件|転送)\s*[-_=]{3,}/i;
// Minimum plain-text length of a "signature" before we bother folding it.
// Short closings ("Cheers, John") stay inline — folding them would add
+12 -1
View File
@@ -112,7 +112,7 @@ function _createChip(f, idx) {
chip.classList.add('thumb-image'); // lets CSS overlay the remove-X on the corner (mobile)
const img = document.createElement('img');
img.className = 'thumb-img';
img.src = URL.createObjectURL(f);
img.src = _getPreviewUrl(f);
img.alt = f.name || 'image';
chip.appendChild(img);
} else {
@@ -172,6 +172,17 @@ export async function uploadPending() {
method: 'POST',
body: fd
});
if (!res.ok) {
// Surface the failure instead of swallowing it. Previously a non-OK
// response (e.g. 429 rate limit, 413 too large) was ignored: the files
// silently vanished and the chat sent with no attachments, so the model
// "didn't even see them" (issue #1346). Show the server's reason and keep
// pendingFiles so the strip re-renders for a retry (see finally below).
let detail = '';
try { const e = await res.json(); detail = e.detail || e.error || ''; } catch (_) {}
_showToast('Upload failed' + (detail ? ': ' + detail : ` (HTTP ${res.status})`));
return [];
}
const data = await res.json();
uploaded = (data.files || []);
pendingFiles = []; // clear only on success
+12 -7
View File
@@ -8,6 +8,7 @@ import spinnerModule from './spinner.js';
import { providerLogo } from './providers.js';
import { PROMPT_TEMPLATES, getAllPresets } from './presets.js';
import { sortModelObjects } from './modelSort.js';
import Storage from './storage.js';
let API_BASE = '';
let _active = false;
@@ -57,7 +58,7 @@ function _initGroupTab() {
});
});
_modelsCache = sortModelObjects(result);
return result;
return _modelsCache;
}
function _render() {
@@ -298,13 +299,16 @@ async function _getCharacterList() {
});
}
} catch (e) {}
// Load user templates and wait for them before returning
// Load user templates and wait for them before returning.
// The endpoint returns a JSON array directly (not {templates:[...]}).
// All user templates are personas by definition — no isCharacter filter needed.
try {
const r = await fetch(API_BASE + '/api/presets/templates', { credentials: 'same-origin' });
const data = await r.json();
(data.templates || []).forEach(t => {
if (t.isCharacter && !chars.find(c => c.id === t.id)) {
chars.push({ id: t.id, name: t.name, prompt: t.prompt || '' });
const templates = Array.isArray(data) ? data : (data.templates || []);
templates.forEach(t => {
if (t.id && t.name && !chars.find(c => c.id === t.id)) {
chars.push({ id: t.id, name: t.name, prompt: t.system_prompt || t.prompt || '' });
}
});
} catch (e) {}
@@ -409,7 +413,7 @@ export async function showModelPicker() {
});
});
_cachedModels = sortModelObjects(result);
return result;
return _cachedModels;
}
async function render(filter) {
@@ -546,7 +550,8 @@ export async function startGroup(models, parentSessionId) {
_parentSessionId = pdata.id;
// Register as group session for sidebar icon
try {
const gids = JSON.parse(localStorage.getItem('odysseus-group-sessions') || '[]');
const storedGroupSessions = Storage.getJSON('odysseus-group-sessions', []);
const gids = Array.isArray(storedGroupSessions) ? storedGroupSessions : [];
if (!gids.includes(_parentSessionId)) { gids.push(_parentSessionId); localStorage.setItem('odysseus-group-sessions', JSON.stringify(gids)); }
} catch (e) {}
} catch (e) {
+33
View File
@@ -165,6 +165,39 @@ window.addEventListener('pageshow', clearFreshComposerRestore);
window.addEventListener('resize', _sync);
}
/* Keep minimized tool chips above the composer. Both the current modalManager
dock and the legacy fallback dock consume this root-level clearance. */
{
const root = document.documentElement;
const chatBar = document.querySelector('.chat-input-bar');
const attachStrip = document.getElementById('attach-strip');
const chatContainer = document.getElementById('chat-container');
const _syncComposerClearance = () => {
let top = window.innerHeight;
for (const el of [attachStrip, chatBar]) {
if (!el) continue;
const rect = el.getBoundingClientRect();
if (rect.height > 0) top = Math.min(top, rect.top);
}
const clearance = Math.max(12, Math.ceil(window.innerHeight - top + 8));
root.style.setProperty('--composer-clearance', clearance + 'px');
};
requestAnimationFrame(_syncComposerClearance);
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(_syncComposerClearance);
if (chatBar) ro.observe(chatBar);
if (attachStrip) ro.observe(attachStrip);
}
if (chatContainer && typeof MutationObserver !== 'undefined') {
new MutationObserver(_syncComposerClearance).observe(chatContainer, {
attributes: true,
attributeFilter: ['class'],
});
}
if (chatBar) chatBar.addEventListener('transitionend', _syncComposerClearance);
window.addEventListener('resize', _syncComposerClearance);
}
/* ---- Resizable sidebar — drag edge to resize, collapse if small, drag rail edge to expand ---- */
{
const sidebar = document.getElementById('sidebar');
+6 -1
View File
@@ -2,6 +2,8 @@
// Keyboard Shortcuts — dynamic keybinds
// ============================================
import { IS_MAC, isAltGrEvent } from './platform.js';
const _defaultKeybinds = {
search: 'ctrl+k', toggle_sidebar: 'ctrl+alt+b', new_session: 'ctrl+alt+n',
fav_session: 'ctrl+alt+f', delete_session: 'ctrl+alt+d',
@@ -13,8 +15,11 @@ const _defaultKeybinds = {
open_notes: '', open_tasks: '', open_theme: '',
};
function _matchesCombo(e, combo) {
export function _matchesCombo(e, combo, isMac = IS_MAC) {
if (!combo) return false;
// Drop AltGr keystrokes so typing characters on non-US layouts can't fire a
// Ctrl+Alt shortcut — e.g. the destructive delete_session. See platform.js.
if (isAltGrEvent(e, isMac)) return false;
const parts = combo.split('+');
const needCtrl = parts.includes('ctrl');
const needAlt = parts.includes('alt');
+2 -2
View File
@@ -175,8 +175,8 @@ export function langIcon(lang, size = 14, opts = {}) {
const key = String(lang).toLowerCase();
const inner = ICONS[key] || ICONS[ALIASES[key]] || '';
if (!inner) return '';
const cls = opts.className ? ` class="${opts.className}"` : '';
const style = opts.style ? ` style="${opts.style}"` : '';
const cls = (opts && opts.className) ? ` class="${opts.className}"` : '';
const style = (opts && opts.style) ? ` style="${opts.style}"` : '';
return (
`<svg${cls}${style} width="${size}" height="${size}" viewBox="0 0 24 24" ` +
`fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">` +
+47 -40
View File
@@ -5,6 +5,7 @@
*/
import uiModule from './ui.js';
import { splitTableRow } from './markdown/tableRow.js';
var escapeHtml = uiModule.esc;
@@ -371,10 +372,46 @@ export function processWithThinking(text) {
* Convert markdown to HTML
*/
export function mdToHtml(src) {
// CRITICAL: Extract allowed HTML blocks first (details/summary)
const allowedHtmlBlocks = [];
const codeBlocks = [];
const mermaidBlocks = [];
let s = (src ?? '');
// Extract fenced code blocks before any markdown/HTML preservation passes.
// Otherwise placeholders from the allowed-HTML sanitizer (e.g.
// ___ALLOWED_HTML_0___) can leak into quoted HTML/JS samples, because the
// placeholder gets captured as literal code content and never restored inside
// the final <pre><code> block.
s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => {
const cleaned = code
.replace(/\r\n/g, '\n')
.replace(/[ \t]+$/gm, '')
.replace(/^\s*\n+/, '')
.replace(/\n+\s*$/g, '');
// Mermaid diagrams: render as diagram instead of code block
if (lang && lang.toLowerCase() === 'mermaid') {
const mermaidId = 'mermaid-' + Date.now() + '-' + mermaidBlocks.length;
const raw = cleaned.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
const placeholder = `___MERMAID_BLOCK_${mermaidBlocks.length}___`;
mermaidBlocks.push(`<div class="mermaid-container"><pre class="mermaid" id="${mermaidId}">${escapeHtml(raw)}</pre></div>`);
return placeholder;
}
const escaped = cleaned.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
const placeholder = `___CODE_BLOCK_${codeBlocks.length}___`;
const langClass = lang ? ` class="language-${lang}"` : '';
const runnableLangs = ['python','py','javascript','js','html','bash','sh','shell','zsh'];
const runBtn = (lang && runnableLangs.includes(lang.toLowerCase()))
? `<button type="button" class="run-code" data-code="${escapeHtml(escaped)}" data-lang="${lang}" title="Run code"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>`
: '';
const editBtn = `<button type="button" class="edit-code" title="Edit"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>`;
codeBlocks.push(`<pre><code${langClass} data-lang="${lang || ''}">${escapeHtml(escaped)}</code>${runBtn}${editBtn}<button type="button" class="copy-code" data-code="${escapeHtml(escaped)}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></pre>`);
return placeholder;
});
// Repair common ways the agent mangles the entity-anchor convention
// (`[Name](#kind-<id>)`). Models reliably get the single-link case
// right but slip into other formats when listing many in a table.
@@ -449,39 +486,6 @@ export function mdToHtml(src) {
s = s.replace(/\n{3,}/g, '\n\n');
// CRITICAL: Extract code blocks and replace with placeholders
const codeBlocks = [];
const mermaidBlocks = [];
s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => {
const cleaned = code
.replace(/\r\n/g, '\n')
.replace(/[ \t]+$/gm, '')
.replace(/^\s*\n+/, '')
.replace(/\n+\s*$/g, '');
// Mermaid diagrams: render as diagram instead of code block
if (lang && lang.toLowerCase() === 'mermaid') {
const mermaidId = 'mermaid-' + Date.now() + '-' + mermaidBlocks.length;
const raw = cleaned.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
const placeholder = `___MERMAID_BLOCK_${mermaidBlocks.length}___`;
mermaidBlocks.push(`<div class="mermaid-container"><pre class="mermaid" id="${mermaidId}">${escapeHtml(raw)}</pre></div>`);
return placeholder;
}
const escaped = cleaned.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
const placeholder = `___CODE_BLOCK_${codeBlocks.length}___`;
const langClass = lang ? ` class="language-${lang}"` : '';
const runnableLangs = ['python','py','javascript','js','html','bash','sh','shell','zsh'];
const runBtn = (lang && runnableLangs.includes(lang.toLowerCase()))
? `<button type="button" class="run-code" data-code="${escapeHtml(escaped)}" data-lang="${lang}" title="Run code"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>`
: '';
const editBtn = `<button type="button" class="edit-code" title="Edit"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>`;
codeBlocks.push(`<pre><code${langClass} data-lang="${lang || ''}">${escapeHtml(escaped)}</code>${runBtn}${editBtn}<button type="button" class="copy-code" data-code="${escapeHtml(escaped)}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></pre>`);
return placeholder;
});
// KaTeX math rendering (after code blocks are extracted, so math in code is safe)
const mathBlocks = [];
if (window.katex) {
@@ -535,16 +539,18 @@ export function mdToHtml(src) {
let html = '<table style="border-collapse: collapse; width: 100%; margin: 10px 0;">';
rows.forEach((row, idx) => {
const cells = row.split('|').filter(cell => cell.trim() !== '');
if (idx === 1 && /^[\s|:\-]+$/.test(row)) {
html += '<tbody>';
return;
}
const cells = splitTableRow(row);
if (cells.length === 0) return;
html += idx === 1 ? '<tbody>' : '';
html += '<tr>';
cells.forEach(cell => {
const tag = idx === 0 ? 'th' : 'td';
const style = idx === 1 ? 'style="border-top: 2px solid var(--red);"' : '';
html += `<${tag} ${style} style="padding: 8px; text-align: left; border-bottom: 1px solid var(--border);">${cell.trim()}</${tag}>`;
html += `<${tag} style="padding: 8px; text-align: left; border-bottom: 1px solid var(--border);">${cell.trim()}</${tag}>`;
});
html += '</tr>';
@@ -580,8 +586,9 @@ export function mdToHtml(src) {
s = s.replace(/(?:^|\n)(<oli>[\s\S]*?)(?=\n(?!<oli>)|$)/g, m => `<ol>${m.trim().replace(/<\/?oli>/g, (t) => t === '<oli>' ? '<li>' : '</li>')}</ol>`);
// Unordered lists
s = s.replace(/^(?:- |\* )(.*)$/gm, '<li>$1</li>');
s = s.replace(/(?:^|\n)(<li>[\s\S]*?)(?=\n(?!<li>)|$)/g, m => `<ul>${m.trim()}</ul>`);
s = s.replace(/^(?:- |\* )(.*)$/gm, '<uli>$1</uli>');
s = s.replace(/(^|\n)((?:<uli>[^\n]*<\/uli>(?:\n|$))+)/g, (_, prefix, block) =>
`${prefix}<ul>${block.trim().replace(/<\/?uli>/g, (t) => t === '<uli>' ? '<li>' : '</li>')}</ul>`);
// Blockquotes
s = s.replace(/^&gt; (.*)$/gm, '<bq>$1</bq>');
+19
View File
@@ -0,0 +1,19 @@
// static/js/markdown/tableRow.js
//
// Pure helper for splitting a markdown table row into cells. No DOM —
// safe to import anywhere and to unit-test under node.
// Split a "| a | b | c |" row into trimmed cell strings.
//
// Strip only the optional leading/trailing pipe, then split — filtering out
// every empty cell (the old behaviour) dropped intentionally-empty interior
// cells too, so "| a | | c |" collapsed to 2 columns and misaligned with the
// header.
export function splitTableRow(row) {
const text = typeof row === 'string' ? row : '';
return text
.replace(/^\s*\|/, '')
.replace(/\|\s*$/, '')
.split('|')
.map((cell) => cell.trim());
}
+49 -7
View File
@@ -78,10 +78,20 @@ function _captureRestoreHeight(modal, state) {
if (!modal || !state) return;
const content = modal.querySelector('.modal-content');
if (!content) return;
if (modal.id === 'email-lib-modal'
&& (modal.classList.contains('modal-left-docked')
|| modal.classList.contains('email-snap-left')
|| document.body.classList.contains('email-doc-split-active'))) {
delete state.restoreMinHeight;
return;
}
const rect = content.getBoundingClientRect();
if (!rect || rect.height < 120) return;
const maxHeight = Math.max(180, window.innerHeight - 24);
state.restoreMinHeight = `${Math.round(Math.min(rect.height, maxHeight))}px`;
const minHeight = modal.id === 'email-lib-modal' && window.innerWidth > 768
? Math.min(560, maxHeight)
: 0;
state.restoreMinHeight = `${Math.round(Math.max(minHeight, Math.min(rect.height, maxHeight)))}px`;
}
function _applyRestoreHeight(modal, state) {
@@ -90,7 +100,10 @@ function _applyRestoreHeight(modal, state) {
if (!content) return;
const maxHeight = Math.max(180, window.innerHeight - 24);
const requested = parseInt(state.restoreMinHeight, 10);
const height = Number.isFinite(requested) ? Math.min(requested, maxHeight) : null;
const minHeight = modal.id === 'email-lib-modal' && window.innerWidth > 768
? Math.min(560, maxHeight)
: 0;
const height = Number.isFinite(requested) ? Math.max(minHeight, Math.min(requested, maxHeight)) : null;
if (height) content.style.minHeight = `${height}px`;
}
@@ -380,7 +393,7 @@ function _renderDock() {
chip.style.setProperty('position', 'fixed', 'important');
chip.style.setProperty('left', `${pos.left}px`, 'important');
chip.style.setProperty('top', `${pos.top}px`, 'important');
chip.style.setProperty('z-index', '999', 'important');
chip.style.setProperty('z-index', '10020', 'important');
document.body.appendChild(chip);
} else {
dock.appendChild(chip);
@@ -820,7 +833,7 @@ function _wireChipDrag(chip, dock) {
// inline styles set via .style on some Safari versions.
chip.style.setProperty('transition', 'none', 'important');
chip.style.setProperty('transform', `translate(${tx}px, ${ty}px) scale(${inZone ? 1.12 : 1.05})`, 'important');
chip.style.setProperty('z-index', '10000', 'important');
chip.style.setProperty('z-index', '10030', 'important');
chip.style.setProperty('position', 'fixed', 'important');
chip.style.setProperty('left', `${chipStartLeft}px`, 'important');
chip.style.setProperty('top', `${chipStartTop}px`, 'important');
@@ -836,7 +849,7 @@ function _wireChipDrag(chip, dock) {
if (dragMode === 'reorder') {
chip.style.transition = 'none';
chip.style.transform = `translate(${dx}px, ${dy}px) scale(1.05)`;
chip.style.zIndex = '1000';
chip.style.zIndex = '10030';
// Find sibling under cursor and swap
const siblings = [...dock.querySelectorAll('.minimized-dock-chip:not(.dragging)')];
@@ -1214,7 +1227,9 @@ export function minimize(id) {
// If this window is edge-docked (right/left), SUSPEND the dock: release
// the body push so the chat returns to full width while the window is
// minimized, but keep the dock so restoring the chip snaps it back in.
if (modal.classList.contains('modal-right-docked') || modal.classList.contains('modal-left-docked')) {
if (modal.classList.contains('modal-right-docked')
|| modal.classList.contains('modal-left-docked')
|| modal.classList.contains('email-snap-left')) {
try { suspendDock(modal); } catch (e) { console.warn('suspendDock on minimize failed', e); }
}
modal.classList.add('hidden');
@@ -1453,6 +1468,24 @@ const _SWIPE_DOWN_MINIMIZES = new Set([
// (per-email reader tabs) survive swipe-down too.
const _SWIPE_DOWN_MINIMIZES_PREFIX = ['email-reader-'];
function _clearEmailSplitAfterMinimize() {
document.body.classList.remove('email-doc-split-active', 'email-front');
document.documentElement.style.removeProperty('--email-doc-split-left-x');
document.documentElement.style.removeProperty('--email-doc-split-email-w');
document.documentElement.style.removeProperty('--email-doc-split-right-x');
const docPane = document.getElementById('doc-editor-pane');
if (docPane) {
[
'position', 'left', 'right', 'top', 'bottom', 'width', 'max-width',
'height', 'z-index', 'transform',
].forEach(prop => docPane.style.removeProperty(prop));
}
const divider = document.getElementById('doc-divider');
if (divider) divider.style.display = '';
requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
setTimeout(() => window.dispatchEvent(new Event('resize')), 80);
}
// Re-route swipe-dismiss to minimize-rather-than-close — but only for the
// allowlisted tools above. For every other modal, return early so the
// default close handler runs and the modal goes away.
@@ -1479,7 +1512,16 @@ window.addEventListener('modal-dismissed', (e) => {
s.isMinimized = true;
_setBadge(s.btnIds, true);
const modal = document.getElementById(id);
if (modal) modal.classList.add('modal-minimized');
if (modal) {
const isEmailModal = id === 'email-lib-modal' || id.startsWith('email-reader-');
if (modal.classList.contains('modal-right-docked')
|| modal.classList.contains('modal-left-docked')
|| modal.classList.contains('email-snap-left')) {
try { suspendDock(modal); } catch (err) { console.warn('suspendDock on dismissed failed', err); }
}
if (isEmailModal) _clearEmailSplitAfterMinimize();
modal.classList.add('modal-minimized');
}
_ensureDock();
_renderDock();
// Stop legacy listeners that reset internal `_open` state
+47 -5
View File
@@ -426,11 +426,16 @@ function _applyDockInternal(modal, side, dockClass) {
// its padding-right.
if (!modal._dockCloseWatcher && typeof MutationObserver !== 'undefined') {
const onGone = () => _onDockedModalGone(modal, dockClass);
// Watch the modal itself for hidden-class flips and parent removal.
const obs = new MutationObserver(() => {
if (!modal.isConnected || modal.classList.contains('hidden')) onGone();
});
obs.observe(modal, { attributes: true, attributeFilter: ['class'] });
// Watch the modal for: the `.hidden` class flip, an inline
// `display:none` (how the draggable modals — calendar, plan, workspace,
// etc. — actually close), and parent removal. Without the `style` filter
// a display:none close left the body's dock padding on, so the chat
// stayed shifted after the docked modal was closed.
const _isGone = () => !modal.isConnected
|| modal.classList.contains('hidden')
|| modal.style.display === 'none';
const obs = new MutationObserver(() => { if (_isGone()) onGone(); });
obs.observe(modal, { attributes: true, attributeFilter: ['class', 'style'] });
// A second observer catches DOM removal — childList on the parent
// is the reliable signal for `.remove()` / `.removeChild()` calls.
if (modal.parentNode) {
@@ -475,6 +480,25 @@ function _onDockedModalGone(modal, dockClass) {
}
modal.classList.remove('modal-right-docked');
modal.classList.remove('modal-left-docked');
// Clear the content's docked inline geometry. Singleton modals (plan,
// workspace, calendar, …) reuse the same element across open/close, so if we
// only drop the body push the element stays positioned (position:fixed;
// right:0; fixed width) on the next open — floating over the chat with no
// push. We deliberately do NOT restore the pre-dock snapshot here: that
// snapshot is the drag position from when the user pulled the window to the
// edge (near the side), so restoring it would reopen the modal off to the
// side, still overlapping. Clearing the inline styles lets the modal reopen
// at its CSS default (centered). Drag-to-undock still uses clearRightDock,
// which DOES restore the snapshot for the peel-off feel.
if (_c) {
for (const prop of ['position', 'inset', 'left', 'top', 'right', 'bottom',
'width', 'maxWidth', 'height', 'maxHeight',
'borderRadius', 'transform', 'margin']) {
_c.style[prop] = '';
}
delete _c._preDockSnapshot;
delete _c._dockSide;
}
}
function _expandSidebarFromRail() {
@@ -498,6 +522,9 @@ export function clearRightDock(modal, cx, cy, dockClass) {
if (!modal.classList.contains(dockClass)) return;
modal.classList.remove(dockClass);
clearDockSide(side, modal);
if (side === 'left' && !_hasOtherDockedWindow('left', modal)) {
_clearEmailDocSplitGeometry();
}
delete content._dockSide;
_disconnectLeftDockObservers(content);
const snap = content._preDockSnapshot;
@@ -555,8 +582,10 @@ export function suspendDock(modal) {
const nodes = _resolveDockNodes(modal);
if (!nodes || !nodes.content) return null;
const content = nodes.content;
const hadEmailSnapLeft = modal.classList.contains('email-snap-left');
const side = content._dockSide
|| (modal.classList.contains('modal-left-docked') ? 'left'
: modal.classList.contains('email-snap-left') ? 'left'
: modal.classList.contains('modal-right-docked') ? 'right' : null);
if (!side) return null;
// Stop the close-watcher from tearing the dock fully down when `.hidden`
@@ -568,6 +597,19 @@ export function suspendDock(modal) {
}
// Release the body push + restore the sidebar so the chat fills the width.
clearDockSide(side, modal);
if (side === 'left') {
_disconnectLeftDockObservers(content);
}
if (hadEmailSnapLeft) {
modal.classList.remove('email-snap-left');
_clearEmailDocSplitGeometry();
delete content._dockSide;
delete content._dockSuspended;
return null;
}
if (side === 'left' && !_hasOtherDockedWindow('left', modal)) {
_clearEmailDocSplitGeometry();
}
if (content._preDockSnapshot?.collapsedSidebar && !_hasAnyOtherDockedWindow(modal)) {
_expandSidebarFromRail();
}
+101 -15
View File
@@ -209,6 +209,54 @@ function _initModelPickerDropdown() {
return sortModelObjects(result);
}
// ── Provider display names and grouping ──
const _PROVIDER_NAMES = {
'01-ai': 'Yi', 'abacusai': 'Abacus AI', 'adept': 'Adept',
'ai21': 'AI21 Labs', 'ai21labs': 'AI21 Labs', 'aion-labs': 'Aion Labs',
'aisingapore': 'AI Singapore', 'allenai': 'Allen AI', 'amazon': 'Amazon',
'anthracite-org': 'Anthracite', 'anthropic': 'Anthropic', 'arcee-ai': 'Arcee AI',
'baai': 'BAAI', 'baidu': 'Baidu', 'bigcode': 'BigCode',
'black-forest-labs': 'Black Forest Labs', 'bytedance': 'ByteDance',
'bytedance-seed': 'ByteDance', 'cognitivecomputations': 'Cognitive Computations',
'cohere': 'Cohere', 'databricks': 'Databricks', 'deepcogito': 'DeepCogito',
'deepseek': 'DeepSeek', 'deepseek-ai': 'DeepSeek', 'essentialai': 'Essential AI',
'google': 'Google', 'gryphe': 'Gryphe', 'ibm': 'IBM',
'ibm-granite': 'IBM Granite', 'inception': 'Inception',
'inclusionai': 'Inclusion AI', 'inflection': 'Inflection',
'kwaipilot': 'KwaiPilot', 'liquid': 'Liquid AI', 'mancer': 'Mancer',
'meta': 'Llama', 'meta-llama': 'Llama', 'microsoft': 'Microsoft',
'minimax': 'MiniMax', 'minimaxai': 'MiniMax', 'mistralai': 'Mistral',
'moonshotai': 'Moonshot', 'morph': 'Morph', 'nex-agi': 'Nex AGI',
'nousresearch': 'Nous Research', 'nv-mistralai': 'NVIDIA x Mistral',
'nvidia': 'NVIDIA', 'openai': 'OpenAI', 'openrouter': 'OpenRouter',
'perceptron': 'Perceptron', 'perplexity': 'Perplexity', 'poolside': 'Poolside',
'prime-intellect': 'Prime Intellect', 'qwen': 'Qwen', 'rekaai': 'Reka',
'relace': 'Relace', 'sao10k': 'Sao10k', 'sarvamai': 'Sarvam AI',
'snowflake': 'Snowflake', 'stepfun': 'StepFun', 'stepfun-ai': 'StepFun',
'stockmark': 'Stockmark', 'switchpoint': 'SwitchPoint', 'tencent': 'Tencent',
'thedrummer': 'TheDrummer', 'undi95': 'Undi95', 'upstage': 'Upstage',
'writer': 'Writer', 'x-ai': 'xAI', 'xiaomi': 'Xiaomi',
'z-ai': 'Zhipu', 'zyphra': 'Zyphra',
'~anthropic': 'Anthropic', '~google': 'Google',
'~moonshotai': 'Moonshot', '~openai': 'OpenAI',
};
const _PROVIDER_ALIAS = {
'meta-llama': 'meta', 'deepseek': 'deepseek-ai', 'minimaxai': 'minimax',
'stepfun-ai': 'stepfun', 'ai21labs': 'ai21', 'ibm-granite': 'ibm',
'bytedance-seed': 'bytedance', '~anthropic': 'anthropic',
'~google': 'google', '~moonshotai': 'moonshotai', '~openai': 'openai',
};
function _providerDisplayName(slug) {
return _PROVIDER_NAMES[slug] || slug.charAt(0).toUpperCase() + slug.slice(1).replace(/-/g, ' ');
}
function _providerSlug(mid) {
const slash = mid.indexOf('/');
let slug = slash > 0 ? mid.substring(0, slash) : 'other';
return _PROVIDER_ALIAS[slug] || slug;
}
const _collapsedProviders = new Set(_loadList('odysseus-model-collapsed'));
let _justExpandedProvider = null;
function _populate(filter) {
listEl.innerHTML = '';
const all = _getAllModels();
@@ -319,13 +367,11 @@ function _initModelPickerDropdown() {
// ── Search mode: flat, filtered results across the whole catalog ──
if (q) {
const matches = all.filter(m =>
[
m.mid,
m.display,
m.epName,
m.providerText,
].filter(Boolean).join(' ').toLowerCase().includes(q));
const matches = all.filter(m => {
const provName = _providerDisplayName(_providerSlug(m.mid)).toLowerCase();
return [m.mid, m.display, m.epName, m.providerText, provName]
.filter(Boolean).join(' ').toLowerCase().includes(q);
});
if (matches.length === 0) _addEmpty('No matching models');
else matches.forEach(_addRow);
return;
@@ -355,14 +401,54 @@ function _initModelPickerDropdown() {
if (shown.size) _addSection('All models');
rest.forEach(_addRow);
}
} else if (!recentModels.length && !favModels.length) {
// Large catalog, nothing pinned yet — point them at the search box.
const hint = document.createElement('div');
hint.className = 'model-switch-empty mp-empty-hint';
hint.innerHTML =
'<span class="mp-empty-title">Search ' + all.length + ' models</span>'
+ '<span class="mp-empty-sub">Picks land in Recent · tap the dot to favorite</span>';
listEl.appendChild(hint);
} else {
// Large catalog: show provider groups with collapsible sections.
const rest = all.filter(m => !shown.has(m.mid));
const groups = new Map();
rest.forEach(m => {
const slug = _providerSlug(m.mid);
if (!groups.has(slug)) groups.set(slug, []);
groups.get(slug).push(m);
});
const sorted = [...groups.keys()].sort((a, b) =>
_providerDisplayName(a).localeCompare(_providerDisplayName(b)));
sorted.forEach(provider => {
const models = groups.get(provider);
const isCollapsed = _collapsedProviders.has(provider);
const header = document.createElement('div');
header.className = 'mp-provider-header';
header.innerHTML =
`<svg class="mp-provider-chevron${isCollapsed ? ' collapsed' : ''}" 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 9 12 15 18 9"/></svg>`
+ `<span class="mp-provider-name">${_providerDisplayName(provider)}</span>`
+ `<span class="mp-provider-count">${models.length}</span>`;
header.addEventListener('click', (e) => {
e.stopPropagation();
if (_collapsedProviders.has(provider)) {
_collapsedProviders.delete(provider);
_justExpandedProvider = provider;
} else {
_collapsedProviders.add(provider);
_justExpandedProvider = null;
}
_saveList('odysseus-model-collapsed', [..._collapsedProviders]);
const st = listEl.scrollTop;
_populate('');
listEl.scrollTop = st;
});
listEl.appendChild(header);
if (!isCollapsed) {
const group = document.createElement('div');
group.className = 'mp-provider-group' + (_justExpandedProvider === provider ? ' mp-just-expanded' : '');
models.forEach(m => {
_addRow(m);
// Move the just-appended row into the group container
group.appendChild(listEl.lastElementChild);
});
listEl.appendChild(group);
if (_justExpandedProvider === provider) _justExpandedProvider = null;
}
});
}
}
+6 -2
View File
@@ -14,8 +14,12 @@ function _compareText(a, b) {
});
}
function _arrayOrEmpty(models) {
return Array.isArray(models) ? models : [];
}
export function sortModelIds(models) {
return (models || []).slice().sort(_compareText);
return _arrayOrEmpty(models).slice().sort(_compareText);
}
export function compareModelObjects(a, b) {
@@ -25,5 +29,5 @@ export function compareModelObjects(a, b) {
}
export function sortModelObjects(models) {
return (models || []).slice().sort(compareModelObjects);
return _arrayOrEmpty(models).slice().sort(compareModelObjects);
}
+1
View File
@@ -0,0 +1 @@
{ "type": "module" }
+47
View File
@@ -0,0 +1,47 @@
// ============================================
// Platform detection + AltGr-keystroke helper
// ============================================
// Shared by the keybind code: root keyboard-shortcuts.js, the editor's
// keyboard-shortcuts.js, and settings.js. Single source of truth so the three
// guards can't drift.
// AltGr (right Alt on AZERTY/QWERTZ and most non-US layouts, used to type
// @ # { } [ ] | \ and €) is reported by browsers as Ctrl+Alt. macOS is the
// exception: there the Option key — a normal part of Mac shortcuts — also sets
// the AltGraph modifier state, so it must NOT be treated as AltGr.
//
// IS_MAC covers all Apple platforms, iPad/iPhone included: a Magic Keyboard's
// Option key sets AltGraph exactly like a Mac's, so they need the same carve-out
// — narrowing to macOS-only would re-break them. The name and the
// /Mac|iPhone|iPad/ test deliberately mirror the existing isMac checks in
// calendar.js and sessions.js; this is their single shared source of truth.
export const IS_MAC =
/Mac|iPhone|iPad/.test((typeof navigator !== 'undefined' && navigator.platform) || '') ||
/Mac/.test((typeof navigator !== 'undefined' && navigator.userAgent) || '');
// True when `e` is an AltGr keystroke we should ignore for Ctrl+Alt shortcut
// purposes. getModifierState('AltGraph') is true for AltGr but false for a
// genuine left Ctrl+Alt, so real shortcuts still work. Always false on macOS,
// where Option legitimately sets AltGraph.
//
// We also require ctrlKey+altKey: the collision we defend against is precisely
// "AltGr reported AS Ctrl+Alt", so an event that asserts AltGraph WITHOUT
// presenting as Ctrl+Alt (a Linux ISO_Level3_Shift layout, a stray modifier
// state) is left alone instead of being swallowed.
//
// Trade-off: on Windows AltGr *is* Ctrl+right-Alt, so a deliberate
// Ctrl+Alt+<char> shortcut typed via AltGr is unreachable too — accepted; use
// the left Ctrl+Alt.
//
// NOTE: the AltGr -> AltGraph mapping is taken from the UI Events spec / MDN,
// not proven by our tests. Older Firefox and some Linux setups historically did
// not report AltGraph; where a browser sets ctrlKey+altKey without it this
// guard is simply a no-op (the pre-fix behaviour) rather than a regression.
export function isAltGrEvent(e, isMac = IS_MAC) {
return (
!isMac &&
!!e.ctrlKey &&
!!e.altKey &&
!!(e.getModifierState && e.getModifierState('AltGraph'))
);
}
+30 -12
View File
@@ -8,6 +8,24 @@ let API_BASE = '';
let selectedPreset = null;
let presets = {};
export function loadStoredArray(key) {
try {
const value = JSON.parse(localStorage.getItem(key) || '[]');
return Array.isArray(value) ? value : [];
} catch (e) {
return [];
}
}
export function loadStoredObject(key) {
try {
const value = JSON.parse(localStorage.getItem(key) || '{}');
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
} catch (e) {
return {};
}
}
// Built-in prompt templates (moved from cot_prompts.py)
export const PROMPT_TEMPLATES = [
{
@@ -220,7 +238,7 @@ function initNameDropdown() {
if (!charName || charName === '__default__') return;
const match = userTemplates.find(t => t.name === charName);
const isBuiltin = PROMPT_TEMPLATES.some(t => t.name === charName);
if (!await window.styledConfirm(`Delete "${charName}"?\n\nThis will remove the character and all its memories.`, { confirmText: 'Delete', danger: true })) return;
if (!await window.styledConfirm(`Delete "${charName}"?\n\nThis will remove the persona and all its memories.`, { confirmText: 'Delete', danger: true })) return;
try {
// Delete saved template if exists
if (match) {
@@ -228,7 +246,7 @@ function initNameDropdown() {
}
// Hide built-in preset
if (isBuiltin) {
const hidden = JSON.parse(localStorage.getItem('odysseus-hidden-presets') || '[]');
const hidden = loadStoredArray('odysseus-hidden-presets');
if (!hidden.includes(charName)) hidden.push(charName);
localStorage.setItem('odysseus-hidden-presets', JSON.stringify(hidden));
}
@@ -296,7 +314,7 @@ function _populateCharSelect() {
const select = document.getElementById('char-template-select');
if (!select) return;
const currentVal = select.value;
select.innerHTML = '<option value="__default__">Default (no character)</option>';
select.innerHTML = '<option value="__default__">Default (no persona)</option>';
const savedNames = new Set(userTemplates.map(t => t.name));
if (userTemplates.length) {
@@ -311,7 +329,7 @@ function _populateCharSelect() {
select.appendChild(group);
}
const hiddenPresets = JSON.parse(localStorage.getItem('odysseus-hidden-presets') || '[]');
const hiddenPresets = loadStoredArray('odysseus-hidden-presets');
const builtins = PROMPT_TEMPLATES.filter(t => !savedNames.has(t.name) && !hiddenPresets.includes(t.name));
if (builtins.length) {
const group = document.createElement('optgroup');
@@ -405,7 +423,7 @@ function initPersistentChat() {
await fetch(`${API_BASE}/api/session/${sessionId}/important`, { method: 'POST', body: favFd });
// Save session → character mapping so it restores on switch
const charSessions = JSON.parse(localStorage.getItem('odysseus-char-sessions') || '{}');
const charSessions = loadStoredObject('odysseus-char-sessions');
charSessions[sessionId] = charName;
localStorage.setItem('odysseus-char-sessions', JSON.stringify(charSessions));
@@ -437,7 +455,7 @@ function initSaveAsTemplate() {
let name = nameInput ? nameInput.value.trim() : '';
if (!name) {
name = prompt('Enter a name for this character:');
name = prompt('Enter a name for this persona:');
if (!name || !name.trim()) return;
name = name.trim();
if (nameInput) nameInput.value = name;
@@ -616,7 +634,7 @@ export function openCustomPresetModal() {
} else {
// Character/persona tab. "Save & " prefix when the user edited a template,
// so it's clear the edit is being saved on start.
label = changed ? 'Save & Start Character' : 'Start Character';
label = changed ? 'Save & Start Persona' : 'Start Persona';
}
btn.textContent = label;
// Show a "Cancel" button next to Start when the active tab's feature is
@@ -708,7 +726,7 @@ export function openCustomPresetModal() {
const notice = document.createElement('div');
notice.id = 'char-lock-notice';
notice.style.cssText = 'font-size:11px;color:var(--color-muted);text-align:center;padding:6px;margin-bottom:8px;border:1px dashed var(--border);border-radius:6px;';
notice.textContent = 'Persistent chat — character is locked. Style, temperature, and memory can still be changed.';
notice.textContent = 'Persistent chat — persona is locked. Style, temperature, and memory can still be changed.';
modal.querySelector('.modal-body').prepend(notice);
}
} else {
@@ -825,7 +843,7 @@ export async function saveCustomPreset(showToast, showError) {
if (showToast) {
// The Inject tab is a plain tuned "prompt" chat, not a persona — say so.
showToast(_isInjectStart ? 'Prompt saved' : 'Character saved');
showToast(_isInjectStart ? 'Prompt saved' : 'Persona saved');
}
const modal = document.getElementById('custom-preset-modal');
if (modal) {
@@ -962,7 +980,7 @@ function _syncCharIndicator() {
if (hasChar) {
if (iconEl) iconEl.innerHTML = _AVATAR;
if (nameSpan) nameSpan.textContent = custom.character_name;
btn.title = `Character: ${custom.character_name} — click to configure`;
btn.title = `Persona: ${custom.character_name} — click to configure`;
} else {
// Inject/tuning chat — syringe tag labeled "Prompt" to match the
// window identity, no persona name.
@@ -1011,7 +1029,7 @@ function _syncCharIndicator() {
let _prevSessionId = null;
export function onSessionSwitch(sessionId) {
const charSessions = JSON.parse(localStorage.getItem('odysseus-char-sessions') || '{}');
const charSessions = loadStoredObject('odysseus-char-sessions');
// Leaving a persistent chat — deactivate for this switch only
if (window._persistentChatSession) {
@@ -1059,7 +1077,7 @@ export function isPersistentChat() {
* Remove a session from persistent chat mappings (call when session is deleted).
*/
export function removePersistentChat(sessionId) {
const charSessions = JSON.parse(localStorage.getItem('odysseus-char-sessions') || '{}');
const charSessions = loadStoredObject('odysseus-char-sessions');
if (charSessions[sessionId]) {
delete charSessions[sessionId];
localStorage.setItem('odysseus-char-sessions', JSON.stringify(charSessions));
+2 -2
View File
@@ -32,8 +32,8 @@ const _PROVIDERS = [
[/meta|llama(?![.\-_ ]?cpp)/i,
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z"/></svg>'],
// Mistral AI (official Simple Icons)
[/mistral/i,
// Mistral AI (official Simple Icons). Match Mixtral and Ministral too.
[/mi[sx]tral|ministral/i,
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.143 3.429v3.428h-3.429v3.429h-3.428V6.857H6.857V3.43H3.43v13.714H0v3.428h10.286v-3.428H6.857v-3.429h3.429v3.429h3.429v-3.429h3.428v3.429h-3.428v3.428H24v-3.428h-3.43V3.429z"/></svg>'],
// Qwen (Tongyi Qianwen) — official geometric hexagonal logo
+59 -10
View File
@@ -78,6 +78,42 @@ function _deselectCurrentSession(sid) {
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
}
function _removeSessionFromLocalState(sid) {
if (!sid) return;
const id = String(sid);
sessions = sessions.filter(s => String(s.id) !== id);
_selectedIds.delete(id);
try {
const savedOrder = Storage.get('session-order');
if (savedOrder) {
const orderIds = JSON.parse(savedOrder);
if (Array.isArray(orderIds) && orderIds.some(x => String(x) === id)) {
Storage.set('session-order', JSON.stringify(orderIds.filter(x => String(x) !== id)));
}
}
} catch (e) {
console.warn('Failed to prune deleted session order:', e);
}
document.querySelectorAll('.list-item[data-session-id]').forEach(el => {
if (String(el.dataset.sessionId) === id) el.remove();
});
_deselectCurrentSession(id);
}
function _normalizeSessionsList(fetched) {
if (!Array.isArray(fetched)) return [];
const seen = new Set();
const unique = [];
for (const session of fetched) {
if (!session || session.id == null) continue;
const id = String(session.id);
if (seen.has(id)) continue;
seen.add(id);
unique.push(session);
}
return unique;
}
// Initialize dependencies from app.js (no-op: dependencies now imported directly)
export function initDependencies() {}
@@ -616,15 +652,17 @@ function createSessionItem(s) {
return;
}
dropdown.style.display = 'none';
// Optimistic: remove from UI immediately
const sessionEl = document.querySelector(`.list-item[data-session-id="${s.id}"]`);
if (sessionEl) sessionEl.remove();
if (!await uiModule.styledConfirm('Delete this session?', { confirmText: 'Delete', danger: true })) {
_forceSidebarOpen();
return;
}
const wasCurrentSession = currentSessionId === s.id;
// If streaming, abort it before deleting
if (wasCurrentSession && window.chatModule && window.chatModule.abortCurrentRequest) {
window.chatModule.abortCurrentRequest();
}
_deselectCurrentSession(s.id);
_removeSessionFromLocalState(s.id);
_skipAutoSelect = true;
// Clean up persistent chat mapping
try {
@@ -640,10 +678,11 @@ function createSessionItem(s) {
} else {
_forceSidebarOpen();
}
// Fire API and reload in background
fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' })
.then(() => loadSessions())
.catch(() => loadSessions());
// Await API deletion, then reload the authoritative list from the server
try {
await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' });
} catch (e) { /* network error — session may still exist server-side */ }
await loadSessions();
});
archiveItem.addEventListener('click', async () => {
@@ -1317,7 +1356,7 @@ export async function loadSessions() {
const res = await fetch(`${API_BASE}/api/sessions`);
fetched = await res.json();
}
sessions = fetched;
sessions = _normalizeSessionsList(fetched);
renderSessionList();
const sessionsSection = uiModule.el('sessions-section');
@@ -1606,7 +1645,15 @@ export async function selectSession(id, { keepSidebar = false } = {}) {
} else if (msgHistory.length) {
for (const msg of msgHistory) {
const meta = msg.metadata ? { ...msg.metadata, _fromHistory: true } : null;
let displayContent = typeof msg.content === 'string' ? msg.content : (msg.content ? String(msg.content) : '');
let displayContent;
if (typeof msg.content === 'string') {
displayContent = msg.content;
} else if (Array.isArray(msg.content)) {
// Multimodal (image/audio attachments): extract text parts, skip binary
displayContent = msg.content.filter(p => p.type === 'text').map(p => p.text).join('\n').trim();
} else {
displayContent = '';
}
// Clean up doc selection context for display
if (msg.role === 'user') {
// Hide "Continue where you left off" bubbles
@@ -1871,7 +1918,7 @@ export function setCurrentSessionId(id) {
}
// Session list keyboard navigation: arrows to move, Delete to delete
function _onSessionListKeydown(e) {
async function _onSessionListKeydown(e) {
const item = e.target.closest('.list-item[data-session-id]');
if (!item) return;
@@ -1899,6 +1946,8 @@ function _onSessionListKeydown(e) {
uiModule.showToast('Unfavorite before deleting');
return;
}
const ok = await uiModule.styledConfirm('Delete this session?', { confirmText: 'Delete', danger: true });
if (!ok) return;
_sessionListFocused = true;
(async () => {
await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' });
+71 -3
View File
@@ -6,6 +6,7 @@ import searchModule from './search.js';
import { makeWindowDraggable } from './windowDrag.js';
import { clearDockSide } from './modalSnap.js';
import { sortModelIds } from './modelSort.js';
import { isAltGrEvent } from './platform.js';
let initialized = false;
let modalEl = null;
@@ -1074,6 +1075,7 @@ var _searchKeyFields = {
async function initSearchSettings() {
var provSel = el('set-searchProvider');
var countSel = el('set-searchResultCount');
var countCustomInput = el('set-searchResultCountCustom');
var urlInput = el('set-searchUrl');
var urlRow = el('set-searchUrlRow');
var keyInput = el('set-searchApiKey');
@@ -1105,15 +1107,37 @@ async function initSearchSettings() {
loadKeyForProvider(prov);
}
function updateCountDisplay() {
var val = _settings.search_result_count || 5;
var presets = ['3', '5', '10', '20'];
if (presets.includes(String(val))) {
countSel.value = String(val);
countCustomInput.style.display = 'none';
} else {
countSel.value = 'custom';
countCustomInput.value = Math.max(1, Math.min(100, val));
countCustomInput.style.display = 'block';
}
}
try {
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
_settings = await res.json();
if (_settings.search_provider) provSel.value = _settings.search_provider;
if (_settings.search_result_count) countSel.value = String(_settings.search_result_count);
updateCountDisplay();
if (_settings.search_url) urlInput.value = _settings.search_url;
if (_settings.google_pse_cx) cxInput.value = _settings.google_pse_cx;
} catch (e) { console.warn('Failed to load search settings', e); }
countSel.addEventListener('change', function() {
if (this.value === 'custom') {
countCustomInput.style.display = 'block';
countCustomInput.focus();
} else {
countCustomInput.style.display = 'none';
}
});
updateVisibility();
async function refreshStatus() {
@@ -1141,9 +1165,20 @@ async function initSearchSettings() {
async function saveSearch() {
try {
var prov = provSel.value;
var resultCount;
if (countSel.value === 'custom') {
var customVal = parseInt(countCustomInput.value, 10);
if (isNaN(customVal) || customVal < 1 || customVal > 100) {
resultCount = _settings.search_result_count || 5;
} else {
resultCount = customVal;
}
} else {
resultCount = parseInt(countSel.value, 10);
}
var payload = {
search_provider: prov,
search_result_count: parseInt(countSel.value, 10),
search_result_count: resultCount,
search_url: urlInput.value.trim(),
google_pse_cx: cxInput.value.trim(),
};
@@ -1367,6 +1402,7 @@ async function initResearchSettings() {
var tokensInput = el('set-researchMaxTokens');
var extractTimeoutInput = el('set-researchExtractTimeout');
var extractConcurrencyInput = el('set-researchExtractConcurrency');
var runTimeoutInput = el('set-researchRunTimeout');
var msg = el('set-researchMsg');
var endpoints = [];
@@ -1389,6 +1425,9 @@ async function initResearchSettings() {
if (settings.research_max_tokens) tokensInput.value = settings.research_max_tokens;
if (settings.research_extraction_timeout_seconds) extractTimeoutInput.value = settings.research_extraction_timeout_seconds;
if (settings.research_extraction_concurrency) extractConcurrencyInput.value = settings.research_extraction_concurrency;
if (settings.research_run_timeout_seconds !== undefined && settings.research_run_timeout_seconds !== null) {
runTimeoutInput.value = settings.research_run_timeout_seconds;
}
} catch (e) { console.warn('Failed to load research settings', e); }
function showStatus() {
@@ -1407,6 +1446,12 @@ async function initResearchSettings() {
if (extractConcurrencyInput.value) {
parts.push('Parallel: ' + extractConcurrencyInput.value);
}
if (runTimeoutInput.value !== '') {
var rtv = parseInt(runTimeoutInput.value, 10);
if (!isNaN(rtv)) {
parts.push(rtv === 0 ? 'Max time: no limit' : 'Max time: ' + rtv + 's');
}
}
if (parts.length) {
msg.textContent = parts.join(' · ');
msg.style.color = 'var(--fg)';
@@ -1425,9 +1470,16 @@ async function initResearchSettings() {
var tv = parseInt(tokensInput.value, 10);
if (tv && tv >= 1024) payload.research_max_tokens = tv;
var et = parseInt(extractTimeoutInput.value, 10);
if (et && et >= 15 && et <= 600) payload.research_extraction_timeout_seconds = et;
if (et && et >= 15 && et <= 3600) payload.research_extraction_timeout_seconds = et;
var ec = parseInt(extractConcurrencyInput.value, 10);
if (ec && ec >= 1 && ec <= 12) payload.research_extraction_concurrency = ec;
if (runTimeoutInput.value !== '') {
var rt = parseInt(runTimeoutInput.value, 10);
// 0 = no limit (disables the hard timeout); otherwise 60s..86400s (24h)
if (!isNaN(rt) && (rt === 0 || (rt >= 60 && rt <= 86400))) {
payload.research_run_timeout_seconds = rt;
}
}
try {
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
@@ -1446,6 +1498,7 @@ async function initResearchSettings() {
tokensInput.addEventListener('change', saveResearch);
extractTimeoutInput.addEventListener('change', saveResearch);
extractConcurrencyInput.addEventListener('change', saveResearch);
runTimeoutInput.addEventListener('change', saveResearch);
_registerAiEndpointRefresh(function(nextEndpoints) {
endpoints = nextEndpoints;
@@ -1710,6 +1763,10 @@ function _formatKeyCaps(combo) {
}
function _comboFromEvent(e) {
// Drop a stray AltGr keystroke (e.g. AltGr+E to type €) so it isn't recorded
// as a bogus ctrl+alt+<char> binding — onKey ignores empty combos. See
// platform.js for the macOS carve-out and Windows trade-off.
if (isAltGrEvent(e)) return '';
const parts = [];
if (e.ctrlKey || e.metaKey) parts.push('ctrl');
if (e.altKey) parts.push('alt');
@@ -2555,6 +2612,7 @@ async function initEmailAccountsSettings() {
const _providerOptions = Object.entries(PROVIDERS)
.map(([k, v]) => `<option value="${k}">${esc(v.label)}</option>`)
.join('');
const _smtpSecurity = (acct) => acct?.smtp_security || ((parseInt(acct?.smtp_port || 465) === 587) ? 'starttls' : 'ssl');
formEl.innerHTML = `
<h3 style="font-size:12px;margin:0 0 8px">${isEdit ? 'Edit Account' : 'New Account'}</h3>
<div class="settings-col">
@@ -2570,6 +2628,7 @@ async function initEmailAccountsSettings() {
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px">SMTP (Sending) <span style="font-weight:normal;opacity:0.7"> optional, leave blank for read-only</span></div>
<div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com, smtp.migadu.com. Leave blank to make this account read-only.')}</label><input id="eaf-smtp-host" class="settings-input" value="${esc(a.smtp_host || '')}"></div>
<div class="settings-row"><label class="settings-label">Port${_hint('465 for SSL/SMTPS, 587 for STARTTLS. 25 is usually blocked by ISPs.')}</label><input id="eaf-smtp-port" class="settings-input" type="number" value="${esc(a.smtp_port || 465)}" style="max-width:100px"></div>
<div class="settings-row"><label class="settings-label">Security${_hint('SSL for port 465, STARTTLS for port 587, or None for local SMTP bridges such as Proton Mail Bridge.')}</label><select id="eaf-smtp-security" class="settings-select"><option value="ssl">SSL</option><option value="starttls">STARTTLS</option><option value="none">None</option></select></div>
<div class="settings-row"><label class="settings-label">Same as IMAP${_hint('Use the IMAP username and password for SMTP too (this is right for almost every provider). Turn off to enter separate SMTP credentials.')}</label><label class="admin-switch"><input type="checkbox" id="eaf-smtp-same" ${(!isEdit || (a.smtp_user && a.imap_user && a.smtp_user === a.imap_user)) ? 'checked' : ''}><span class="admin-slider"></span></label></div>
<div class="settings-row eaf-smtp-creds"><label class="settings-label">Username${_hint('Usually the same as your IMAP username (your email address).')}</label><input id="eaf-smtp-user" class="settings-input" value="${esc(a.smtp_user || '')}"></div>
<div class="settings-row eaf-smtp-creds"><label class="settings-label">Password${_hint('Your SMTP password — often the same as your IMAP password.')}</label><input id="eaf-smtp-pass" class="settings-input" type="password" placeholder="${isEdit && a.has_smtp_password ? '(unchanged)' : ''}"></div>
@@ -2596,7 +2655,9 @@ async function initEmailAccountsSettings() {
el('eaf-imap-starttls').checked = !!p.imap.starttls;
el('eaf-smtp-host').value = p.smtp.host;
el('eaf-smtp-port').value = p.smtp.port;
el('eaf-smtp-security').value = p.smtp.security || ((parseInt(p.smtp.port || 465) === 587) ? 'starttls' : 'ssl');
});
el('eaf-smtp-security').value = _smtpSecurity(a);
// "Same as IMAP" toggle — hide the SMTP creds rows when on. The save
// handler copies the IMAP user/password into SMTP at submit time.
@@ -2620,6 +2681,7 @@ async function initEmailAccountsSettings() {
imap_starttls: el('eaf-imap-starttls').checked,
smtp_host: el('eaf-smtp-host').value.trim(),
smtp_port: parseInt(el('eaf-smtp-port').value) || 465,
smtp_security: el('eaf-smtp-security').value,
smtp_user: el('eaf-smtp-user').value.trim(),
};
if (el('eaf-imap-pass').value) body.imap_password = el('eaf-imap-pass').value;
@@ -3642,6 +3704,7 @@ async function initUnifiedIntegrations() {
};
const _providerOptions = Object.entries(PROVIDERS)
.map(([k, v]) => `<option value="${k}">${esc(v.label)}</option>`).join('');
const _smtpSecurity = (acct) => acct?.smtp_security || ((parseInt(acct?.smtp_port || 465) === 587) ? 'starttls' : 'ssl');
formEl.innerHTML = `
<div class="admin-card" style="margin-top:8px">
<h2 style="font-size:13px">${isEdit ? 'Edit' : 'Add'} Email Account</h2>
@@ -3659,6 +3722,7 @@ async function initUnifiedIntegrations() {
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px">SMTP (Sending) <span style="font-weight:normal;opacity:0.7"> optional, leave blank for read-only</span></div>
<div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com. Leave blank to make this account read-only.')}</label><input id="uf-smtp-host" class="settings-input" placeholder="smtp.example.com"></div>
<div class="settings-row"><label class="settings-label">Port${_hint('465 for SSL/SMTPS, 587 for STARTTLS. 25 is usually blocked by ISPs.')}</label><input id="uf-smtp-port" class="settings-input" type="number" placeholder="465" style="max-width:100px"></div>
<div class="settings-row"><label class="settings-label">Security${_hint('SSL for port 465, STARTTLS for port 587, or None for local SMTP bridges such as Proton Mail Bridge.')}</label><select id="uf-smtp-security" class="settings-select"><option value="ssl">SSL</option><option value="starttls">STARTTLS</option><option value="none">None</option></select></div>
<div class="settings-row"><label class="settings-label">Same as IMAP${_hint('Use the IMAP username and password for SMTP too (right for almost every provider). Turn off to enter separate SMTP credentials.')}</label><label class="admin-switch" style="margin-left:0"><input type="checkbox" id="uf-smtp-same" checked><span class="admin-slider"></span></label></div>
<div class="settings-row uf-smtp-creds"><label class="settings-label">Username${_hint('Usually the same as your IMAP username (your email address).')}</label><input id="uf-smtp-user" class="settings-input"></div>
<div class="settings-row uf-smtp-creds"><label class="settings-label">Password${_hint('Your SMTP password — often the same as your IMAP password.')}</label><input id="uf-smtp-pass" class="settings-input" type="password" placeholder="${placeholderPass}"></div>
@@ -3785,6 +3849,7 @@ async function initUnifiedIntegrations() {
el('uf-imap-starttls').checked = !!p.imap.starttls;
el('uf-smtp-host').value = p.smtp.host;
el('uf-smtp-port').value = p.smtp.port;
el('uf-smtp-security').value = p.smtp.security || ((parseInt(p.smtp.port || 465) === 587) ? 'starttls' : 'ssl');
if (p.emailEx) {
el('uf-email-from').placeholder = p.emailEx;
el('uf-imap-user').placeholder = p.emailEx;
@@ -3810,6 +3875,7 @@ async function initUnifiedIntegrations() {
el('uf-imap-starttls').checked = existing.imap_starttls !== false;
el('uf-smtp-host').value = existing.smtp_host || '';
el('uf-smtp-port').value = existing.smtp_port || 465;
el('uf-smtp-security').value = _smtpSecurity(existing);
el('uf-smtp-user').value = existing.smtp_user || '';
el('uf-email-default').checked = !!existing.is_default;
// If the saved SMTP user matches the IMAP user, keep the "Same as
@@ -3821,6 +3887,7 @@ async function initUnifiedIntegrations() {
} else {
el('uf-imap-port').value = 993;
el('uf-smtp-port').value = 465;
el('uf-smtp-security').value = 'ssl';
}
el('uf-email-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
@@ -3856,6 +3923,7 @@ async function initUnifiedIntegrations() {
imap_starttls: el('uf-imap-starttls').checked,
smtp_host: el('uf-smtp-host').value.trim(),
smtp_port: parseInt(el('uf-smtp-port').value) || 465,
smtp_security: el('uf-smtp-security').value,
smtp_user: el('uf-smtp-user').value.trim(),
is_default: el('uf-email-default').checked,
};
+7 -4
View File
@@ -7,6 +7,7 @@ import markdownModule from './markdown.js';
import * as spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js';
import { sortModelIds } from './modelSort.js';
import { ordinalSuffix } from './util/ordinal.js';
const API_BASE = window.location.origin;
let _open = false;
@@ -244,7 +245,7 @@ function _scheduleLabel(task) {
}
if (task.schedule === 'monthly') {
const d = task.scheduled_day ?? 1;
const suffix = d === 1 ? 'st' : d === 2 ? 'nd' : d === 3 ? 'rd' : 'th';
const suffix = ordinalSuffix(d);
return `Monthly on ${d}${suffix} at ${localTime}`;
}
return task.schedule || '—';
@@ -2253,8 +2254,9 @@ function _renderActivityEntry(entry) {
const hue = _categoryHue(entry.taskName, entry.kind);
// CSS vars feed the colored title + accent stripe.
const styleVars = `--cat-hue:${hue};`;
const _runningPlaceholder = /^(Starting…|Starting\.\.\.|_Running…_|_Running\.\.\._|_Queued\b)/i.test((entry.result || '').trim());
const hasResult = !!(entry.result && entry.result.trim() && entry.status !== 'running' && entry.status !== 'queued');
const hasRunningProgress = !!(entry.result && entry.result.trim() && (entry.status === 'running' || entry.status === 'queued'));
const hasRunningProgress = !!(entry.result && entry.result.trim() && !_runningPlaceholder && (entry.status === 'running' || entry.status === 'queued'));
// "Open in chat" only makes sense for runs whose result is a real assistant
// message (Prompt / Research tasks). Action/event runs are just log lines
// (e.g. "No recent emails", "Tidied N memories") — for those, replace the
@@ -2299,11 +2301,12 @@ function _renderActivityEntry(entry) {
let rightHtml;
if (_isRunning) {
const isQueued = entry.status === 'queued';
const label = isQueued ? 'Queued' : 'Running';
// Initial elapsed for the first paint; the 1s interval below keeps it live.
const startMs = entry.ts ? new Date(entry.ts).getTime() : Date.now();
const stale = !isQueued && (Date.now() - startMs) > 30 * 60 * 1000;
const label = isQueued ? 'Queued' : stale ? 'Still running' : 'Running';
const elapsedInit = isQueued ? '' : `<span class="task-log-running-elapsed" data-since="${startMs}">${_fmtElapsed(Date.now() - startMs)}</span>`;
const forceBtn = isQueued && entry.taskId ? `<button class="task-log-force-run" type="button" title="Start now in parallel, bypassing the queue" style="border:0;background:transparent;box-shadow:none;margin-left:5px;padding:0;width:12px;height:12px;display:inline-flex;align-items:center;justify-content:center;font-size:10px;line-height:1;color:inherit;opacity:.8;"><svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor" style="display:block;"><polygon points="6 4 20 12 6 20 6 4"/></svg></button>` : '';
const forceBtn = isQueued && entry.taskId ? `<button class="task-log-force-run" type="button" title="Start now in parallel, bypassing the queue"><svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor"><polygon points="6 4 20 12 6 20 6 4"/></svg><span>Start now</span></button>` : '';
const stopBtn = entry.taskId ? `<button class="task-log-stop" type="button" title="Stop this task"><svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="1"/></svg></button>` : '';
rightHtml = `<span class="task-log-running-inline"><span class="task-log-running-label">${label}</span>${elapsedInit}<span data-spin-here="1"></span>${forceBtn}${stopBtn}</span>`;
} else {
+8 -9
View File
@@ -4,6 +4,7 @@
import Storage from './storage.js';
import uiModule from './ui.js';
import { initColorPickers, attachColorPicker } from './colorPicker.js';
import { hexToRgb } from './color/hex.js';
import { makeWindowDraggable } from './windowDrag.js';
import { snapModalToZone } from './tileManager.js';
@@ -128,10 +129,10 @@ function _syncCustomThemesToServer(ct) {
// --- Syntax color derivation from theme base colors ---
function hexToHSL(hex) {
hex = hex.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16) / 255;
const g = parseInt(hex.substring(2, 4), 16) / 255;
const b = parseInt(hex.substring(4, 6), 16) / 255;
const rgb = hexToRgb(hex) || { r: 0, g: 0, b: 0 };
const r = rgb.r / 255;
const g = rgb.g / 255;
const b = rgb.b / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) { h = s = 0; }
@@ -1797,8 +1798,7 @@ function _initPerlinFlow() {
if (bg !== _cachedBg) {
_cachedBg = bg;
// Parse hex to rgb for rgba fade
const h = bg.replace('#', '');
const r = parseInt(h.substring(0, 2), 16), g = parseInt(h.substring(2, 4), 16), b = parseInt(h.substring(4, 6), 16);
const { r, g, b } = hexToRgb(bg) || { r: 0, g: 0, b: 0 };
_fadeStyle = `rgba(${r},${g},${b},0.02)`;
}
return _fadeStyle;
@@ -1982,9 +1982,8 @@ function _initEmbers() {
return s.getPropertyValue('--bg-effect-color').trim() || s.getPropertyValue('--fg').trim() || '#c9a95a';
}
function rgba(hex, a) {
const h = hex.replace('#', '');
const n = parseInt(h, 16);
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
const { r, g, b } = hexToRgb(hex) || { r: 0, g: 0, b: 0 };
return `rgba(${r},${g},${b},${a})`;
}
function draw() {
if (!document.body.classList.contains('bg-pattern-embers')) {
+42 -6
View File
@@ -519,7 +519,20 @@ export function getAutoScroll() {
export function autoResize(textarea) {
const lineHeight = parseInt(getComputedStyle(textarea).lineHeight);
const isMobile = window.innerWidth <= 768;
const maxHeight = isMobile ? 150 : lineHeight * 8;
const autoMaxHeight = isMobile ? 150 : lineHeight * 8;
// Keep a height chosen with the native desktop resize handle. Automatic
// changes are recorded before the observer runs, so only a real drag
// updates the manual floor.
if (!textarea._manualResizeObserver && typeof ResizeObserver !== 'undefined') {
textarea._manualResizeObserver = new ResizeObserver(() => {
const height = textarea.offsetHeight;
if (Math.abs(height - (textarea._autoResizeHeight || height)) > 1) {
textarea._manualResizeHeight = height;
}
});
textarea._manualResizeObserver.observe(textarea);
}
// Use a hidden clone to measure without disrupting the real textarea
let clone = textarea._resizeClone;
@@ -539,9 +552,12 @@ export function autoResize(textarea) {
clone.style.width = textarea.offsetWidth + 'px';
clone.value = textarea.value;
clone.style.height = '0';
const newHeight = Math.min(Math.max(clone.scrollHeight, lineHeight), maxHeight);
const manualHeight = textarea._manualResizeHeight || 0;
const maxHeight = Math.max(autoMaxHeight, manualHeight);
const newHeight = Math.min(Math.max(clone.scrollHeight, lineHeight, manualHeight), maxHeight);
textarea._autoResizeHeight = newHeight;
textarea.style.height = newHeight + 'px';
textarea.style.overflow = newHeight >= maxHeight ? 'auto' : 'hidden';
textarea.style.overflow = newHeight >= autoMaxHeight ? 'auto' : 'hidden';
}
/**
@@ -579,8 +595,8 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
overlay.id = 'styled-confirm-overlay';
overlay.className = 'modal';
overlay.innerHTML =
'<div class="modal-content styled-confirm-box">' +
'<div class="modal-header"><h4>Confirm</h4></div>' +
'<div class="modal-content styled-confirm-box" role="dialog" aria-modal="true" aria-labelledby="styled-confirm-title" aria-describedby="styled-confirm-msg">' +
'<div class="modal-header"><h4 id="styled-confirm-title">Confirm</h4></div>' +
'<div class="modal-body"><p id="styled-confirm-msg"></p></div>' +
'<div class="modal-footer">' +
'<button id="styled-confirm-cancel"></button>' +
@@ -600,6 +616,8 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
okBtn.className = danger ? 'confirm-btn confirm-btn-danger' : 'confirm-btn confirm-btn-primary';
cancelBtn.className = 'confirm-btn confirm-btn-secondary';
// Remember what had focus so we can restore it when the dialog closes.
const _prevFocus = document.activeElement;
overlay.classList.remove('hidden');
overlay.style.display = '';
@@ -610,6 +628,7 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
cancelBtn.removeEventListener('click', onCancel);
overlay.removeEventListener('click', onBackdrop);
document.removeEventListener('keydown', onKey);
try { _prevFocus && _prevFocus.focus && _prevFocus.focus(); } catch {}
resolve(result);
}
function onOk() { cleanup(true); }
@@ -626,6 +645,13 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
e.stopPropagation();
e.stopImmediatePropagation();
cleanup(false);
} else if (e.key === 'Tab') {
// Trap focus inside the dialog so Tab can't wander to the page behind.
e.preventDefault();
const f = [cancelBtn, okBtn];
const i = f.indexOf(document.activeElement);
const n = e.shiftKey ? (i <= 0 ? f.length - 1 : i - 1) : (i >= f.length - 1 ? 0 : i + 1);
f[n].focus();
}
}
@@ -656,7 +682,7 @@ export function styledPrompt(message, {
overlay.id = 'styled-prompt-overlay';
overlay.className = 'modal';
overlay.innerHTML =
'<div class="modal-content styled-confirm-box styled-prompt-box">' +
'<div class="modal-content styled-confirm-box styled-prompt-box" role="dialog" aria-modal="true" aria-labelledby="styled-prompt-title" aria-describedby="styled-prompt-msg">' +
'<div class="modal-header"><h4 id="styled-prompt-title"></h4></div>' +
'<div class="modal-body">' +
'<p id="styled-prompt-msg"></p>' +
@@ -685,6 +711,8 @@ export function styledPrompt(message, {
okBtn.textContent = confirmText;
cancelBtn.textContent = cancelText;
// Remember what had focus so we can restore it when the dialog closes.
const _prevFocus = document.activeElement;
overlay.classList.remove('hidden');
overlay.style.display = '';
@@ -696,6 +724,7 @@ export function styledPrompt(message, {
overlay.removeEventListener('click', onBackdrop);
document.removeEventListener('keydown', onKey);
input.removeEventListener('keydown', onInputKey);
try { _prevFocus && _prevFocus.focus && _prevFocus.focus(); } catch {}
resolve(result);
}
function onOk() { cleanup((input.value || '').trim()); }
@@ -707,6 +736,13 @@ export function styledPrompt(message, {
e.stopPropagation();
e.stopImmediatePropagation();
cleanup(null);
} else if (e.key === 'Tab') {
// Trap focus inside the dialog (input → Cancel → OK → input …).
e.preventDefault();
const f = [input, cancelBtn, okBtn];
const i = f.indexOf(document.activeElement);
const n = e.shiftKey ? (i <= 0 ? f.length - 1 : i - 1) : (i >= f.length - 1 ? 0 : i + 1);
f[n].focus();
}
}
function onInputKey(e) {
+13
View File
@@ -0,0 +1,13 @@
// Pure (browser-free) English ordinal suffix, e.g. 1 -> "st", 21 -> "st",
// 22 -> "nd", 23 -> "rd", 11/12/13 -> "th". Extracted so it can be unit-tested.
export function ordinalSuffix(n) {
const a = Math.abs(Math.trunc(Number(n) || 0));
const mod100 = a % 100;
if (mod100 >= 11 && mod100 <= 13) return 'th';
switch (a % 10) {
case 1: return 'st';
case 2: return 'nd';
case 3: return 'rd';
default: return 'th';
}
}
+6
View File
@@ -63,6 +63,7 @@ export function makeWindowDraggable(modal, options = {}) {
const onExitFullscreen = options.onExitFullscreen || null;
const enableFullscreen = options.enableFullscreen !== false && !!onEnterFullscreen;
const onDragEnd = options.onDragEnd || null;
const onDragStart = options.onDragStart || null;
const skipSelector = options.skipSelector || 'button, input, select';
const mobileSkip = (typeof options.mobileSkip === 'number') ? options.mobileSkip : 768;
const enableTouch = options.enableTouch !== false;
@@ -147,7 +148,11 @@ export function makeWindowDraggable(modal, options = {}) {
const _startDrag = (cx, cy) => {
dragging = true;
if (modal) modal.classList.add('modal-dragging');
const rect = content.getBoundingClientRect();
if (onDragStart) {
try { onDragStart({ rect, cx, cy }); } catch (_) {}
}
startX = cx; startY = cy;
startLeft = rect.left; startTop = rect.top;
// Pin position so the drag follows the cursor instead of fighting a
@@ -237,6 +242,7 @@ export function makeWindowDraggable(modal, options = {}) {
const _onEnd = (cx, cy) => {
if (!dragging) return;
dragging = false;
if (modal) modal.classList.remove('modal-dragging');
_showSnapHint(false);
// Top edge wins over side edges — fullscreen is the more common gesture.
if (enableFullscreen && typeof cy === 'number' && cy <= SNAP_PX) {