Checkpoint Odysseus local update

This commit is contained in:
pewdiepie-archdaemon
2026-07-07 00:50:07 +00:00
parent 5f6e6a2c4a
commit 017903de61
66 changed files with 22349 additions and 982 deletions
+124 -71
View File
@@ -54,7 +54,13 @@ window.adminModule = adminModule;
window.cookbookModule = cookbookModule;
function _isMobileChatInput() {
return window.innerWidth <= 768 || (window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
return window.innerWidth <= 768;
}
function _submitChatFormDirect(form) {
if (!form) return;
if (form.requestSubmit) form.requestSubmit();
else form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
}
function _isForegroundChatBusy() {
@@ -101,9 +107,7 @@ function _submitMobileQueuedInput(input) {
}
window.__odysseusQueueStreamingSubmit = now;
const form = document.getElementById('chat-form');
const submitBtn = form && form.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.click();
else if (form) form.requestSubmit ? form.requestSubmit() : form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
_submitChatFormDirect(form);
return true;
}
@@ -248,6 +252,50 @@ async function _createDirectChatFromPreferredModel() {
return false;
}
async function _hasUsableChatModel() {
try {
const pending = sessionModule?.getPendingChat?.();
if (pending && pending.url && pending.modelId) return true;
} catch (_) {}
try {
const current = sessionModule?.getSessions?.()
?.find(s => s.id === sessionModule?.getCurrentSessionId?.());
if (current && current.endpoint_url && current.model) return true;
} catch (_) {}
const dc = await _refreshDefaultChat();
if (dc && dc.endpoint_url && dc.model) return true;
try {
const items = window.modelsModule?.getCachedItems?.() || [];
if (items.some(item => !item.offline && ((item.models || []).length || (item.models_extra || []).length))) {
return true;
}
} catch (_) {}
try {
const res = await fetch(`${API_BASE}/api/models?background=false`, { credentials: 'same-origin' });
if (!res.ok) return false;
const data = await res.json();
return (data.items || []).some(item => !item.offline && ((item.models || []).length || (item.models_extra || []).length));
} catch (_) {
return false;
}
}
async function _syncWelcomeModelHint() {
const tip = document.getElementById('welcome-tip');
const sub = document.getElementById('welcome-sub');
if (!tip && !sub) return;
const hasModel = await _hasUsableChatModel();
if (hasModel) {
if (sub && !sub.dataset.researchOrigText) sub.textContent = 'New chat ready.';
if (tip) tip.textContent = 'Pick a model if you want, or just type.';
} else {
if (sub && !sub.dataset.researchOrigText) {
sub.innerHTML = 'Welcome, <span class="setup-trigger-link" style="color:var(--accent,var(--red));font-weight:600;cursor:pointer;text-decoration:underline;" title="Click to launch setup">type /setup</span> to get started.';
}
if (tip) tip.textContent = 'Add an AI endpoint from Settings in the sidebar, or paste an endpoint/API key into the chat.';
}
}
// ============================================
// EVENT LISTENERS INITIALIZATION
// ============================================
@@ -258,9 +306,9 @@ function initializeEventListeners() {
// File attachments (inside overflow menu)
const _overflowAttach = el('overflow-attach-btn');
if (_overflowAttach) _overflowAttach.addEventListener('click', fileHandlerModule.openPicker);
el('file-input').addEventListener('change', (e)=>{
for (const f of e.target.files) fileHandlerModule.addFiles([f]);
fileHandlerModule.renderAttachStrip();
el('file-input').addEventListener('change', async (e)=>{
await fileHandlerModule.addFiles(Array.from(e.target.files || []));
e.target.value = '';
// Refocus textarea after file picker closes (mobile keyboard)
const ta = el('message');
if (ta) setTimeout(() => ta.focus(), 100);
@@ -274,7 +322,7 @@ function initializeEventListeners() {
if (item.kind === 'file'){
const f = item.getAsFile();
if (f) {
fileHandlerModule.addFiles([f]);
await fileHandlerModule.addFiles([f]);
changed = true;
}
}
@@ -434,8 +482,10 @@ function initializeEventListeners() {
}
const body = child.querySelector('.body');
// Prefer dataset.raw (original markdown) over innerText (rendered HTML as text)
// to avoid extra newlines and formatting artifacts.
const text = body ? (body.dataset.raw || body.innerText || body.textContent || '').trim() : '';
// to avoid extra newlines and formatting artifacts. Raw text lives on
// the outer .msg in the main renderer; keep body.dataset.raw as a legacy
// fallback for older/reused render paths.
const text = (child.dataset?.raw || body?.dataset?.raw || body?.innerText || body?.textContent || '').trim();
if (text) parts.push(`${label}: ${text}`);
} else if (child.classList?.contains('agent-thread')) {
const lines = ['[Tool calls]'];
@@ -3176,10 +3226,7 @@ function initializeEventListeners() {
}, { passive: true });
})();
// New session button on icon rail
const railNewSession = el('rail-new-session');
if (railNewSession) {
railNewSession.addEventListener('click', async () => {
async function _handleNewChatAction({ preferModel = true, focus = true } = {}) {
if (!sessionModule) return;
if (_closeCompareIfActive()) return;
_deactivateIncognito();
@@ -3188,18 +3235,24 @@ function initializeEventListeners() {
// Clear research mode if active
const _resChk = el('research-toggle');
if (_resChk && _resChk.checked) _syncResearchIndicator(false);
if (await _createDirectChatFromPreferredModel()) return;
if (preferModel && await _createDirectChatFromPreferredModel()) return;
// No models at all — show welcome screen
sessionModule.setCurrentSessionId(null);
if (documentModule && documentModule.isPanelOpen && documentModule.isPanelOpen()) documentModule.closePanel();
_startFreshChat();
const docBtn3 = el('overflow-doc-btn');
if (docBtn3) docBtn3.classList.remove('active', 'has-docs');
const box = el('chat-history');
if (box) box.innerHTML = '';
if (chatModule && chatModule.showWelcomeScreen) {
chatModule.showWelcomeScreen();
}
document.querySelectorAll('.session-item.active').forEach(s => s.classList.remove('active'));
if (focus) {
const input = el('message');
if (input) { try { input.focus(); } catch (_) {} }
}
}
// New session button on icon rail
const railNewSession = el('rail-new-session');
if (railNewSession) {
railNewSession.addEventListener('click', async (e) => {
if (e) { e.preventDefault(); e.stopPropagation(); }
await _handleNewChatAction();
});
}
@@ -3226,31 +3279,17 @@ function initializeEventListeners() {
// Logo click → new chat (same logic as rail new-session button)
const brandBtn = el('sidebar-brand-btn');
if (brandBtn) {
brandBtn.addEventListener('click', async () => {
if (!sessionModule) return;
if (_closeCompareIfActive()) return;
_deactivateIncognito();
if (presetsModule && presetsModule.deactivateCharacter) presetsModule.deactivateCharacter();
// Clear research toggle when starting a fresh chat (not via research button)
_syncResearchIndicator(false);
if (await _createDirectChatFromPreferredModel()) return;
// No models at all — show welcome screen
sessionModule.setCurrentSessionId(null);
if (documentModule && documentModule.isPanelOpen && documentModule.isPanelOpen()) documentModule.closePanel();
const docBtn2 = el('overflow-doc-btn');
if (docBtn2) docBtn2.classList.remove('active', 'has-docs');
const box = el('chat-history');
if (box) box.innerHTML = '';
if (chatModule && chatModule.showWelcomeScreen) chatModule.showWelcomeScreen();
document.querySelectorAll('.session-item.active').forEach(s => s.classList.remove('active'));
brandBtn.addEventListener('click', async (e) => {
if (e) { e.preventDefault(); e.stopPropagation(); }
await _handleNewChatAction();
});
}
const sidebarNewChatBtn = el('sidebar-new-chat-btn');
if (sidebarNewChatBtn) {
sidebarNewChatBtn.addEventListener('click', () => {
const brandBtn = el('sidebar-brand-btn');
if (brandBtn) brandBtn.click();
sidebarNewChatBtn.addEventListener('click', async (e) => {
if (e) { e.preventDefault(); e.stopImmediatePropagation(); }
await _handleNewChatAction();
});
}
@@ -3339,8 +3378,7 @@ function initializeEventListeners() {
}
window.__odysseusQueueStreamingSubmit = Date.now();
}
const submitBtn = form.querySelector('button[type="submit"]');
if (submitBtn) submitBtn.click();
_submitChatFormDirect(form);
}
}
});
@@ -3519,7 +3557,7 @@ function initializeEventListeners() {
// Now submit the form (the /new command handler will process it)
setTimeout(() => {
const form = el('chat-form');
if (form) form.querySelector('button[type="submit"]').click();
_submitChatFormDirect(form);
}, 0);
}
};
@@ -3960,7 +3998,7 @@ function startOdysseusApp() {
}
window.__odysseusQueueStreamingSubmit = Date.now();
}
handleSubmit(e);
_submitChatFormDirect(document.getElementById('chat-form'));
}
});
}
@@ -4038,14 +4076,13 @@ function startOdysseusApp() {
_showDropHighlight();
});
chatContainer.addEventListener('drop', (e) => {
chatContainer.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
_hideDropHighlight();
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
fileHandlerModule.addFiles(files);
fileHandlerModule.renderAttachStrip();
await fileHandlerModule.addFiles(files);
uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to chat`);
});
@@ -4062,12 +4099,13 @@ function startOdysseusApp() {
attachStrip.style.borderRadius = '4px';
});
attachStrip.addEventListener('drop', (e) => {
attachStrip.addEventListener('drop', async (e) => {
e.preventDefault();
attachStrip.style.backgroundColor = '';
const files = Array.from(e.dataTransfer.files);
if (files.length === 0) return;
await fileHandlerModule.addFiles(files);
uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to chat`);
@@ -4135,14 +4173,13 @@ function startOdysseusApp() {
if (_compareActive() && !e.relatedTarget) _hideCmpShield();
}, true);
window.addEventListener('dragend', _hideCmpShield, true);
window.addEventListener('drop', (e) => {
window.addEventListener('drop', async (e) => {
if (!_isFileDrag(e) || !_compareActive()) return;
e.preventDefault();
_hideCmpShield();
const files = Array.from(e.dataTransfer.files || []);
if (!files.length) return;
fileHandlerModule.addFiles(files);
fileHandlerModule.renderAttachStrip();
await fileHandlerModule.addFiles(files);
uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to attach`);
}, true);
@@ -4177,24 +4214,40 @@ function startOdysseusApp() {
console.error('Session module not loaded!');
}
// Non-critical: load in parallel, resolve silently
modelsModule.refreshModels(false).then(() => {
try { sessionModule.updateModelPicker(); } catch (_) {}
const modelsBox = document.getElementById('models');
const hasModels = modelsBox && modelsBox.querySelector('.models-row');
if (!hasModels) {
const tip = document.getElementById('welcome-tip');
if (tip) tip.textContent = 'Add an AI endpoint from Settings in the sidebar, or paste an endpoint/API key into the chat.';
}
}).catch(() => {});
modelsModule.refreshProviders();
ragModule.loadPersonalDocs();
memoryModule.loadMemories(); // Ensure memories are loaded on page load
// Ensure the memory list is rendered after loading
setTimeout(async () => {
await memoryModule.loadMemories();
}, 1000);
const runNonCriticalStartup = (fn, delay = 4000) => {
let tries = 0;
const run = () => {
const busy = !!window.__odysseusChatBusy
|| Date.now() < (window.__odysseusChatBusyUntil || 0)
|| !!document.querySelector('.send-btn[data-mode="streaming"], .send-btn.send-pending');
if (busy && tries < 12) {
tries += 1;
setTimeout(run, 2500);
return;
}
try { fn(); } catch (e) { console.warn('non-critical startup task failed:', e); }
};
setTimeout(() => {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(run, { timeout: 5000 });
} else {
run();
}
}, delay);
};
// Non-critical startup work must not compete with first paint, chat send, or
// chat switching. Panels load their own data when opened; these are only warmups.
_syncWelcomeModelHint().catch(() => {});
runNonCriticalStartup(() => {
modelsModule.refreshModels(false).then(() => {
try { sessionModule.updateModelPicker(); } catch (_) {}
_syncWelcomeModelHint().catch(() => {});
}).catch(() => {});
}, 3500);
runNonCriticalStartup(() => modelsModule.refreshProviders(), 6500);
runNonCriticalStartup(() => ragModule.loadPersonalDocs(), 9000);
runNonCriticalStartup(() => memoryModule.loadMemories(), 12000);
// Ensure proper initial state
voiceRecorderModule.init();
+5 -33
View File
@@ -1356,7 +1356,7 @@
<div class="settings-sidebar">
<!-- Section 1: AI plumbing (Add Models → AI Defaults → Search) -->
<button class="settings-nav-item active" data-settings-tab="services">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1"/><circle cx="6" cy="18" r="1"/></svg>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
<span>Add Models</span>
</button>
<button class="settings-nav-item" data-settings-tab="added-models">
@@ -1979,34 +1979,6 @@
</div>
</div>
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg>Translation</h2>
<div class="settings-row" style="align-items:center;">
<div style="flex:1;min-width:0;">
<div class="settings-label" style="margin-bottom:3px;">Auto translate</div>
<div class="admin-toggle-sub" style="margin:0;">When an opened email appears to be in another language, prepare a translated view.</div>
</div>
<label class="admin-switch"><input type="checkbox" id="set-email-auto-translate"><span class="admin-slider"></span></label>
</div>
<div class="settings-row" style="align-items:center;margin-top:8px;">
<label class="settings-label" for="set-email-translate-language">Translate to</label>
<input id="set-email-translate-language" class="settings-select" list="set-email-translate-language-list" placeholder="English, Swedish, Japanese..." style="max-width:220px;">
<datalist id="set-email-translate-language-list">
<option value="English"></option>
<option value="Swedish"></option>
<option value="Norwegian"></option>
<option value="Danish"></option>
<option value="Japanese"></option>
<option value="Spanish"></option>
<option value="French"></option>
<option value="German"></option>
<option value="British English"></option>
<option value="Plain English"></option>
</datalist>
<span id="set-email-translate-msg" style="font-size:11px;margin-left:auto;"></span>
</div>
</div>
</div>
<!-- ═══ REMINDERS TAB ═══ -->
@@ -2122,7 +2094,7 @@
<!-- ── Local card ─────────────────────────────────────────── -->
<div class="admin-card">
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>Add Local Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoint)</span>
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M12 5v14"/><path d="M5 12h14"/></svg>Add Local Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoint)</span>
<span style="flex:1"></span>
<button class="admin-btn-sm" id="adm-epLocalTestBtn" style="font-size:11px;font-weight:normal;display:inline-flex;align-items:center;gap:4px;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test
@@ -2154,7 +2126,7 @@
<input id="adm-epLocalUrl" type="text" placeholder="Paste endpoint URL, e.g. http://localhost:11434/v1" style="flex:1;min-width:0;border-top-left-radius:0;border-bottom-left-radius:0;">
</div>
<button class="admin-btn-add" id="adm-epLocalAddBtn" style="min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;flex-shrink:0;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>Add
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>Add
</button>
</div>
<div class="admin-model-form-row" id="adm-epLocalApiKey-row" style="display:none;">
@@ -2167,7 +2139,7 @@
<!-- ── API card ───────────────────────────────────────────── -->
<div class="admin-card">
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>Add API Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoint)</span>
<h2 style="display:flex;align-items:center;gap:8px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><circle cx="11" cy="12" r="8"/><path d="M19 5v6"/><path d="M16 8h6"/><path d="M7 12h8"/></svg>Add API Models <span style="opacity:0.45;font-weight:normal;font-size:0.82em">(Endpoint)</span>
<span style="flex:1"></span>
<button class="admin-btn-sm" id="adm-epApiTestBtn" style="font-size:11px;font-weight:normal;display:inline-flex;align-items:center;gap:4px;">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>Test
@@ -2236,7 +2208,7 @@
<input id="adm-epApiKey" type="password" placeholder="API key, e.g. sk-proj-AbCdEf…" autocomplete="off" style="flex:1;padding-left:28px;height:32px;box-sizing:border-box;">
</div>
<button class="admin-btn-add" id="adm-epAddBtn" style="height:32px;min-width:55px;text-align:center;display:inline-flex;align-items:center;justify-content:center;gap:4px;flex-shrink:0;box-sizing:border-box;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>Add
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>Add
</button>
</div>
<div id="adm-epApiMsg" class="adm-ep-inline-msg"></div>
+1 -1
View File
@@ -471,7 +471,7 @@ async function loadEndpoints() {
const listLegacy = el('adm-epList');
// Refresh model picker so new endpoints show up in chat
if (window.modelsModule && window.modelsModule.refreshModels) {
window.modelsModule.refreshModels();
window.modelsModule.refreshModels(true);
setTimeout(() => {
if (window.sessionModule && window.sessionModule.updateModelPicker) {
window.sessionModule.updateModelPicker();
+170 -28
View File
@@ -52,6 +52,49 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
} catch (_) {}
}
let _pendingContinue = null; // Stores the stopped AI element to merge with new response
function _createChatSendPerf() {
const started = (performance && performance.now) ? performance.now() : Date.now();
let last = started;
let reported = false;
const stages = [];
const now = () => (performance && performance.now) ? performance.now() : Date.now();
return {
mark(name) {
const t = now();
stages.push({ name, delta_ms: Math.round(t - last), at_ms: Math.round(t - started) });
last = t;
},
report(extra) {
if (reported) return;
const total = Math.round(now() - started);
const slowStage = stages.some(s => (s.delta_ms || 0) >= 1500);
if (total < 1500 && !slowStage) return;
reported = true;
const payload = JSON.stringify({
type: 'chat_send',
total_ms: total,
stages,
extra: extra || '',
session: sessionModule && sessionModule.getCurrentSessionId ? sessionModule.getCurrentSessionId() : '',
});
try {
if (navigator.sendBeacon) {
navigator.sendBeacon(`${API_BASE}/api/client-perf`, new Blob([payload], { type: 'application/json' }));
return;
}
} catch (_) {}
try {
fetch(`${API_BASE}/api/client-perf`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true,
credentials: 'same-origin',
}).catch(() => {});
} catch (_) {}
}
};
}
// ── Auto-recovery: when a turn's stream silently dies (connection drop) or
// goes quiet while the connection is alive, re-engage the model with a
// completion handshake instead of leaving it hung. Capped so it can't loop.
@@ -121,6 +164,26 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
return final && !visible ? 'Done.' : visible;
}
function _stripIncompleteRawToolJsonForChat(text) {
const s = String(text || '');
const starts = ['[{"function"', '[\n{"function"', '{"function"'];
let idx = -1;
for (const marker of starts) idx = Math.max(idx, s.lastIndexOf(marker));
if (idx < 0) return s;
const tail = s.slice(idx);
// Complete raw OpenAI-style function blobs are removed by stripToolBlocks.
// While the stream is still mid-JSON, hide the tail so it never flashes in
// the chat bubble as prose.
if (!/"type"\s*:\s*"function"/.test(tail) || !/\}\s*\]?\s*(?:<\/?\|(?:assistant|assistan|user|system|tool)\|>?)?\s*$/i.test(tail)) {
return s.slice(0, idx);
}
return s;
}
function _streamDisplayText(text, opts = {}) {
return stripToolBlocks(_stripIncompleteRawToolJsonForChat(_stripDocumentFenceForChat(text, opts)));
}
function _showDocumentWritingStatus(contentEl) {
const msg = contentEl && contentEl.closest ? contentEl.closest('.msg') : null;
const chatBox = document.getElementById('chat-history');
@@ -670,6 +733,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// --- Send-path entry: block re-clicks between submit and stream start ---
if (_sendInFlight) return;
const _sendPerf = _createChatSendPerf();
_sendInFlight = true;
_setForegroundChatBusy(true);
// Instant visual feedback so the user sees their click was accepted
@@ -728,18 +792,34 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// Materialize pending session (deferred from model click) on first message
if (sessionModule.hasPendingChat && sessionModule.hasPendingChat()) {
_sendPerf.mark('pending_session_begin');
const ok = await sessionModule.materializePendingSession();
_sendPerf.mark('pending_session_done');
if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; }
}
if (!sessionModule.getCurrentSessionId()) {
// Auto-create a session using default chat config. Always fetch fresh
// so that a recent Settings change takes effect without a page reload.
try {
const pending = sessionModule.getPendingChat && sessionModule.getPendingChat();
if (pending && pending.url && pending.modelId) {
const ok = await sessionModule.materializePendingSession();
if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; }
}
} catch (_) {}
}
if (!sessionModule.getCurrentSessionId()) {
// Auto-create a session using default chat config. Always fetch fresh
// so that a recent Settings change takes effect without a page reload.
try {
let dc = null;
try {
_sendPerf.mark('default_chat_fetch_begin');
const dcRes = await fetch('/api/default-chat');
dc = await dcRes.json();
_sendPerf.mark('default_chat_fetch_done');
if (dc && dc.endpoint_url && dc.model) {
try { window.__odysseusDefaultChat = dc; } catch (_) {}
}
@@ -747,8 +827,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
dc = (typeof window !== 'undefined' && window.__odysseusDefaultChat) || null;
}
if (dc.endpoint_url && dc.model) {
_sendPerf.mark('direct_chat_create_begin');
await sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id);
_sendPerf.mark('direct_chat_create_done');
const ok = await sessionModule.materializePendingSession();
_sendPerf.mark('direct_chat_materialize_done');
if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; }
} else {
el('message').value = '';
@@ -904,6 +987,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (!skipBubble) {
_userMsgEl = addMessage('user', userDisplay, null, _pendingAttachInfo ? { attachments: _pendingAttachInfo } : null);
}
_sendPerf.mark('user_bubble_visible');
messageInput.value = '';
messageInput.style.height = '';
messageInput.dispatchEvent(new Event('input'));
@@ -939,9 +1023,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
let ids = [];
try {
_sendPerf.mark('upload_begin');
ids = await fileHandlerModule.uploadPending({ sessionId: sessionModule.getCurrentSessionId() });
_sendPerf.mark('upload_done');
} catch(e) {
console.error('upload failed', e);
_sendPerf.mark('upload_failed');
}
if (_pendingAttachInfo && !ids.length && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) {
if (_userMsgEl && _userMsgEl.parentNode) _userMsgEl.remove();
@@ -1039,11 +1126,18 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
let activeDocIdForSend = documentModule && typeof documentModule.getCurrentDocId === 'function'
? documentModule.getCurrentDocId()
: null;
if (!activeDocIdForSend && activeEmailComposerCtx?.docId) {
if (activeEmailComposerCtx?.docId) {
activeDocIdForSend = activeEmailComposerCtx.docId;
}
if (documentModule && activeDocIdForSend) {
try { await documentModule.saveDocument(); } catch(e) { console.warn('doc auto-save failed', e); }
try {
_sendPerf.mark('doc_save_begin');
await documentModule.saveDocument();
_sendPerf.mark('doc_save_done');
} catch(e) {
console.warn('doc auto-save failed', e);
_sendPerf.mark('doc_save_failed');
}
}
// Inject document selection context if present
@@ -1075,7 +1169,13 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (ids.length) fd.append('attachments', JSON.stringify(ids));
// Auto-save & send active doc ID so the backend sees latest content
if (documentModule && activeDocIdForSend) {
try { await documentModule.saveDocument({ silent: true }); } catch (_e) { /* best-effort */ }
try {
_sendPerf.mark('doc_silent_save_begin');
await documentModule.saveDocument({ silent: true });
_sendPerf.mark('doc_silent_save_done');
} catch (_e) {
_sendPerf.mark('doc_silent_save_failed');
}
fd.append('active_doc_id', activeDocIdForSend);
}
// Active email context — when an email reader is open, pass its
@@ -1094,7 +1194,10 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (emCtx.account) fd.append('active_email_account', String(emCtx.account));
}
} catch (_e) { /* best-effort */ }
// Web toggle: pre-search in Chat mode, tool permission in Agent mode
// Web toggle: pre-search in Chat mode only. Agent mode should not
// opportunistically hit SearXNG just because the chat search toggle is
// on; explicit web/current-info requests are handled by the backend
// intent gate.
const toggleState = Storage.loadToggleState();
let isAgentMode = (toggleState.mode || 'chat') === 'agent';
const incognitoChk = el('incognito-toggle');
@@ -1106,13 +1209,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
}
fd.append('mode', isAgentMode ? 'agent' : 'chat');
if (el('web-toggle').checked) {
if (isAgentMode) {
fd.append('allow_web_search', 'true');
} else {
if (!isAgentMode) {
fd.append('use_web', 'true');
}
} else if (isAgentMode) {
fd.append('allow_web_search', 'false');
}
if (isAgentMode) {
fd.append('allow_web_search', el('web-toggle').checked ? 'true' : 'false');
}
if (el('research-toggle').checked) {
fd.append('use_research', 'true');
@@ -1247,12 +1349,15 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || ''; }
catch { return ''; }
})();
_sendPerf.mark('chat_stream_post_begin');
const res = await fetch(`${API_BASE}/api/chat_stream`, {
method: 'POST',
body: fd,
headers: { 'X-Tz-Offset': String(_tzOffsetMin), 'X-Tz-Name': _tzName },
signal: abortCtrl.signal
});
_sendPerf.mark('chat_stream_headers');
_sendPerf.report('headers_received');
if (!res.ok) {
clearResponseTimeout();
@@ -1298,6 +1403,8 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (_chatLog) _chatLog.setAttribute('aria-busy', 'true');
const reader = res.body.getReader();
_sendPerf.mark('reader_ready');
_sendPerf.report('reader_ready');
const decoder = new TextDecoder();
let buffer = '';
let metrics = null;
@@ -1340,6 +1447,32 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
}
return contentDiv;
}
function _ensureVisibleRoundForDelta() {
if (!roundHolder || roundHolder.style.display !== 'none') return;
const box = document.getElementById('chat-history');
if (!box) {
roundHolder.style.display = '';
return;
}
const newWrap = document.createElement('div');
newWrap.className = 'msg msg-ai msg-continuation streaming';
const newRole = document.createElement('div');
newRole.className = 'role';
const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId);
const requested = holder?._requestedModel || metaS?.model || modelName;
const actual = holder?._actualModel || requested;
newRole.textContent = _modelRouteLabel(requested, actual) || '';
_applyModelColor(newRole, actual);
newWrap.appendChild(newRole);
const newBody = document.createElement('div');
newBody.className = 'body';
newWrap.appendChild(newBody);
box.appendChild(newWrap);
if (lastToolThread && lastToolThread.isConnected) lastToolThread.classList.add('has-bottom');
roundHolder = newWrap;
roundText = '';
roundFinalized = false;
}
const esc = uiModule.esc;
// Remove thinking spinner helper
_removeThinkingSpinner = () => {
@@ -1463,7 +1596,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
// Direct render helper for streaming text
_renderStream = () => {
let dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(_stripDocumentFenceForChat(roundText)));
let dt = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText));
const bodyEl = roundHolder.querySelector('.body');
const contentEl = _ensureStreamLayout(bodyEl);
@@ -1718,24 +1851,25 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (!_thinkOpen) { _delta = '<think>' + _delta; _thinkOpen = true; }
} else if (_thinkOpen) {
_delta = '</think>' + _delta; _thinkOpen = false;
}
const wasEmpty = !accumulated;
accumulated += _delta;
roundText += _delta;
currentAccumulated = accumulated; // Update global tracker
// First token arrived — switch stop button from processing to streaming
if (wasEmpty && submitBtn && !_isBg) {
submitBtn.dataset.phase = 'receiving';
}
const wasEmpty = !accumulated;
accumulated += _delta;
currentAccumulated = accumulated; // Update global tracker
// First token arrived — switch stop button from processing to streaming
if (wasEmpty && submitBtn && !_isBg) {
submitBtn.dataset.phase = 'receiving';
}
// Update background map if running in background
if (_isBg) {
var bgEntry = _backgroundStreams.get(streamSessionId);
if (bgEntry) bgEntry.accumulated = accumulated;
continue; // Skip all DOM writes
}
if (bgEntry) bgEntry.accumulated = accumulated;
continue; // Skip all DOM writes
}
_ensureVisibleRoundForDelta();
roundText += _delta;
// --- Text-fence doc streaming (for models that don't use native tool calls) ---
// --- Text-fence doc streaming (for models that don't use native tool calls) ---
if (!_docFenceOpened && documentModule && (roundText.includes('```create_document\n') || roundText.includes('```document\n') || roundText.includes('```documen\n'))) {
const fenceMarker = roundText.includes('```document\n') ? '```document\n' : (roundText.includes('```documen\n') ? '```documen\n' : '```create_document\n');
const fenceIdx = roundText.indexOf(fenceMarker);
@@ -1870,7 +2004,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
} else if (hasUnclosedThink && isThinking) {
if (_liveThinkInner) {
// Extract raw thinking text (strip known thinking wrappers and prefixes)
var thinkText = markdownModule.normalizeThinkingMarkup(roundText)
var thinkText = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText))
.replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '')
.replace(/<\|channel>thought\s*\n?/gi, '')
.replace(/<\|channel>response\s*\n?/gi, '')
@@ -2303,6 +2437,14 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (!_isBg) {
uiModule.showToast('Context compacted — older messages summarized');
}
} else if (json.type === 'context_trimmed') {
if (!_isBg) {
const d = json.data || {};
const before = Number(d.messages_before || 0);
const after = Number(d.messages_after || 0);
const detail = before && after && before > after ? ` (${after}/${before} messages sent)` : '';
uiModule.showToast(`Context trimmed for this model${detail}`);
}
} else if (json.type === 'metrics') {
metrics = json.data;
if (!_isBg && holder && metrics) {
@@ -2349,7 +2491,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
if (!roundFinalized) {
roundFinalized = true;
if (spinner && spinner.element) spinner.destroy();
const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(_stripDocumentFenceForChat(roundText)));
const dt = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText));
if (dt.trim()) {
var _body3 = roundHolder.querySelector('.body');
var _contentEl3 = _ensureStreamLayout(_body3);
@@ -2844,7 +2986,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
}
// Finalize the last round's bubble — flatten stream-content wrapper for clean DOM
const finalDisplay = stripToolBlocks(_stripDocumentFenceForChat(roundText, { final: _docFenceOpened }));
const finalDisplay = _streamDisplayText(roundText, { final: _docFenceOpened });
if (finalDisplay.trim()) {
var _body4 = roundHolder.querySelector('.body');
// Preserve sources expanded state before final render
@@ -2910,11 +3052,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
_body4b.innerHTML = _sourcesData ? _buildSourcesBox(_sourcesData, _sourcesType, _wasExpanded2) : _sourcesHtml;
} else if (roundHolder !== holder) {
// Check if there's thinking content worth showing
const _thinkingOnly = markdownModule.extractThinkingBlocks(roundText);
const _thinkingOnly = markdownModule.extractThinkingBlocks(_streamDisplayText(roundText));
if (_thinkingOnly.thinkingBlocks?.length && !_thinkingOnly.content) {
// Show thinking in a collapsed section even if no visible reply text
const _body4c = roundHolder.querySelector('.body');
if (_body4c) _body4c.innerHTML = markdownModule.processWithThinking(roundText);
if (_body4c) _body4c.innerHTML = markdownModule.processWithThinking(_streamDisplayText(roundText));
} else {
roundHolder.style.display = 'none';
// Thread above expected a bubble below — remove has-bottom since bubble is hidden
@@ -3656,7 +3798,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
};
const renderDelta = () => {
const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(_stripDocumentFenceForChat(roundText, { final: docFenceOpened })));
const dt = markdownModule.normalizeThinkingMarkup(_streamDisplayText(roundText, { final: docFenceOpened }));
if (docFenceOpened && !dt.trim()) {
_showDocumentWritingStatus(contentDiv);
} else {
+73 -19
View File
@@ -474,6 +474,10 @@ const XML_INVOKE_RE = /<invoke\s+name=['"][^'"]*['"]>[\s\S]*?<\/invoke>/gi;
// (e.g. mid-stream before the closing tag arrives).
const DSML_TOOL_RE = /<\s*[|]+\s*DSML\s*[|]+\s*tool_calls\s*>[\s\S]*?(?:<\s*\/\s*[|]+\s*DSML\s*[|]+\s*tool_calls\s*>|$)/gi;
const DSML_STRAY_RE = /<\s*\/?\s*[|]+\s*DSML\s*[|]+[^>]*>/gi;
const DSML_INVOKE_RE = /<\s*[|]+\s*DSML\s*[|]+\s*invoke\b[^>]*>[\s\S]*?(?:<\s*\/\s*[|]+\s*DSML\s*[|]+\s*invoke\s*>|$)/gi;
const RAW_OPENAI_TOOL_JSON_RE = /(?:\[\s*)?\{\s*"function"\s*:\s*\{[\s\S]*?\}\s*,\s*"id"\s*:\s*"[^"]*"\s*,\s*"type"\s*:\s*"function"\s*\}\s*\]?/gi;
const QWEN_ROLE_MARKER_RE = /<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi;
const QWEN_BARE_MARKER_RE = /(?:^|[\t\r\n ])(?:\|?end\|?|\/?\|end\|)(?=[\t\r\n ]|$)|(?:^|[\t\r\n ])assistan(?:t)?(?=[\t\r\n ]|$)/gi;
// Self-narration about tool results (model echoing stdout/exit_code)
const TOOL_NARRATION_RE = /(?:The (?:result|output) shows?:?\s*)?-?\s*(?:stdout|stderr|exit_code):\s*.+/gi;
@@ -911,9 +915,13 @@ export function stripToolBlocks(text) {
let cleaned = text.replace(TOOL_CALL_RE, '');
if (EXEC_FENCE_RE) cleaned = cleaned.replace(EXEC_FENCE_RE, stripExecutedFence);
cleaned = cleaned.replace(DSML_TOOL_RE, '');
cleaned = cleaned.replace(DSML_INVOKE_RE, '');
cleaned = cleaned.replace(DSML_STRAY_RE, '');
cleaned = cleaned.replace(XML_TOOL_CALL_RE, '');
cleaned = cleaned.replace(XML_INVOKE_RE, '');
cleaned = cleaned.replace(RAW_OPENAI_TOOL_JSON_RE, '');
cleaned = cleaned.replace(QWEN_ROLE_MARKER_RE, '');
cleaned = cleaned.replace(QWEN_BARE_MARKER_RE, ' ');
cleaned = cleaned.replace(TOOL_NARRATION_RE, '');
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
return cleaned.trim();
@@ -1152,17 +1160,41 @@ document.addEventListener('click', function(e) {
while (_t && _t.nodeType === Node.TEXT_NODE) _t = _t.parentElement;
const a = _t && _t.closest && _t.closest('a[href]');
if (!a) return;
const href = a.getAttribute('href') || '';
const rawHref = a.getAttribute('href') || '';
let href = rawHref;
try {
const parsed = new URL(rawHref, window.location.origin);
if (parsed.origin === window.location.origin && parsed.pathname === window.location.pathname) {
href = parsed.hash || rawHref;
}
} catch (_) {}
if (!href.startsWith('#')) return;
const m = href.match(/^#(session|document|note|image|email|event|task|skill|research)-(.+)$/);
let m = href.match(/^#(session|document|note|image|email|event|task|skill|research)-(.+)$/);
if (!m) {
const noteOpen = href.match(/^#open=notes&note=([^&]+)/);
if (noteOpen) m = ['note', 'note', decodeURIComponent(noteOpen[1])];
}
if (!m) {
const bareSession = href.match(/^#([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
if (bareSession) m = ['session', 'session', bareSession[1]];
}
if (!m) return;
e.preventDefault();
e.stopPropagation();
const [, kind, id] = m;
if (kind === 'session') {
try {
a.classList.add('is-loading');
a.setAttribute('aria-busy', 'true');
} catch {}
import('./sessions.js').then(mod => {
const fn = mod.selectSession || (mod.default && mod.default.selectSession);
if (fn) fn(id);
if (fn) return fn(id, { showLoading: true, immediateLoading: true });
}).finally(() => {
try {
a.classList.remove('is-loading');
a.removeAttribute('aria-busy');
} catch {}
});
} else if (kind === 'document') {
import('./document.js').then(mod => {
@@ -1175,6 +1207,11 @@ document.addEventListener('click', function(e) {
import('./notes.js').then(mod => {
const open = mod.openNote || (mod.default && mod.default.openNote);
if (open) open(id);
try {
if (/^#(?:note-|open=notes&note=)/.test(window.location.hash || '')) {
history.replaceState(null, '', window.location.pathname + window.location.search);
}
} catch (_) {}
}).catch(() => {});
} else if (kind === 'image') {
import('./gallery.js').then(mod => {
@@ -1208,7 +1245,7 @@ document.addEventListener('click', function(e) {
if (open) open(id);
}).catch(() => {});
}
});
}, true);
/**
* Build a generated-image bubble element.
@@ -1326,6 +1363,25 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
});
actions.appendChild(editBtn);
if (imageId) {
const galleryBtn = document.createElement('button');
galleryBtn.className = 'footer-copy-btn footer-open-gallery-btn';
galleryBtn.type = 'button';
galleryBtn.title = 'Open in gallery';
galleryBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg><span>Open in gallery</span>';
galleryBtn.addEventListener('click', async (e) => {
e.stopPropagation();
try {
const mod = await import('./gallery.js');
const open = mod.openGalleryImage || (mod.default && mod.default.openGalleryImage);
if (open) open(imageId);
} catch (err) {
console.error('[chat] open in gallery failed', err);
}
});
actions.appendChild(galleryBtn);
}
const delBtn = document.createElement('button');
delBtn.className = 'footer-copy-btn footer-delete-btn';
delBtn.type = 'button';
@@ -1789,19 +1845,16 @@ export function displayMetrics(messageElement, metrics) {
}
}
// Default: show tok/s if available, else fall back to other stats
// Keep token counts in the Message Stats popup; the footer should stay slim.
const costStr0 = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : null;
const metricsLabel = tps != null && tps !== 'undefined'
const hasTps = tps != null && tps !== 'undefined';
const metricsLabel = hasTps
? `${tps} tok/s`
: costStr0
? `${outputTokens} tok · ${costStr0}`
: outputTokens
? `${outputTokens} tok · ${responseTime != null ? responseTime + 's' : ''}`
: inputTokens
? `${inputTokens} in${responseTime != null ? ' · ' + responseTime + 's' : ''}`
: responseTime != null
? `${responseTime}s`
: '';
? costStr0
: responseTime != null
? `${responseTime}s`
: '';
if (!metricsLabel) return;
metricsContainer.textContent = metricsLabel;
metricsContainer.style.cursor = 'pointer';
@@ -2442,8 +2495,8 @@ export function addMessage(role, content, modelName, metadata) {
.trim();
}
wrap.dataset.raw = text;
if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id;
wrap.dataset.raw = text;
if (metadata?._db_id) wrap.dataset.dbId = metadata._db_id;
// Prepend sources box if saved in metadata
var sourcesPrefix = '';
var findingsSuffix = '';
@@ -2466,9 +2519,10 @@ export function addMessage(role, content, modelName, metadata) {
'<think' + (thinkTime ? ` time="${thinkTime}"` : '') + '>' + metadata.thinking + '</think>\n\n' + text
);
b.innerHTML = sourcesPrefix + thinkHtml + findingsSuffix;
} else {
b.innerHTML = sourcesPrefix + markdownModule.processWithThinking(text) + findingsSuffix;
}
} else {
b.innerHTML = sourcesPrefix + markdownModule.processWithThinking(text) + findingsSuffix;
}
b.dataset.raw = text;
// The vision/OCR caption is stripped from the displayed text above (so the
// bubble doesn't show the raw model output) but no longer rendered as an
+10 -2
View File
@@ -185,9 +185,17 @@ export function handleUIControl(uiData) {
} else if (uiEvent === 'open_email_reply' || uiData.ui_event === 'open_email_reply') {
try {
var existingDocId = documentModule && documentModule.findEmailDocId
? documentModule.findEmailDocId(uiData.uid, uiData.folder || 'INBOX')
var activeCtx = documentModule && documentModule.getActiveEmailComposerContext
? documentModule.getActiveEmailComposerContext()
: null;
var sameActiveDraft = activeCtx
&& String(activeCtx.sourceUid || '') === String(uiData.uid || '')
&& String(activeCtx.sourceFolder || 'INBOX') === String(uiData.folder || 'INBOX');
var existingDocId = sameActiveDraft && activeCtx.docId
? activeCtx.docId
: (documentModule && documentModule.findEmailDocId
? documentModule.findEmailDocId(uiData.uid, uiData.folder || 'INBOX')
: null);
if (existingDocId && documentModule.replaceEmailReplyBody) {
if (documentModule.loadDocument) documentModule.loadDocument(existingDocId);
documentModule.replaceEmailReplyBody(existingDocId, uiData.body || '', { force: true });
+11 -1
View File
@@ -49,6 +49,16 @@ const _RECIPES = [
},
},
// ── MLX ───────────────────────────────────────────────────────────────
{
backend: 'mlx_lm',
label: 'Any MLX model',
match: () => true,
variants: {
pip: { commands: ['uv pip install -U mlx-lm'] },
},
},
// ── llama.cpp ─────────────────────────────────────────────────────────
{
backend: 'llama_cpp',
@@ -75,7 +85,7 @@ export function recipeCommands(recipe, variant) {
// Backends we surface a recipe panel for. Other rows in the Dependencies
// list keep the existing flat Install/Reinstall button without an expand
// affordance.
export const RECIPE_BACKENDS = new Set(['vllm', 'sglang', 'llama_cpp']);
export const RECIPE_BACKENDS = new Set(['vllm', 'sglang', 'mlx_lm', 'llama_cpp']);
// All recipe entries for a given backend, in catalog order. The first one
// is the model-specific match (when present); the last is always the
+229 -10
View File
@@ -149,6 +149,118 @@ function _openCpuServeEdit(panel) {
});
}
function _taskForDiagnosisPanel(panel) {
const taskEl = panel?.closest?.('.cookbook-task');
const taskId = taskEl?.dataset?.taskId || '';
if (!taskId) return null;
return (_loadTasks() || []).find(t => t.sessionId === taskId) || null;
}
function _pythonFromServeCmd(cmd) {
const s = String(cmd || '');
const abs = s.match(/(?:^|\s)(\/[^\s]+\/bin\/python3?)(?=\s+-m\s+(?:sglang\.launch_server|mlx_lm\.server))/);
if (abs) return abs[1];
const rel = s.match(/(?:^|\s)(python3?)(?=\s+-m\s+(?:sglang\.launch_server|mlx_lm\.server))/);
return rel ? rel[1] : '';
}
function _pythonForDiagnosisPanel(panel) {
const task = _taskForDiagnosisPanel(panel);
const fromCmd = _pythonFromServeCmd(task?.payload?._cmd || '');
if (fromCmd) return fromCmd;
return (_envState.env === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3`
: 'python3';
}
function _sglangKernelRepairCommand(panel) {
return `${_pythonForDiagnosisPanel(panel)} -m pip install -U --force-reinstall --no-cache-dir sglang-kernel`;
}
function _mlxLmInstallCommand(panel) {
return `${_pythonForDiagnosisPanel(panel)} -m pip install -U mlx-lm`;
}
async function _repairSglangKernel(panel) {
const task = _taskForDiagnosisPanel(panel);
uiModule.showToast('Repairing sglang-kernel on the selected server...');
await _launchServeTask(
'repair-sglang-kernel',
'pip-update',
_sglangKernelRepairCommand(panel),
null,
task?.remoteHost || undefined,
task ? {
serverKey: task.remoteServerKey || task.remoteHost || '',
serverName: task.remoteServerName || task.remoteHost || '',
} : null,
);
}
async function _installMlxLm(panel) {
const task = _taskForDiagnosisPanel(panel);
uiModule.showToast('Installing MLX LM on the selected server...');
await _launchServeTask(
'install-mlx-lm',
'pip-update',
_mlxLmInstallCommand(panel),
null,
task?.remoteHost || undefined,
_diagnosisTargetMeta(task),
);
}
function _diagnosisTargetMeta(task) {
return task ? {
serverKey: task.remoteServerKey || task.remoteHost || '',
serverName: task.remoteServerName || task.remoteHost || '',
} : null;
}
function _gpuCleanupCommand() {
return `set -u
echo "[odysseus] Clearing GPU compute processes..."
if command -v nvidia-smi >/dev/null 2>&1; then
pids="$(nvidia-smi --query-compute-apps=pid --format=csv,noheader,nounits 2>/dev/null | tr -d " " | grep -E "^[0-9]+$" | sort -u)"
if [ -z "$pids" ]; then
echo "[odysseus] No NVIDIA compute processes found."
exit 0
fi
echo "[odysseus] GPU PIDs: $pids"
ps -fp $pids 2>/dev/null || true
echo "[odysseus] Sending TERM..."
kill -TERM $pids || true
sleep 3
alive=""
for pid in $pids; do
if kill -0 "$pid" 2>/dev/null; then alive="$alive $pid"; fi
done
if [ -n "$alive" ]; then
echo "[odysseus] Force killing remaining GPU PIDs:$alive"
kill -KILL $alive || true
fi
sleep 1
remaining="$(nvidia-smi --query-compute-apps=pid,process_name,used_memory --format=csv,noheader,nounits 2>/dev/null | sed "/^$/d" || true)"
if [ -n "$remaining" ]; then
echo "[odysseus] GPU processes still remain:"
echo "$remaining"
exit 2
fi
echo "[odysseus] GPU cleanup complete. No NVIDIA compute processes remain."
else
echo "[odysseus] nvidia-smi not found; falling back to common model-server process cleanup."
pkill -TERM -f "sglang.launch_server|vllm|llama-server|text-generation-launcher|aphrodite" || true
sleep 3
pkill -KILL -f "sglang.launch_server|vllm|llama-server|text-generation-launcher|aphrodite" || true
echo "[odysseus] Fallback cleanup complete."
fi`;
}
async function _clearGpuProcesses(panel) {
uiModule.showToast('Clearing GPU compute processes on the selected server...');
await _runQuickCmd(panel, _gpuCleanupCommand());
}
// Infer the gated base repo that single-file checkpoints need configs from
function _inferBaseRepo(text) {
if (!text) return null;
@@ -161,6 +273,25 @@ function _inferBaseRepo(text) {
}
export const ERROR_PATTERNS = [
{
pattern: /tmux is required|tmux.*not found|tmux:\s*command not found|command not found:\s*tmux|No such file or directory:\s*['"]?tmux/i,
message: 'tmux is missing on this server.',
suggestion: 'Suggested action: open Dependencies and install tmux on the selected server.',
fixes: [
{ label: 'Open tmux dependency', action: () => _openCookbookDependencies('tmux') },
{ label: 'Copy apt install', action: () => _copyText('sudo apt install -y tmux') },
{ label: 'Copy pacman install', action: () => _copyText('sudo pacman -S --needed tmux') },
],
},
{
pattern: /Port \d+ is already serving|port is occupied by a different model|choose another port before launching/i,
message: 'Serve port is already occupied by another model.',
suggestion: 'Suggested action: stop the old server or choose a different port before relaunching.',
fixes: [
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
{ label: 'Copy check command', action: () => _copyText('curl http://127.0.0.1:PORT/v1/models') },
],
},
{
pattern: /No available memory for the cache blocks|Available KV cache memory:.*-/i,
message: 'No GPU memory left for KV cache after loading model.',
@@ -179,6 +310,39 @@ export const ERROR_PATTERNS = [
{ label: 'Retry with --max-num-seqs 32', action: (panel) => _serveAutoRetry(panel, '--max-num-seqs 32') },
],
},
{
pattern: /Loaded weights leave no GPU memory for the KV cache under --mem-fraction-static|Raise --mem-fraction-static above/i,
message: 'SGLang static memory fraction is too low for the loaded weights.',
suggestion: 'Suggested action: retry with --mem-fraction-static 0.80 so weights fit and KV cache can still allocate.',
fixes: [
{ label: 'Retry mem 0.80', action: (panel) => _serveAutoRetryReplace(panel, '--mem-fraction-static', '0.80') },
{ label: 'Retry mem 0.82', action: (panel) => _serveAutoRetryReplace(panel, '--mem-fraction-static', '0.82') },
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
],
},
{
pattern: /get_paged_mqa_logits_metadata|deepseek_v4_backend\.py|paged_mqa_metadata\.cuh:113.*CUDA error:\s*invalid argument/i,
message: 'SGLang DeepSeek-V4 attention metadata kernel failed on this GPU/runtime.',
suggestion: 'Suggested action: stop retrying graph/memory tweaks for this exact FP8 command. SGLangs RTX PRO 6000 recipe uses the original deepseek-ai/DeepSeek-V4-Flash checkpoint with --moe-runner-backend marlin, not the converted sgl-project FP8 checkpoint. Try that recipe/checkpoint, official SGLang container/nightly, or supported Hopper/Blackwell hardware.',
fixes: [
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
{ label: 'Copy error', action: (panel) => {
const task = panel.closest('.cookbook-task');
const text = task?.querySelector('.cookbook-task-output')?.textContent || task?.textContent || '';
_copyText(text.trim());
} },
],
},
{
pattern: /Capture cuda graph failed|cuda graph failed|paged_mqa_metadata|cuda-graph-backend-decode|cuda-graph-max-bs-decode|CUDA error:\s*invalid argument/i,
message: 'SGLang failed while capturing decode CUDA graphs.',
suggestion: 'Suggested action: disable SGLang decode CUDA graph for this launch. DeepSeek-V4 is reaching graph capture, but this kernel is failing on the target hardware.',
fixes: [
{ label: 'Disable decode graph', action: (panel) => _serveAutoRetryReplace(panel, '--cuda-graph-backend-decode', 'disabled') },
{ label: 'Retry mem 0.80', action: (panel) => _serveAutoRetryReplace(panel, '--mem-fraction-static', '0.80') },
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
],
},
{
pattern: /CUDA out of memory|torch\.cuda\.OutOfMemoryError|CUDA error: out of memory/i,
message: 'GPU ran out of memory. Try more GPUs (higher TP) or lower context.',
@@ -334,6 +498,18 @@ export const ERROR_PATTERNS = [
{ label: 'Enable enforce eager', action: (panel) => _setPanelCheckbox(panel, 'enforce_eager', true) },
],
},
{
pattern: /memory capacity is unbalanced|Some GPUs may be occupied by other processes|pre_model_load_memory=.*local_gpu_memory/i,
message: 'SGLang refused to start because free GPU memory is uneven across the selected tensor-parallel GPUs.',
suggestion: 'Suggested action: run Clear GPUs, then relaunch. If it still fails, choose only equally free GPUs or lower TP/context.',
fixes: [
{ label: 'Clear GPUs', action: (panel) => _clearGpuProcesses(panel) },
{ label: 'Copy clear command', action: () => _copyText(_gpuCleanupCommand()) },
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
{ label: 'Set TP to 1', action: (panel) => _setPanelField(panel, 'tp', '1') },
{ label: 'Lower context', action: (panel) => _setPanelField(panel, 'ctx', '32768') },
],
},
{
pattern: /KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context/i,
message: 'Context length too large for available GPU memory.',
@@ -355,11 +531,14 @@ export const ERROR_PATTERNS = [
],
},
{
pattern: /sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops)|(Python\.h|libnuma\.so\.1|common_ops)[\s\S]*sgl_kernel|Please ensure sgl_kernel is properly installed/i,
message: 'SGLang native dependencies are missing on this server.',
pattern: /sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)|(?:Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)[\s\S]*sgl_kernel|Could not load any common_ops library|Please ensure sgl_kernel is properly installed/i,
message: 'SGLang native kernel/runtime is missing or mismatched on this server.',
suggestion: 'Suggested action: relaunch with Odysseus venv CUDA library path fix. If the venv does not contain the matching NVIDIA runtime libs, run Repair sglang-kernel.',
fixes: [
{ label: 'Edit / relaunch serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
{ label: 'Repair sglang-kernel', action: (panel) => _repairSglangKernel(panel) },
{ label: 'Copy repair command', action: (panel) => _copyText(_sglangKernelRepairCommand(panel)) },
{ label: 'Copy OS package command', action: () => _copyText('sudo apt-get install -y libnuma-dev python3.12-dev build-essential') },
{ label: 'Copy kernel upgrade', action: () => _copyText('python3 -m pip install --upgrade sglang-kernel') },
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') },
],
},
@@ -371,6 +550,30 @@ export const ERROR_PATTERNS = [
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install "sglang[all]"') },
],
},
{
pattern: /No module named ['"]?mlx_lm|mlx_lm.*command not found|MLX is not installed|MLX LM is not installed/i,
message: 'MLX LM is not installed on this server.',
suggestion: 'Suggested action: install mlx-lm in the selected Python environment. MLX serving is intended for Apple Silicon Macs.',
fixes: [
{ label: 'Install MLX LM', action: (panel) => _installMlxLm(panel) },
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('mlx_lm') },
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install -U mlx-lm') },
],
},
{
pattern: /Unable to quantize model of type <class ['"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['"]>|QuantizedSwitchLinear/i,
message: 'MLX-LM tried to quantize an already-quantized DeepSeek switch layer.',
suggestion: 'Suggested action: relaunch from the cached local snapshot path. Odysseus now rewrites MLX repo-id launches to the newest local Hugging Face snapshot when it exists on the selected Mac.',
fixes: [
{ label: 'Edit / relaunch serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('mlx_lm') },
{ label: 'Copy error', action: (panel) => {
const task = panel.closest('.cookbook-task');
const text = task?.querySelector('.cookbook-task-output')?.textContent || task?.textContent || '';
_copyText(text.trim());
} },
],
},
{
pattern: /No accelerator \(CUDA, XPU, HPU, NPU, MUSA, MPS\) is available|Triton is not supported on current platform/i,
message: 'SGLang needs a visible GPU/accelerator on this server.',
@@ -834,22 +1037,38 @@ export function _clearDiagnosis(panel) {
// ── Quick command ──
export async function _runQuickCmd(panel, cmd) {
const task = _taskForDiagnosisPanel(panel);
let fullCmd = cmd;
if (_envState.remoteHost) {
fullCmd = _sshCmd(_envState.remoteHost, cmd);
const host = task?.remoteHost || _envState.remoteHost || '';
const port = task?.sshPort || task?.payload?.ssh_port || _envState.sshPort || '';
if (host) {
fullCmd = _sshCmd(host, cmd, port);
}
const diag = panel.querySelector('.cookbook-diagnosis');
if (diag) { diag.classList.remove('hidden'); diag.textContent = `Running: ${fullCmd}...`; }
if (diag) {
diag.classList.remove('hidden');
diag.innerHTML = '<div class="cookbook-diag-message">Running command...</div>';
}
try {
const res = await fetch('/api/shell/stream', {
const res = await fetch('/api/shell/exec', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: fullCmd }),
body: JSON.stringify({ command: fullCmd, timeout: 60 }),
});
if (diag) diag.textContent = res.ok ? `Done: ${cmd}` : `Failed (HTTP ${res.status})`;
const data = await res.json().catch(() => ({}));
const out = [data.stdout, data.stderr].filter(Boolean).join('\n').trim();
const ok = res.ok && Number(data.exit_code ?? 1) === 0;
if (diag) {
diag.innerHTML = ''
+ `<div class="cookbook-diag-message">${ok ? 'Command completed.' : 'Command failed.'}</div>`
+ `<div class="cookbook-diag-suggestion" style="opacity:0.75;margin-top:1px;">Exit code: ${_diagEsc(data.exit_code ?? 'unknown')}</div>`
+ (out ? `<pre class="cookbook-diag-output" style="margin:6px 0 0;white-space:pre-wrap;max-height:180px;overflow:auto;font-size:10px;line-height:1.35;">${_diagEsc(out)}</pre>` : '');
}
} catch (e) {
if (diag) diag.textContent = `Error: ${e.message}`;
if (diag) {
diag.innerHTML = `<div class="cookbook-diag-message">Command error.</div><div class="cookbook-diag-suggestion">${_diagEsc(e.message)}</div>`;
}
}
}
+140 -44
View File
@@ -24,6 +24,9 @@ import {
_MODELDIR_CHECK_ON,
_MODELDIR_CHECK_OFF,
_serverEntryHtml,
_serverDefaultHtml,
_applyServerSelectColor,
_syncServerSelectColors,
_copyText,
// Import cookbook.js WITHOUT a ?v= query — the same plain specifier every other
// importer uses. A query mismatch loads cookbook.js twice as two separate modules
@@ -34,10 +37,59 @@ import spinnerModule from './spinner.js';
import { _loadTasks, _tmuxGracefulKill, _nextAvailablePort, _taskPort } from './cookbookRunning.js';
import { openCookbookDependencies } from './cookbook-diagnosis.js';
// Map a serve-backend code (vllm / sglang / llamacpp) → the package name
// Map a serve-backend code (vllm / sglang / llamacpp / mlx) → the package name
// the Dependencies API reports. Used to look up "is this backend installed
// on the target server" before firing a launch.
const _BACKEND_PKG = { vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp' };
const _BACKEND_PKG = { vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', mlx: 'mlx_lm' };
function _normalizeCookbookModelDir(dir) {
const d = String(dir || '').replaceAll('\u2715', '').replaceAll('\u2716', '').trim();
return /^(home|mnt|media|data|opt|srv|var)\//.test(d) ? `/${d}` : d;
}
function _wireServerColorPicker(entry) {
const wrap = entry.querySelector('.cookbook-srv-color-wrap');
const select = entry.querySelector('.cookbook-srv-color');
const btn = entry.querySelector('.cookbook-srv-color-btn');
const menu = entry.querySelector('.cookbook-srv-color-menu');
if (!wrap || !select || !btn || !menu || btn.dataset.bound) return;
btn.dataset.bound = '1';
const close = () => {
menu.classList.add('hidden');
btn.setAttribute('aria-expanded', 'false');
};
const open = () => {
document.querySelectorAll('.cookbook-srv-color-menu').forEach(m => {
if (m !== menu) m.classList.add('hidden');
});
menu.classList.remove('hidden');
btn.setAttribute('aria-expanded', 'true');
};
btn.addEventListener('click', (e) => {
e.stopPropagation();
if (menu.classList.contains('hidden')) open();
else close();
});
menu.querySelectorAll('.cookbook-srv-color-item').forEach(item => {
item.addEventListener('click', (e) => {
e.stopPropagation();
const color = item.dataset.color || '';
select.value = color;
const label = item.querySelector('span:last-child')?.textContent || 'Auto';
const labelEl = btn.querySelector('.cookbook-srv-color-label');
if (labelEl) labelEl.textContent = label;
const swatch = item.style.getPropertyValue('--swatch-color') || color;
if (/^#[0-9a-fA-F]{6}$/.test(swatch.trim())) {
entry.style.setProperty('--cookbook-server-color', swatch.trim());
wrap.style.setProperty('--cookbook-server-color', swatch.trim());
}
menu.querySelectorAll('.cookbook-srv-color-item').forEach(b => b.classList.toggle('active', b === item));
close();
select.dispatchEvent(new Event('change', { bubbles: true }));
});
});
document.addEventListener('click', close);
}
// Pre-launch: ask the deps API whether the chosen backend is present on
// the target server. Returns true if it's good to go, false if we should
@@ -80,7 +132,6 @@ export let _cachedModelIds = null; // repo IDs already downloaded
// after the user has switched servers.
let _hwfitFetchToken = 0;
let _dismissedHwChips = new Set();
let _hwfitAutoScanStarted = new Set();
// Permanently removed (X-clicked) chips. Separate from _dismissedHwChips
// so the ranker treats "off" and "removed" the same (both ignore the
// hardware) but the UI keeps "off" chips visible to toggle back on,
@@ -407,15 +458,12 @@ function _manualDisplaySystem(sys, manual) {
// Signature of everything that affects the result list, so we never paint a
// cached list under mismatched filters.
function _scanSig() {
const sortEl = document.getElementById('hwfit-sort');
const tc = document.getElementById('hwfit-gpu-toggles');
return JSON.stringify({
h: _envState.remoteHost || '',
hk: _currentServerValue(),
u: document.getElementById('hwfit-usecase')?.value || '',
s: document.getElementById('hwfit-search')?.value?.trim() || '',
o: sortEl?.value || 'newest',
r: sortEl?.dataset.reverse === '1' ? 1 : 0,
q: document.getElementById('hwfit-quant')?.value || '',
c: _ctxValue(),
g: (tc && typeof tc._activeCount === 'number') ? String(tc._activeCount) : '',
@@ -534,6 +582,13 @@ function _olParseSize(s) {
function _ollamaToHwfitRows(libModels, vramAvail, ramAvail) {
const out = [];
if (!Array.isArray(libModels)) return out;
const _ramFitLevel = (need, budget) => {
if (!need || !budget || need > budget) return 'too_tight';
const ratio = need / budget;
if (ratio <= 0.50) return 'perfect';
if (ratio <= 0.78) return 'good';
return 'marginal';
};
for (const m of libModels) {
const sizes = (Array.isArray(m.sizes) && m.sizes.length) ? m.sizes : ['latest'];
for (const sz of sizes) {
@@ -544,10 +599,10 @@ function _ollamaToHwfitRows(libModels, vramAvail, ramAvail) {
if (vramGb && vramAvail) {
if (vramGb <= vramAvail * 0.6) fitLevel = 'perfect';
else if (vramGb <= vramAvail) fitLevel = 'good';
else if (ramAvail && vramGb <= ramAvail) fitLevel = 'marginal';
else if (ramAvail && vramGb <= ramAvail) fitLevel = _ramFitLevel(vramGb, ramAvail);
else fitLevel = 'too_tight';
} else if (vramGb && ramAvail && vramGb <= ramAvail) {
fitLevel = 'marginal';
fitLevel = _ramFitLevel(vramGb, ramAvail);
}
const tag = `${m.name}:${sz}`;
const paramsLabel = params
@@ -628,21 +683,18 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
loadingTitle.textContent = 'No cached scan yet';
loadingTitle.style.cssText = 'font-size:12px;opacity:0.7;';
const loadingLbl = document.createElement('div');
loadingLbl.textContent = 'Scanning hardware…';
loadingLbl.textContent = 'Loading model list…';
loadingLbl.style.cssText = 'font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;';
loadingDiv.appendChild(loadingTitle);
loadingDiv.appendChild(loadingLbl);
list.innerHTML = '';
list.appendChild(loadingDiv);
if (!_hwfitAutoScanStarted.has(_sig)) {
_hwfitAutoScanStarted.add(_sig);
setTimeout(() => {
if (_tk === _hwfitFetchToken) {
_resetGpuToggleState();
_hwfitFetch(true, { autoFromEmpty: true });
}
}, 60);
}
setTimeout(() => {
if (_tk === _hwfitFetchToken) {
_resetGpuToggleState();
_hwfitFetch(true, { autoFromEmpty: true });
}
}, 60);
return;
}
if (!canKeepPrevious) {
@@ -653,13 +705,15 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
loadingDiv.style.flexDirection = 'column';
loadingDiv.style.gap = '6px';
loadingDiv.appendChild(wp.element);
// Text label like the other cookbook tabs: "Loading…", then if the scan runs
// long (remote SSH hardware probe), switch to "Scanning hardware…".
// Text label like the other cookbook tabs. Only fresh rescans are hardware
// probes; normal refreshes are just model ranking/loading from cached hw.
const loadingLbl = document.createElement('div');
loadingLbl.textContent = 'Loading…';
loadingLbl.textContent = fresh ? 'Scanning hardware…' : 'Loading models…';
loadingLbl.style.cssText = 'text-align:center;opacity:0.5;font-size:11px;';
loadingDiv.appendChild(loadingLbl);
setTimeout(() => { if (loadingLbl.isConnected) loadingLbl.textContent = 'Scanning hardware…'; }, 2000);
setTimeout(() => {
if (loadingLbl.isConnected) loadingLbl.textContent = fresh ? 'Scanning hardware…' : 'Loading model list…';
}, 2000);
list.innerHTML = '';
list.appendChild(loadingDiv);
_hwfitCache = null; // no instant paint — clear until the fetch returns
@@ -703,10 +757,6 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
_setLastCacheHost('');
});
}
if (_paintedFromCache && !forceRevalidate) {
try { wp.destroy(); } catch {}
return;
}
try {
const sortBy = document.getElementById('hwfit-sort')?.value || 'newest';
const quantPref = document.getElementById('hwfit-quant')?.value || '';
@@ -722,7 +772,10 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
if (!hasManualOrDismissed && toggleContainer && toggleContainer._activeGroup) {
gpuGroupOverride = String(toggleContainer._activeGroup);
}
const params = new URLSearchParams({ limit: '80', sort: sortBy });
// Sorting is a table operation, not a different backend query. Fetch a
// broad candidate set once, then sort it client-side so VRAM/Params/etc.
// do not appear to "filter out" rows by returning a different top-80 slice.
const params = new URLSearchParams({ limit: '2500', sort: 'score' });
if (fresh) params.set('fresh', '1'); // bypass the hardware-scan cache
if (search) params.set('search', search);
if (remoteHost) {
@@ -743,6 +796,9 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
if (hasManualOrDismissed) params.set('_hw_override_ts', String(Date.now()));
// Image models use a separate registry/endpoint
const isImageMode = useCase === 'image_gen';
if ((fresh || (_paintedFromCache && !search)) && !isImageMode) {
params.set('refresh_catalog', '1'); // update HF-backed dynamic catalogs in the background
}
if (!isImageMode) {
if (useCase) params.set('use_case', useCase);
if (quantPref) params.set('quant', quantPref);
@@ -1198,9 +1254,9 @@ function _modeLabel(model) {
export const _hwfitColumns = [
{ key: 'fit', label: 'Fit', cls: 'hwfit-fit' },
{ key: 'newest', label: 'Model (latest)', cls: 'hwfit-name' },
{ key: 'vram', label: 'VRAM', cls: 'hwfit-c-vram' },
{ key: 'params',label: 'Param', cls: 'hwfit-c-params' },
{ key: null, label: 'Quant', cls: 'hwfit-c-quant' },
{ key: 'vram', label: 'VRAM', cls: 'hwfit-c-vram' },
{ key: 'context',label: 'Ctx', cls: 'hwfit-c-ctx' },
{ key: 'speed', label: 'Speed', cls: 'hwfit-c-speed' },
{ key: 'score', label: 'Score', cls: 'hwfit-c-score' },
@@ -1301,13 +1357,13 @@ export function _hwfitRenderList(el, models) {
}
}
html += `<span class="hwfit-col hwfit-name">${modelLogo(m.name)}${esc(_short)}${_quantSuffix}${moeBadge}${imgBadge}${dlDot}</span>`;
html += `<span class="hwfit-col hwfit-c-params">${esc(pcount)}</span>`;
html += `<span class="hwfit-col hwfit-c-vram" title="Estimated loaded footprint for this quant/backend/context, including model weights and KV/runtime allowance.">${vramLabel}</span>`;
html += `<span class="hwfit-col hwfit-c-params" title="Original total model parameters, not quantized storage size.">${esc(pcount)}</span>`;
// Truncate the Quant cell to 9 chars + ellipsis so long tags like
// "FP4-MoE-Mixed" don't push neighboring columns. Full tag stays in title.
const _qRaw = m.quant || '?';
const _qShort = _qRaw.length > 9 ? _qRaw.slice(0, 9) + '…' : _qRaw;
html += `<span class="hwfit-col hwfit-c-quant" title="${esc(_qRaw)}">${esc(_qShort)}</span>`;
html += `<span class="hwfit-col hwfit-c-vram">${vramLabel}</span>`;
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>`;
@@ -1356,14 +1412,13 @@ export function _hwfitRenderList(el, models) {
if (e.target.closest('[data-fit-dot]')) {
const on = !e.target.classList.contains('active');
try { localStorage.setItem('hwfit_fit_only_v1', on ? '1' : '0'); } catch {}
// Un-toggling the fit filter (off → showing too-tight rows again) is
// typically because the user wants to see the LARGE models they can't
// run yet — re-sort by VRAM descending so the biggest surface first.
// Un-toggling the fit filter should still keep the list usable: show
// nearest/smallest VRAM first, not a wall of impossible 7000G rows.
if (!on) {
const sortSel = document.getElementById('hwfit-sort');
if (sortSel) {
sortSel.value = 'vram';
sortSel.dataset.reverse = '0'; // descending (biggest first)
sortSel.dataset.reverse = '1'; // ascending (smallest VRAM first)
}
}
_hwfitCache = null;
@@ -1379,7 +1434,9 @@ export function _hwfitRenderList(el, models) {
sel.dataset.reverse = sel.dataset.reverse === '1' ? '0' : '1';
} else {
sel.value = sortKey;
sel.dataset.reverse = '0';
// VRAM is most useful as "what fits / closest fit first"; descending
// buries qwen/gemma-sized rows below absurd impossible footprints.
sel.dataset.reverse = sortKey === 'vram' ? '1' : '0';
}
_hwfitFetch();
});
@@ -1751,6 +1808,9 @@ export function _expandModelRow(row, modelData) {
cmd += ` --context-length ${maxCtx}`;
cmd += ` --mem-fraction-static ${gpuUtil}`;
cmd += ' --trust-remote-code';
} else if (runBackend === 'mlx') {
const bindHost = host ? '0.0.0.0' : '127.0.0.1';
cmd = `python3 -m mlx_lm.server --model ${_shellQuote(modelData.name)} --host ${bindHost} --port ${port}`;
} else if (runBackend === 'llamacpp') {
const dir = `"$HOME/.cache/huggingface/hub/models--${modelData.name.replace(/\//g, '--')}/snapshots"`;
const ggufPath = `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`;
@@ -1868,6 +1928,7 @@ const _HWFIT_ENGINE_GLYPHS = {
'': '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line><circle cx="8" cy="6" r="2" fill="currentColor" stroke="none"></circle><circle cx="16" cy="12" r="2" fill="currentColor" stroke="none"></circle><circle cx="10" cy="18" r="2" fill="currentColor" stroke="none"></circle></svg>',
vllm: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"></path><path d="M14 4l4 9 3-9"></path></svg>',
sglang: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
mlx: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"></path><path d="M16 6v12"></path><path d="M20 6v12"></path></svg>',
llamacpp: '<svg width="14" height="14" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"></path><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"></path></svg>',
ollama: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;"></span>',
diffusers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"></circle><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"></path></svg>',
@@ -2040,15 +2101,22 @@ export function _hwfitInit() {
];
for (const sel of selectors) {
if (!sel) continue;
const currentVal = sel.value;
let html = `<option value="local">Local</option>`;
const currentVal = sel.value || _currentServerValue();
const localSrv = _envState.servers.find(s => !s.host || String(s.host).toLowerCase() === 'local') || {};
const localColor = /^#[0-9a-fA-F]{6}$/.test(String(localSrv.color || '').trim()) ? String(localSrv.color).trim() : '';
const localLabel = localSrv.name || 'Local';
let html = `<option value="local"${localColor ? ` style="color:${uiModule.esc(localColor)};"` : ''}>${uiModule.esc(localColor ? `${localLabel}` : localLabel)}</option>`;
_envState.servers.forEach((s, i) => {
if (!s.host) return;
const label = s.name || s.host || `Server ${i + 1}`;
html += `<option value="${i}">${uiModule.esc(label)}</option>`;
const color = /^#[0-9a-fA-F]{6}$/.test(String(s.color || '').trim()) ? String(s.color).trim() : '';
html += `<option value="${uiModule.esc(_serverKey(s))}"${color ? ` style="color:${uiModule.esc(color)};"` : ''}>${uiModule.esc(color ? `${label}` : label)}</option>`;
});
sel.innerHTML = html;
sel.value = currentVal;
if (sel.selectedIndex < 0) sel.value = _currentServerValue();
if (sel.selectedIndex < 0) sel.value = 'local';
_applyServerSelectColor(sel);
}
}
@@ -2066,13 +2134,15 @@ export function _hwfitInit() {
const port = row.querySelector('.cookbook-srv-port')?.value.trim() || '';
const env = row.querySelector('.cookbook-srv-env')?.value || 'none';
const envPath = row.querySelector('.cookbook-srv-path')?.value.trim() || '';
const colorRaw = row.querySelector('.cookbook-srv-color')?.value?.trim() || '';
const color = /^#[0-9a-fA-F]{6}$/.test(colorRaw) ? colorRaw : '';
// Collect model directories from tags. Read the authoritative data-dir
// attribute, not textContent \u2014 the tag now also holds a download-target
// icon, and textContent would fold the icon/\u2716 glyph into the path.
const dirTags = entry.querySelectorAll('.cookbook-modeldir-tag');
const modelDirs = [];
dirTags.forEach(tag => {
const d = (tag.dataset.dir || '').replaceAll('\u2715', '').replaceAll('\u2716', '').trim();
const d = _normalizeCookbookModelDir(tag.dataset.dir || '');
if (d) modelDirs.push(d);
});
if (!modelDirs.length) modelDirs.push('~/.cache/huggingface/hub');
@@ -2080,7 +2150,7 @@ export function _hwfitInit() {
const dlEl = entry.querySelector('.cookbook-modeldir-dl.active');
const downloadDir = dlEl ? (dlEl.dataset.dlDir || '') : '';
const platform = entry.dataset.platform || '';
_envState.servers.push({ name, host: host || '', port, env, envPath, modelDirs, modelDir: modelDirs.filter(d => d !== '~/.cache/huggingface/hub')[0] || modelDirs[0], downloadDir, platform });
_envState.servers.push({ name, host: host || '', port, env, envPath, color, modelDirs, modelDir: modelDirs.filter(d => d !== '~/.cache/huggingface/hub')[0] || modelDirs[0], downloadDir, platform });
});
// Do NOT auto-change the selected host here. _syncServers can run while the
// servers DOM is mid-render — host fields that are disabled/readonly read as
@@ -2259,8 +2329,7 @@ export function _hwfitInit() {
document.querySelectorAll('.cookbook-srv-default').forEach(b => {
const on = !!_envState.defaultServer && b.dataset.srvKey === _envState.defaultServer;
b.classList.toggle('active', on);
// Keep the "default" label after the icon (don't overwrite it).
b.innerHTML = (on ? _MODELDIR_CHECK_ON : _MODELDIR_CHECK_OFF) + '<span class="cookbook-srv-default-label">default</span>';
b.innerHTML = _serverDefaultHtml(on);
b.title = on ? 'Default server — Cookbook opens here' : 'Make this the default server';
});
// Apply immediately so the dropdowns reflect it without reopening
@@ -2307,10 +2376,28 @@ export function _hwfitInit() {
uiModule.showToast('SSH setup command copied');
});
}
_wireServerColorPicker(entry);
entry.querySelectorAll('input, select').forEach(el => {
el.addEventListener('change', () => {
const selectedBefore = _envState.remoteHost || '';
const entryHost = entry.querySelector('.cookbook-srv-host')?.value?.trim() || '';
const color = entry.querySelector('.cookbook-srv-color')?.value?.trim() || '';
const hasColor = /^#[0-9a-fA-F]{6}$/.test(color);
const colorWrap = entry.querySelector('.cookbook-srv-color-wrap');
if (hasColor) {
entry.style.setProperty('--cookbook-server-color', color);
colorWrap?.style.setProperty('--cookbook-server-color', color);
} else {
const autoColor = (colorWrap?.style.getPropertyValue('--cookbook-server-color') || entry.style.getPropertyValue('--cookbook-server-color') || '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(autoColor)) {
entry.style.setProperty('--cookbook-server-color', autoColor);
colorWrap?.style.setProperty('--cookbook-server-color', autoColor);
} else {
entry.style.removeProperty('--cookbook-server-color');
colorWrap?.style.removeProperty('--cookbook-server-color');
}
}
colorWrap?.classList.toggle('has-color', true);
_syncServers();
_rebuildServerSelect();
if (selectedBefore && selectedBefore === entryHost) {
@@ -2320,6 +2407,11 @@ export function _hwfitInit() {
if (!entry.querySelector('.cookbook-server-key-panel')?.classList.contains('hidden')) {
_populateServerKeyPanel(entry, false);
}
const saveBtn = entry.querySelector('.cookbook-server-save-btn.saved');
if (saveBtn) {
saveBtn.classList.remove('saved');
saveBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save';
}
});
});
// Manual connectivity test after editing host or port. Existing saved
@@ -2341,7 +2433,7 @@ export function _hwfitInit() {
_hwfitFetch();
});
}
// Save button on a brand-new server entry: persist + confirm with a check.
// Save button: persist + confirm with a check.
const saveBtn = entry.querySelector('.cookbook-server-save-btn');
if (saveBtn && !saveBtn.dataset.bound) {
saveBtn.dataset.bound = '1';
@@ -2359,6 +2451,7 @@ export function _hwfitInit() {
} catch (_) {}
saveBtn.classList.add('saved');
saveBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><polyline points="20 6 9 17 4 12"/></svg>Saved';
uiModule.showToast('Server saved');
});
}
const rmBtn = entry.querySelector('.cookbook-server-rm');
@@ -2520,7 +2613,7 @@ export function _hwfitInit() {
// Build the new entry with the SAME template as existing servers (Model
// Directory header, default checkmark, platform icon) \u2014 isNew swaps the
// delete button for a Save button. forceRemote keeps it editable.
const blank = { host: '', name: '', port: '', env: 'none', envPath: '', platform: '', modelDirs: ['~/.cache/huggingface/hub'] };
const blank = { host: '', name: '', port: '', env: 'none', envPath: '', color: '', platform: '', modelDirs: ['~/.cache/huggingface/hub'] };
const wrap = document.createElement('div');
wrap.innerHTML = _serverEntryHtml(blank, idx, _envState.defaultServer || '', true, true);
const entry = wrap.firstElementChild;
@@ -2554,6 +2647,7 @@ export function _hwfitInit() {
}
}
_persistEnvState();
_applyServerSelectColor(serverSelect);
// Keep the other server dropdowns (Download / Cache / Deps) in sync. The
// download-input button reads #hwfit-dl-server *directly*, so without this
// it kept its old value and downloads went to the wrong host even
@@ -2561,6 +2655,7 @@ export function _hwfitInit() {
document.querySelectorAll('#hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => {
if (!sel || sel.tagName !== 'SELECT') return;
sel.value = _currentServerValue();
_applyServerSelectColor(sel);
});
_hwfitCache = null;
// Reset GPU-toggle state (no flicker) so the new server's hardware re-renders.
@@ -2568,5 +2663,6 @@ export function _hwfitInit() {
_hwfitFetch();
});
}
_syncServerSelectColors();
}
+276 -43
View File
@@ -60,6 +60,11 @@ if (typeof window !== 'undefined' && !window._tagScrollGuardWired) {
export const _MODELDIR_CHECK_OFF = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/></svg>';
export const _MODELDIR_CHECK_ON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="8 12 11 15 16 9"/></svg>';
function _normalizeCookbookModelDir(dir) {
const d = String(dir || '').replaceAll('✕', '').replaceAll('✖', '').trim();
return /^(home|mnt|media|data|opt|srv|var)\//.test(d) ? `/${d}` : d;
}
// Monochrome platform glyphs (currentColor) for a server's OS tag: a penguin for
// Linux, the four-pane logo for Windows, an Android robot for Termux/Android.
function _platformIcon(platform) {
@@ -170,6 +175,92 @@ function _gemma4ThinkingChatTemplateArg(modelName) {
: '';
}
const _SERVER_COLOR_CHOICES = [
['', 'Auto'],
['#bd93f9', 'Purple'],
['#ff79c6', 'Pink'],
['#fca5a5', 'Red'],
['#93c5fd', 'Blue'],
['#86efac', 'Green'],
['#d6b37a', 'Bronze'],
['#111827', 'Black'],
['#f8fafc', 'White'],
['#c0c4cc', 'Silver'],
['#d9f99d', 'Lime'],
['#ccfbf1', 'Mint'],
];
const _SERVER_AUTO_COLOR_VALUES = _SERVER_COLOR_CHOICES.slice(1).map(([value]) => value);
function _serverColorValue(value) {
const v = String(value || '').trim();
return /^#[0-9a-fA-F]{6}$/.test(v) ? v : '';
}
function _serverColor(s) {
return _serverColorValue(s && s.color);
}
function _serverColorLabel(color) {
const hit = _SERVER_COLOR_CHOICES.find(([value]) => value === color);
return hit ? hit[1] : 'Auto';
}
function _autoServerColor(index) {
const servers = Array.isArray(_envState.servers) ? _envState.servers : [];
const explicit = new Set(servers.map(s => _serverColor(s)).filter(Boolean));
const used = new Set(explicit);
for (let j = 0; j <= index; j++) {
const s = servers[j] || {};
const explicitColor = _serverColor(s);
if (explicitColor) continue;
const picked = _SERVER_AUTO_COLOR_VALUES.find(c => !used.has(c)) || _SERVER_AUTO_COLOR_VALUES[j % _SERVER_AUTO_COLOR_VALUES.length] || '';
if (j === index) return picked;
if (picked) used.add(picked);
}
return _SERVER_AUTO_COLOR_VALUES[index % _SERVER_AUTO_COLOR_VALUES.length] || '';
}
function _resolvedServerColor(s, index) {
return _serverColor(s) || _autoServerColor(index);
}
function _serverOptionLabel(label, color) {
return color ? `${label}` : label;
}
function _serverOptionStyle(color) {
return color ? ` style="color:${esc(color)};"` : '';
}
function _serverColorOptionStyle(color) {
if (!color) return ' style="background:var(--bg);color:var(--fg);"';
const c = String(color).toLowerCase();
const fg = (c === '#111827') ? '#f8fafc' : (c === '#f8fafc' || c === '#fca5a5' || c === '#93c5fd' || c === '#86efac' || c === '#d9f99d' || c === '#ccfbf1' || c === '#c0c4cc') ? '#111827' : color;
return ` style="color:${esc(fg)};background-color:color-mix(in srgb, ${esc(color)} 28%, var(--bg));"`;
}
function _serverColorForValue(value) {
const s = _serverByVal(value);
const idx = Array.isArray(_envState.servers) ? _envState.servers.indexOf(s) : -1;
return s ? _resolvedServerColor(s, idx >= 0 ? idx : 0) : '';
}
export function _applyServerSelectColor(sel) {
if (!sel || sel.tagName !== 'SELECT') return;
const color = _serverColorForValue(sel.value);
if (color) {
sel.style.setProperty('--cookbook-server-color', color);
sel.classList.add('cookbook-server-select-colored');
} else {
sel.style.removeProperty('--cookbook-server-color');
sel.classList.remove('cookbook-server-select-colored');
}
}
export function _syncServerSelectColors(root = document) {
root.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(_applyServerSelectColor);
}
function _buildServerOpts(excludeLocal = false) {
// The local server is ALWAYS represented by the synthetic value="local" option
// (showing its custom name from the "server name" feature). We must therefore
@@ -177,7 +268,8 @@ function _buildServerOpts(excludeLocal = false) {
const _localIdx = _envState.servers.findIndex(_isLocalEntry);
const _localSrv = _localIdx >= 0 ? _envState.servers[_localIdx] : null;
const _localLabel = (_localSrv && _localSrv.name) ? _localSrv.name : 'Local';
let html = `<option value="local"${!_envState.remoteHost ? ' selected' : ''}>${esc(_localLabel)}</option>`;
const _localColor = _localSrv ? _resolvedServerColor(_localSrv, _localIdx) : '';
let html = `<option value="local"${_serverOptionStyle(_localColor)}${!_envState.remoteHost ? ' selected' : ''}>${esc(_serverOptionLabel(_localLabel, _localColor))}</option>`;
const selectedKey = _envState.remoteServerKey || '';
let legacyHostSelected = false;
for (let i = 0; i < _envState.servers.length; i++) {
@@ -186,12 +278,13 @@ function _buildServerOpts(excludeLocal = false) {
if (excludeLocal && _isLocalEntry(s)) continue;
const label = s.name || s.host || `Server ${i + 1}`;
const value = _serverKey(s);
const color = _resolvedServerColor(s, i);
let selected = selectedKey ? value === selectedKey : false;
if (!selectedKey && _envState.remoteHost === s.host && !legacyHostSelected) {
selected = true;
legacyHostSelected = true;
}
html += `<option value="${esc(value)}"${selected ? ' selected' : ''}>${esc(label)}</option>`;
html += `<option value="${esc(value)}"${_serverOptionStyle(color)}${selected ? ' selected' : ''}>${esc(_serverOptionLabel(label, color))}</option>`;
}
return html;
}
@@ -345,6 +438,8 @@ export function _detectReasoningParser(modelName) {
// MiniMax M2 / M2.5 / M2.7 — released with a dedicated parser. Catch M2
// before plain "minimax" so M2.x doesn't fall through to a wrong parser.
if (n.includes('minimax') && n.match(/\bm2(?:\.\d)?\b/)) return 'minimax_m2';
// DeepSeek-V4 has a dedicated parser in SGLang. Keep it before R1/V3.
if (n.includes('deepseek') && /\bv[-_]?4\b/.test(n)) return 'deepseek-v4';
// DeepSeek-R1 / V3-Thinking / V3.1-Thinking variants. Bare V3/V3.1 (non-
// thinking) skip this — they're not reasoning models.
if (n.includes('deepseek') && (n.includes('r1') || n.includes('thinking'))) return 'deepseek_r1';
@@ -382,6 +477,7 @@ export function _detectToolParser(modelName) {
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';
if (n.includes('deepseek') && /\bv[-_]?4\b/.test(n)) return 'deepseekv4';
if (n.includes('deepseek-v3')) return 'deepseek_v3';
if (n.includes('deepseek')) return 'deepseek_v3';
if (n.includes('minimax') && /\bm3\b/.test(n)) return 'minimax_m3';
@@ -409,7 +505,7 @@ export function _detectBackend(model) {
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' };
return { backend: 'mlx', label: 'MLX' };
}
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 hasGgufFile = Array.isArray(model.gguf_files)
@@ -442,7 +538,7 @@ export function _detectBackend(model) {
// 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' };
return { backend: 'mlx', label: 'MLX' };
}
// ROCm/AMD machines should not blindly default HF safetensors models to
@@ -540,13 +636,32 @@ function _venvRootFromPath(path) {
return p;
}
function _venvLooksWrongForPlatform(path, platform) {
const p = String(path || '').trim();
const plat = String(platform || '').toLowerCase();
if (!p || !plat) return false;
if ((plat === 'darwin' || plat === 'macos') && /^\/(?:home|usr\/local\/cuda|opt\/conda)\//.test(p)) return true;
if ((plat === 'linux' || plat === 'termux') && /^\/(?:Users|opt\/homebrew)\//.test(p)) return true;
return false;
}
function _isDeepSeekV4Model(modelName) {
const n = String(modelName || '').toLowerCase();
return n.includes('deepseek') && /\bv[-_]?4\b/.test(n);
}
function _envHasKey(envText, key) {
return String(envText || '').split(/\s+/).some(part => part.startsWith(`${key}=`));
}
export function _buildServeCmd(f, modelName, backend) {
// When a venv is configured on the chosen server, use the venv's binaries
// by absolute path. Bare `vllm` / `python3` relies on PATH, and SSH non-
// interactive sessions often leave a user-site install (~/.local/bin/vllm)
// ahead of the venv's bin, so the WRONG vllm gets launched even with the
// venv activated. Absolute path sidesteps the whole PATH question.
const _formVenv = (f.venv ?? '').toString().trim();
let _formVenv = (f.venv ?? '').toString().trim();
if (_venvLooksWrongForPlatform(_formVenv, f.platform)) _formVenv = '';
const _activeVenvPath = _venvRootFromPath(_formVenv || (_envState.env === 'venv' ? (_envState.envPath || '') : ''));
const _venvBin = _activeVenvPath ? (_activeVenvPath + '/bin/') : '';
const _vllmBin = _venvBin ? `${_venvBin}vllm` : 'vllm';
@@ -618,14 +733,19 @@ export function _buildServeCmd(f, modelName, backend) {
// button strip is the only source for which devices to pin.
const gpuId = (f.gpus || f.gpu_id || '').toString().trim();
cmd += _gpuEnvPrefix(gpuId);
const _extraEnv = (f.extra_env ?? '').toString().replace(/\s+/g, ' ').trim();
const _isDsv4 = _isDeepSeekV4Model(modelName);
let _extraEnv = (f.extra_env ?? '').toString().replace(/\s+/g, ' ').trim();
if (_isDsv4 && !_envHasKey(_extraEnv, 'SGLANG_DSV4_COMPRESS_STATE_DTYPE')) {
_extraEnv = (`SGLANG_DSV4_COMPRESS_STATE_DTYPE=bf16 ${_extraEnv}`).trim();
}
if (_extraEnv) cmd += _extraEnv + ' ';
cmd += `${_py3Bin} -m sglang.launch_server --model-path ${modelName} --host 0.0.0.0 --port ${f.port || '30000'}`;
const _gemma4ChatTemplate = _gemma4ThinkingChatTemplateArg(modelName);
if (_gemma4ChatTemplate) cmd += ` --chat-template ${_gemma4ChatTemplate}`;
if (f.tp && f.tp !== '1') cmd += ` --tp ${f.tp}`;
if (f.ctx) cmd += ` --context-length ${f.ctx}`;
if (f.gpu_mem && f.gpu_mem !== '0.90') cmd += ` --mem-fraction-static ${f.gpu_mem}`;
const _memFraction = _isDsv4 && (!f.gpu_mem || f.gpu_mem === '0.90') ? '0.80' : f.gpu_mem;
if (_memFraction && _memFraction !== '0.90') cmd += ` --mem-fraction-static ${_memFraction}`;
if (f.dtype && f.dtype !== 'auto') cmd += ` --dtype ${f.dtype}`;
if (f.max_seqs && f.max_seqs.toString().trim()) cmd += ` --max-running-requests ${f.max_seqs.toString().trim()}`;
if (f.trust_remote) cmd += ' --trust-remote-code';
@@ -638,6 +758,14 @@ export function _buildServeCmd(f, modelName, backend) {
}
if (!f.prefix_cache) cmd += ' --disable-radix-cache';
if (f.enforce_eager) cmd += ' --disable-cuda-graph';
const _decodeGraph = String(f.sglang_decode_graph || '').trim();
if (!f.enforce_eager && _decodeGraph === 'disabled') {
cmd += ' --cuda-graph-backend-decode disabled';
} else if (!f.enforce_eager && _decodeGraph === 'bs16') {
cmd += ' --cuda-graph-max-bs-decode 16';
} else if (!f.enforce_eager && _isDsv4 && !/\s--cuda-graph-max-bs-decode\b/.test(cmd) && !/\s--cuda-graph-backend-decode\b/.test(cmd)) {
cmd += ' --cuda-graph-backend-decode disabled';
}
} else if (backend === 'llamacpp') {
const ggufPath = f._gguf_path || 'model.gguf';
// GPU list — read from gpus (button strip); fall back to gpu_id for
@@ -812,6 +940,13 @@ export function _buildServeCmd(f, modelName, backend) {
if (f.diff_attention_slicing) cmd += ' --attention-slicing';
if (f.diff_vae_slicing) cmd += ' --vae-slicing';
if (f.diff_harmonize_gpu) cmd += ` --harmonize-gpu ${f.diff_harmonize_gpu}`;
} else if (backend === 'mlx') {
const mlxPy = _isWindows() ? 'python' : _py3Bin;
const mlxHost = f.host ? '0.0.0.0' : '127.0.0.1';
cmd += `${mlxPy} -m mlx_lm.server --model ${_shellQuote(modelName)} --host ${mlxHost} --port ${f.port || '8080'}`;
if (/minimax|mini-max/i.test(modelName)) {
cmd += ' --temp 0.7 --top-p 0.9 --max-tokens 2048';
}
}
return cmd;
}
@@ -933,6 +1068,7 @@ async function _fetchDependencies() {
const pkgs = data.packages || [];
if (!pkgs.length) { list.innerHTML = '<div class="hwfit-loading">No packages found</div>'; return; }
const _winUnsupported = new Set(['hf_transfer', 'vllm', 'rembg', 'gfpgan']);
const _systemInstallable = new Set(['tmux']);
const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => {
if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`;
@@ -944,8 +1080,12 @@ async function _fetchDependencies() {
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.');
if (pkg.applicable !== false && _systemInstallable.has(pkg.name)) {
return `<button type="button" class="cookbook-dep-tag cookbook-dep-install cookbook-dep-install-sysdeps" data-dep-sysdeps="${esc(pkg.name)}" data-dep-target="${isLocal ? 'local' : 'remote'}" title="${depTip}">Install</button>`;
}
const depLabel = pkg.applicable === false ? 'N/A ?' : 'Missing';
return `<span class="cookbook-dep-tag cookbook-dep-na" title="${depTip}">${depLabel}</span>`;
const depStyle = pkg.name === 'docker' ? ' style="width:87.7px;justify-content:center;"' : '';
return `<span class="cookbook-dep-tag cookbook-dep-na" title="${depTip}"${depStyle}>${depLabel}</span>`;
}
return `<button class="cookbook-dep-tag cookbook-dep-install" data-dep-pip="${esc(pkg.pip)}" data-dep-target="${isLocal ? 'local' : 'remote'}">Install</button>`;
};
@@ -957,6 +1097,7 @@ async function _fetchDependencies() {
const _DEP_GLYPHS = {
vllm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
sglang: '<span aria-hidden="true" style="display:block;width:13px;height:13px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
mlx_lm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"/><path d="M16 6v12"/><path d="M20 6v12"/></svg>',
llama_cpp: '<svg width="13" height="13" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"/><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"/></svg>',
ollama: '<img src="/static/icons/ollama-mark-crop.png" alt="" aria-hidden="true" width="13" height="13" style="display:block;width:13px;height:13px;object-fit:contain;" />',
diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
@@ -1121,22 +1262,32 @@ async function _fetchDependencies() {
// "Update" item in an installed package's ⋮ menu. `upgrade` adds pip -U;
// `statusEl`, when given, shows "Installing…/Updating…" and is disabled.
async function _installDep(pipName, pkgName, isLocalOnly, upgrade, statusEl) {
let targetServer = null;
if (isLocalOnly) {
_envState.remoteHost = '';
_envState.env = 'none';
_envState.envPath = '';
} else {
const depsServerSel = document.getElementById('hwfit-deps-server');
if (depsServerSel) _applyServerSelection(depsServerSel.value);
if (depsServerSel) {
targetServer = _serverByVal(depsServerSel.value);
_applyServerSelection(depsServerSel.value);
}
}
const targetHost = isLocalOnly ? 'this server' : (_envState.remoteHost || 'local');
const targetHost = isLocalOnly ? 'this server' : ((targetServer?.host || _envState.remoteHost) || 'local');
const targetEnv = isLocalOnly ? 'none' : (targetServer?.env || _envState.env || 'none');
const targetEnvPath = isLocalOnly ? '' : (targetServer?.envPath || _envState.envPath || '');
const targetPlatform = isLocalOnly ? (_envState.hostPlatform || _envState.platform || '') : (targetServer?.platform || _envState.platform || '');
const targetRemoteHost = isLocalOnly ? '' : (targetServer?.host || _envState.remoteHost || '');
// Always go through `python -m pip` so the leading token is `python`
// — matches the /api/model/serve allow-list (bare `pip` is blocked).
// Inside a venv/conda env, `--user` is invalid (pip refuses), so we
// only add `--user --break-system-packages` when there's no env —
// for PEP-668-locked system pythons (Arch, newer Debian).
const _inEnv = _envState.env === 'venv' || _envState.env === 'conda';
const _pipFlags = (!_isWindows() && !_inEnv) ? ' --user --break-system-packages' : '';
const _inEnv = targetEnv === 'venv' || targetEnv === 'conda';
const _platform = String(targetPlatform || '').toLowerCase();
const _isAppleTarget = _platform === 'darwin' || _platform === 'macos' || _platform.includes('mac os');
const _pipFlags = (!_isWindows() && !_inEnv) ? (_isAppleTarget ? ' --user' : ' --user --break-system-packages') : '';
// Use the venv's python3 by absolute path when configured. Even with the
// env_prefix sourcing activate, SSH non-interactive sessions sometimes
// pick a `python3` ahead of the venv's bin on PATH, so the install
@@ -1144,35 +1295,35 @@ async function _fetchDependencies() {
let _py;
if (_isWindows()) {
_py = 'python';
} else if (_envState.env === 'venv' && _envState.envPath) {
_py = `${_envState.envPath.replace(/\/+$/, '')}/bin/python3`;
} else if (targetEnv === 'venv' && targetEnvPath) {
_py = `${targetEnvPath.replace(/\/+$/, '')}/bin/python3`;
} else {
_py = 'python3';
}
const cmd = `${_py} -m pip install${upgrade ? ' -U' : ''}${_pipFlags} "${pipName}"`;
let envPrefix = '';
if (_isWindows()) {
if (_envState.env === 'venv' && _envState.envPath) {
envPrefix = '& ' + _psQuote(_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1');
} else if (_envState.env === 'conda' && _envState.envPath) {
envPrefix = 'conda activate ' + _psQuote(_envState.envPath);
if (targetEnv === 'venv' && targetEnvPath) {
envPrefix = '& ' + _psQuote(targetEnvPath.endsWith('\\Scripts\\Activate.ps1') ? targetEnvPath : targetEnvPath + '\\Scripts\\Activate.ps1');
} else if (targetEnv === 'conda' && targetEnvPath) {
envPrefix = 'conda activate ' + _psQuote(targetEnvPath);
}
} else {
if (_envState.env === 'venv' && _envState.envPath) {
const p = _envState.envPath;
if (targetEnv === 'venv' && targetEnvPath) {
const p = targetEnvPath;
envPrefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate');
} else if (_envState.env === 'conda' && _envState.envPath) {
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
} else if (targetEnv === 'conda' && targetEnvPath) {
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(targetEnvPath);
}
}
try {
const reqBody = {
repo_id: pipName,
cmd: cmd,
remote_host: _envState.remoteHost || undefined,
ssh_port: _getPort(_envState.remoteHost) || undefined,
remote_host: targetRemoteHost || undefined,
ssh_port: _getPort(targetRemoteHost) || undefined,
env_prefix: envPrefix || undefined,
platform: _envState.platform || undefined,
platform: targetPlatform || undefined,
};
const res = await fetch('/api/model/serve', {
method: 'POST', credentials: 'same-origin',
@@ -1196,7 +1347,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, env_path: _envState.envPath || '' };
const payload = { repo_id: pipName, _cmd: cmd, remote_host: targetRemoteHost || '', _dep: true, env_path: targetEnvPath || '', platform: targetPlatform || '' };
_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}...`);
@@ -1334,7 +1485,7 @@ async function _fetchDependencies() {
// from the row) so the user can copy-paste it without leaving
// the toast. Otherwise just surface the error.
const _suffix = _resolvedCmd ? `\n\nRun on ${targetLabel}: ${_resolvedCmd}` : '';
uiModule.showToast('Build-deps install failed: ' + String(reason).slice(0, 300) + _suffix, {
uiModule.showToast('System dependency install failed: ' + String(reason).slice(0, 300) + _suffix, {
duration: 25000,
action: _resolvedCmd ? 'Copy command' : 'OK',
onAction: async () => {
@@ -1638,9 +1789,47 @@ function _applyServerSelection(val) {
sel.value = _want;
if (sel.selectedIndex < 0) sel.value = 'local';
}
_applyServerSelectColor(sel);
});
}
async function _refreshScanDownloadTarget() {
const btn = document.getElementById('hwfit-hw-refresh-btn');
if (btn && btn.disabled) return;
const selectedVal = document.getElementById('hwfit-server-select')?.value || _currentServerValue();
if (btn) {
btn.disabled = true;
btn.style.opacity = '0.55';
btn.style.cursor = 'wait';
}
try {
if (selectedVal) _applyServerSelection(selectedVal);
const ok = await _syncFromServer().catch((e) => {
console.warn('[cookbook] explicit server sync failed', e);
return false;
});
if (ok) {
try { Object.assign(_envState, _readStoredEnvState()); } catch {}
if (selectedVal) _applyServerSelection(selectedVal);
}
_resetGpuToggleState();
await Promise.allSettled([
_hwfitFetch(true),
_fetchCachedModels(true),
]);
if (uiModule?.showToast) uiModule.showToast('Refreshed selected server');
} catch (e) {
console.warn('[cookbook] scan/download refresh failed', e);
if (uiModule?.showError) uiModule.showError('Refresh failed: ' + (e?.message || e));
} finally {
if (btn) {
btn.disabled = false;
btn.style.opacity = '';
btn.style.cursor = '';
}
}
}
function _wireTabEvents(body) {
// Tab switching
body.querySelectorAll('.cookbook-tab').forEach(tab => {
@@ -1701,17 +1890,18 @@ function _wireTabEvents(body) {
const port = entry.querySelector('.cookbook-srv-port')?.value?.trim() || '';
const env = entry.querySelector('.cookbook-srv-env')?.value || 'none';
const envPath = entry.querySelector('.cookbook-srv-path')?.value?.trim() || '';
const color = _serverColorValue(entry.querySelector('.cookbook-srv-color')?.value || '');
const platform = entry.dataset.platform || '';
const dirs = [];
entry.querySelectorAll('.cookbook-modeldir-tag').forEach(tag => {
// Read from data attribute (authoritative) — never parse displayed text
const d = (tag.dataset.dir || '').replaceAll('✕', '').replaceAll('✖', '').trim();
const d = _normalizeCookbookModelDir(tag.dataset.dir || '');
if (d) dirs.push(d);
});
// Directory flagged as the download target ('' = default HF cache).
const dlEl = entry.querySelector('.cookbook-modeldir-dl.active');
const downloadDir = dlEl ? (dlEl.dataset.dlDir || '') : '';
servers.push({ name, host, port, env, envPath, modelDirs: dirs, downloadDir, platform });
servers.push({ name, host, port, env, envPath, color, modelDirs: dirs, downloadDir, platform });
});
_envState.servers = servers;
// Auto-default: when the user has configured EXACTLY ONE remote server
@@ -1737,13 +1927,16 @@ function _wireTabEvents(body) {
Promise.resolve().then(() => {
const _want = _currentServerValue();
document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => {
if (sel && sel.tagName === 'SELECT') sel.value = _want;
if (sel && sel.tagName === 'SELECT') {
sel.value = _want;
_applyServerSelectColor(sel);
}
});
});
}
// Wire server form inputs
document.querySelectorAll('.cookbook-srv-name, .cookbook-srv-host, .cookbook-srv-port, .cookbook-srv-path').forEach(el => {
document.querySelectorAll('.cookbook-srv-name, .cookbook-srv-color, .cookbook-srv-host, .cookbook-srv-port, .cookbook-srv-path').forEach(el => {
el.addEventListener('change', _syncServers);
});
document.querySelectorAll('.cookbook-srv-env').forEach(el => {
@@ -1788,7 +1981,7 @@ function _wireTabEvents(body) {
if (cacheDirEl) cacheDirEl.value = srv.modelDir || '~/.cache/huggingface/hub';
const dirsEl = document.querySelector('.cookbook-serve-dirs');
if (dirsEl) {
const dirs = (Array.isArray(srv.modelDirs) ? srv.modelDirs : [srv.modelDir || '~/.cache/huggingface/hub']).map(d => d.replaceAll('✕', '').replaceAll('✖', '').trim()).filter(Boolean);
const dirs = (Array.isArray(srv.modelDirs) ? srv.modelDirs : [srv.modelDir || '~/.cache/huggingface/hub']).map(d => _normalizeCookbookModelDir(d)).filter(Boolean);
dirsEl.innerHTML = dirs.map(d => `<span class="cookbook-serve-dir-pill">${esc(d)}</span>`).join('') +
'<span class="cookbook-serve-dir-edit" title="Edit in Settings">edit</span>';
dirsEl.querySelector('.cookbook-serve-dir-edit')?.addEventListener('click', () => {
@@ -1802,7 +1995,22 @@ function _wireTabEvents(body) {
const scanBtn = document.getElementById('hwfit-cache-scan');
if (scanBtn) {
scanBtn.addEventListener('click', () => _fetchCachedModels(true));
scanBtn.addEventListener('click', async () => {
if (scanBtn.disabled) return;
scanBtn.disabled = true;
scanBtn.classList.add('spinning');
try {
await _fetchCachedModels(true);
} finally {
scanBtn.disabled = false;
scanBtn.classList.remove('spinning');
}
});
}
const hwRefreshBtn = document.getElementById('hwfit-hw-refresh-btn');
if (hwRefreshBtn) {
hwRefreshBtn.addEventListener('click', _refreshScanDownloadTarget);
}
const editDirsLink = document.querySelector('.cookbook-serve-dir-edit');
@@ -1822,6 +2030,7 @@ function _wireTabEvents(body) {
_fetchDependencies();
});
}
_syncServerSelectColors(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,
@@ -2522,11 +2731,30 @@ function _wireTabEvents(body) {
// (Model Directory header, default-server checkmark, trash delete, platform icon).
// forceRemote renders an editable remote entry even before a host is typed
// (a new server's host is empty, which would otherwise read as "Local").
export function _serverDefaultHtml(active) {
const check = active ? '<span class="hwfit-hf-check cookbook-srv-default-check" title="Default server" style="font-weight:800;color:var(--green,#50fa7b);font-size:15px;line-height:1;flex-shrink:0;position:relative;top:2px;">✓</span>' : '';
return `${check}<span class="cookbook-srv-default-label">default</span>`;
}
export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) {
const isLocal = (forceRemote || isNew) ? false : (!s.host || s.host === 'local');
const envOpts = [['none', 'None'], ['venv', 'venv'], ['conda', 'conda']].map(([value, label]) => `<option value="${value}"${s.env === value ? ' selected' : ''}>${label}</option>`).join('');
const srvColor = _serverColor(s);
const resolvedSrvColor = _resolvedServerColor(s, i);
const colorOpts = _SERVER_COLOR_CHOICES.map(([value, label]) => {
const displayLabel = label;
return `<option value="${esc(value)}"${_serverColorOptionStyle(value)}${srvColor === value ? ' selected' : ''}>${esc(displayLabel)}</option>`;
}).join('');
const selectedColorLabel = srvColor ? _serverColorLabel(srvColor) : `Auto · ${_serverColorLabel(resolvedSrvColor)}`;
const colorMenu = _SERVER_COLOR_CHOICES.map(([value, label]) => {
const active = value === srvColor;
const swatchColor = value || resolvedSrvColor;
const rowLabel = value ? label : `Auto · ${_serverColorLabel(resolvedSrvColor)}`;
const swatch = swatchColor ? ` style="--swatch-color:${esc(swatchColor)};"` : '';
return `<button type="button" class="cookbook-srv-color-item${active ? ' active' : ''}" data-color="${esc(value)}"${swatch}><span class="cookbook-srv-color-item-dot"></span><span>${esc(rowLabel)}</span></button>`;
}).join('');
let html = '';
html += `<div class="cookbook-server-entry" data-idx="${i}" data-platform="${esc(s.platform || '')}">`;
html += `<div class="cookbook-server-entry" data-idx="${i}" data-platform="${esc(s.platform || '')}"${resolvedSrvColor ? ` style="--cookbook-server-color:${esc(resolvedSrvColor)};"` : ''}>`;
const _srvTitle = s.name || (isLocal ? 'Local' : (s.host || `Server ${i + 1}`));
const _srvKey = isLocal ? 'local' : (s.host || '');
const _isDefaultSrv = (defaultServer || '') === _srvKey;
@@ -2542,12 +2770,13 @@ export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) {
// sense once the server is saved.
html += `<span style="margin-left:auto;display:inline-flex;gap:4px;align-items:center;">${_checkBtn}${_keyBtn}<button class="cookbook-server-cancel-btn" title="Discard this new server" style="height:22px;box-sizing:border-box;display:inline-flex;align-items:center;position:relative;top:-2px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>Cancel</button></span>`;
} else {
html += `<span style="margin-left:auto;display:inline-flex;gap:4px;align-items:center;">${!isLocal ? _checkBtn + _keyBtn : ''}<span class="cookbook-srv-default${_isDefaultSrv ? ' active' : ''}" title="${_isDefaultSrv ? 'Default server — Cookbook opens here' : 'Make this the default server'}" data-srv-key="${esc(_srvKey)}">${_isDefaultSrv ? _MODELDIR_CHECK_ON : _MODELDIR_CHECK_OFF}<span class="cookbook-srv-default-label">default</span></span></span>`;
html += `<span style="margin-left:auto;display:inline-flex;gap:4px;align-items:center;">${!isLocal ? _checkBtn + _keyBtn : ''}<span class="cookbook-srv-default${_isDefaultSrv ? ' active' : ''}" title="${_isDefaultSrv ? 'Default server — Cookbook opens here' : 'Make this the default server'}" data-srv-key="${esc(_srvKey)}">${_serverDefaultHtml(_isDefaultSrv)}</span></span>`;
}
html += `</span>`;
html += `<div class="cookbook-server-row">`;
html += `<input type="text" class="hwfit-sf cookbook-srv-name" value="${esc(s.name || (isLocal ? 'Local' : ''))}" placeholder="Name (optional)" style="width:92px;flex-shrink:0;" />`;
html += `<input type="text" class="hwfit-sf cookbook-srv-host" value="${isLocal ? '' : esc(s.host || '')}" placeholder="e.g. user@ip" style="width:214.5px;flex-shrink:0;box-sizing:border-box;" ${isLocal ? 'readonly' : ''} />`;
html += `<span class="cookbook-srv-color-wrap has-color" title="Server color"><select class="hwfit-sf cookbook-srv-color" aria-hidden="true" tabindex="-1">${colorOpts}</select><button type="button" class="hwfit-sf cookbook-srv-color-btn" aria-haspopup="listbox" aria-expanded="false"><span class="cookbook-srv-color-dot" aria-hidden="true"></span><span class="cookbook-srv-color-label">${esc(selectedColorLabel)}</span><svg class="cookbook-srv-color-caret" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button><div class="cookbook-srv-color-menu hidden" role="listbox">${colorMenu}</div></span>`;
html += `<input type="text" class="hwfit-sf cookbook-srv-host" value="${isLocal ? '' : esc(s.host || '')}" placeholder="e.g. user@ip" style="width:184px;flex-shrink:0;box-sizing:border-box;" ${isLocal ? 'readonly' : ''} />`;
html += `<input type="text" class="hwfit-sf cookbook-srv-port" value="${esc(s.port || '')}" placeholder="Port" title="SSH port (default 22)" style="width:48px;flex-shrink:0;" ${isLocal ? 'readonly' : ''} />`;
html += `<select class="hwfit-sf cookbook-srv-env">${envOpts}</select>`;
html += `<input type="text" class="hwfit-sf cookbook-srv-path" value="${esc(s.envPath || '')}" placeholder="${s.platform === 'windows' ? 'venv/conda env' : '~/venv or conda-env'}" />`;
@@ -2567,13 +2796,15 @@ export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) {
html += `<span class="cookbook-modeldir-tag${isDefault ? ' cookbook-modeldir-default' : ''}${isTarget ? ' cookbook-modeldir-target' : ''}" data-dir-idx="${j}" data-dir="${esc(modelDirs[j])}">${dlBtn} ${esc(modelDirs[j])}${rmBtn}</span>`;
}
html += `<button class="cookbook-modeldir-add" title="Add model directory">+ Add</button>`;
const _btnStyle = 'margin-left:auto;position:relative;top:-2px;height:22px;box-sizing:border-box;display:inline-flex;align-items:center;';
const _btnBaseStyle = 'position:relative;top:-2px;height:22px;box-sizing:border-box;display:inline-flex;align-items:center;';
const _btnPushStyle = `margin-left:auto;${_btnBaseStyle}`;
if (isNew) {
// A brand-new server: Save (confirm) sits where Delete would be; Cancel is
// top-right in the title. Save confirms with a checkmark (auto-saves on edit too).
html += `<button class="cookbook-server-save-btn" title="Save this server" style="${_btnStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>`;
html += `<button class="cookbook-server-save-btn" title="Save this server" style="${_btnPushStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>`;
} else if (!isLocal) {
html += `<button class="cookbook-server-rm cookbook-server-rm-btn" title="Delete this server" style="${_btnStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>Delete</button>`;
html += `<button class="cookbook-server-rm cookbook-server-rm-btn" title="Delete this server" style="${_btnPushStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>Delete</button>`;
html += `<button class="cookbook-server-save-btn" title="Save server changes" style="${_btnBaseStyle}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;flex-shrink:0;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>`;
}
html += `</div>`;
if (!isLocal) {
@@ -2709,6 +2940,7 @@ function _renderRecipes() {
html += '<option value="">Engine</option>';
html += '<option value="llamacpp">llama.cpp</option>';
html += '<option value="ollama">Ollama</option>';
html += '<option value="mlx">MLX</option>';
html += '<option value="vllm">vLLM</option>';
html += '<option value="sglang">SGLang</option>';
html += '<option value="diffusers">Diffusers</option>';
@@ -2726,7 +2958,7 @@ function _renderRecipes() {
html += '<span class="hwfit-quant-wrap">';
html += '<select class="cookbook-field-input hwfit-quant" id="hwfit-quant" style="height:28px;">';
html += '<option value="" selected>Quant</option>';
html += '<option value="Q4_K_M">Q4</option><option value="Q8_0">Q8</option>';
html += '<option value="Q4_K_M">Q4 / AWQ</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><option value="FP4">FP4</option><option value="NVFP4">NVFP4</option></select>';
@@ -2744,9 +2976,8 @@ function _renderRecipes() {
html += _buildServerOpts(false);
html += '</select>';
html += '<div class="hwfit-gpu-toggles" id="hwfit-gpu-toggles"></div>';
// (Rescan button removed — Edit handles manual hardware updates;
// automatic re-probe runs on container restart.)
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-manual-btn" id="hwfit-hw-manual-btn" title="Set hardware manually" style="flex-shrink:0;position:relative;top:-3px;left:-1px;display:inline-flex;align-items:center;gap:3px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>EDIT</button>';
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-refresh-btn" id="hwfit-hw-refresh-btn" title="Refresh selected server hardware and cached models" aria-label="Refresh selected server hardware and cached models" style="flex-shrink:0;position:relative;top:-3px;left:-3px;width:26px;height:26px;padding:0;display:inline-flex;align-items:center;justify-content:center;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10"/><path d="M3.51 15a9 9 0 0 0 14.85 3.36L23 14"/></svg></button>';
// 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">';
@@ -2787,7 +3018,7 @@ function _renderRecipes() {
html += '<h2 style="margin:0;padding:0;line-height:1;">Serve <span id="serve-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>';
html += '</div>';
const _selSrv = _es.servers.find(s => s.host === _es.remoteHost) || _es.servers[0] || {};
const _srvDirs = (Array.isArray(_selSrv.modelDirs) ? _selSrv.modelDirs : [_selSrv.modelDir || '~/.cache/huggingface/hub']).map(d => d.replaceAll('✕', '').replaceAll('✖', '').trim()).filter(Boolean);
const _srvDirs = (Array.isArray(_selSrv.modelDirs) ? _selSrv.modelDirs : [_selSrv.modelDir || '~/.cache/huggingface/hub']).map(d => _normalizeCookbookModelDir(d)).filter(Boolean);
html += '<div class="cookbook-serve-dirs" style="margin-top:6px;">';
html += _srvDirs.map(d => `<span class="cookbook-serve-dir-pill">${esc(d)}</span>`).join('');
html += '<span class="cookbook-serve-dir-edit" title="Edit in Settings">edit</span>';
@@ -2797,6 +3028,7 @@ function _renderRecipes() {
html += '<select class="memory-sort-select" id="serve-sort" style="height:24px;">';
html += '<option value="name">Name</option><option value="size-desc">Size \u2193</option><option value="size-asc">Size \u2191</option><option value="recent">Recent</option>';
html += '</select>';
html += '<button type="button" class="hwfit-gpu-btn" id="hwfit-cache-scan" title="Refresh cached models on selected server" aria-label="Refresh cached models on selected server"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10"/><path d="M3.51 15a9 9 0 0 0 14.85 3.36L23 14"/></svg></button>';
html += '</div>';
html += '<div class="memory-toolbar" style="margin-top:8px;">';
html += '<div class="memory-category-filters">';
@@ -3170,6 +3402,7 @@ document.addEventListener('cookbook:state-synced', () => {
if (isVisible()) {
const activeTab = document.querySelector('#cookbook-modal .cookbook-tab.active')?.dataset?.backend || '';
if (activeTab === 'Running') _renderRunningTab();
else if (activeTab === 'Serve') _rerenderCachedModels();
}
});
+23 -6
View File
@@ -484,8 +484,12 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
// they disagree on the active host. The servers LIST is consistent, so we look
// up the matching server to get its env / path / platform / port.
let host;
let selectedServer = null;
let selectedServerKey = '';
if (hostOverride !== undefined) {
host = hostOverride || '';
selectedServer = host ? (_serverByVal?.(host) || (_envState.servers || []).find(s => s.host === host) || null) : null;
selectedServerKey = selectedServer ? (typeof window.cookbookModule?._serverKey === 'function' ? window.cookbookModule._serverKey(selectedServer) : '') : '';
} else {
// No explicit host passed: resolve from the visible server dropdown rather
// than _envState.remoteHost (unreliable — multiple state copies disagree).
@@ -496,15 +500,21 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
const _dsrv = (_ssv && _ssv !== 'local') ? (_serverByVal?.(_ssv) || _envState.servers[parseInt(_ssv)]) : null;
if (_dsrv) {
host = _dsrv.host;
selectedServer = _dsrv;
selectedServerKey = _ssv || '';
} else if (ssEl && ssEl.value === 'local') {
host = '';
} else {
host = _envState.remoteHost || '';
selectedServer = host ? ((_envState.servers || []).find(s => s.host === host) || _serverByVal?.(host) || null) : null;
}
}
const srv = _serverByVal?.(_envState.remoteServerKey || host) || {};
const env = host ? (srv.env || 'none') : (_envState.env || 'none');
const srv = selectedServer || _serverByVal?.(host) || {};
let env = host ? (srv.env || 'none') : (_envState.env || 'none');
const envPath = host ? (srv.envPath || '') : (_envState.envPath || '');
if ((!env || env === 'none') && envPath) {
env = /(?:^|\/)(?:\.?venv|env)(?:\/|$)|\/bin\/activate$/i.test(envPath) ? 'venv' : env;
}
const platform = host ? (srv.platform || '') : (_envState.platform || '');
const isWin = host ? (platform === 'windows') : _isWindows();
@@ -515,7 +525,13 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
// 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 (host) {
payload.remote_host = host;
if (selectedServerKey && selectedServerKey !== 'local') payload.remote_server_key = selectedServerKey;
if (srv.name) payload.remote_server_name = srv.name;
const _sp = srv.port || _getPort(host);
if (_sp) payload.ssh_port = _sp;
}
if (platform) payload.platform = platform;
// If this server has a directory flagged as the download target, send it so
// the backend downloads into <dir>/<model> instead of the default HF cache.
@@ -562,11 +578,12 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
if (zombieCandidate) {
try {
const _zh = zombieCandidate.remoteHost || '';
const _zPort = (_serverByVal?.(_envState.remoteServerKey || _zh)
const _zPort = (_serverByVal?.(zombieCandidate.remoteServerKey || zombieCandidate.payload?.remote_server_key || _zh)
|| (_envState.servers || []).find(s => s.host === _zh) || {}).port;
const _sshPf = _zh ? `ssh ${_zPort && _zPort !== '22' ? `-p ${_zPort} ` : ''}${_zh} '` : '';
const _sshSf = _zh ? `'` : '';
const _probeCmd = `${_sshPf}tmux has-session -t ${zombieCandidate.sessionId} 2>/dev/null${_sshSf}`;
const _probePrefix = _zh ? 'PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"; ' : '';
const _probeCmd = `${_sshPf}${_probePrefix}tmux has-session -t ${zombieCandidate.sessionId} 2>/dev/null${_sshSf}`;
const _r = await fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
@@ -593,7 +610,7 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
if (activeOnHost) {
const queueId = `queue-${Date.now().toString(36)}`;
const allTasks = _loadTasks();
allTasks.push({ id: queueId, sessionId: queueId, name: taskName, type: 'download', status: 'queued', output: '', ts: Date.now(), payload, remoteHost: host });
allTasks.push({ id: queueId, sessionId: queueId, name: taskName, type: 'download', status: 'queued', output: '', ts: Date.now(), payload, remoteHost: host, remoteServerKey: payload.remote_server_key || '', remoteServerName: payload.remote_server_name || '', sshPort: payload.ssh_port || '', platform: payload.platform || '' });
_saveTasks(allTasks);
_renderRunningTab();
uiModule.showToast(`Queued ${shortName} — waiting for current download`);
+279 -23
View File
@@ -113,7 +113,7 @@ function _downloadOutputLooksActive(task) {
function _canClearTask(task) {
if (!task || task.status === 'running') return false;
if (task.type === 'serve' && (task.status === 'ready' || (task._serveReady && !['stopped', 'error', 'crashed', 'failed', 'completed'].includes(task.status)))) return false;
if (task.type === 'serve' && (task.status === 'ready' || (!['error', 'crashed', 'failed', 'completed'].includes(task.status) && _serveOutputLooksReady(task)))) return false;
// If the tmux output still shows an in-flight download, the task isn't
// actually finished — hide the clear/check pill so it doesn't show on a
// task that's still doing work. (The next render will reflect this and
@@ -349,6 +349,34 @@ function _taskServerSelection(task) {
return { host, server, key };
}
function _serverColorForTaskGroup(key, tasks) {
const firstTask = Array.isArray(tasks) ? tasks[0] : null;
const host = firstTask?.remoteHost || firstTask?.payload?.remote_host || '';
const savedKey = firstTask?.remoteServerKey || firstTask?.payload?.remote_server_key || key || '';
const server = (savedKey ? _serverByVal?.(savedKey) : null)
|| (key ? _serverByVal?.(key) : null)
|| (host ? _serverByVal?.(host) : null)
|| (key === 'local' || !key ? (_envState?.servers || []).find(s => !s.host || String(s.host).toLowerCase() === 'local') : null)
|| null;
const color = String(server?.color || '').trim();
return /^#[0-9a-fA-F]{6}$/.test(color) ? color : '';
}
function _serverHeaderStyle(color) {
if (!color) return '';
const c = color.toLowerCase();
const accent = (c === '#ffffff' || c === '#f8fafc') ? '#cbd5e1'
: (c === '#111827' || c === '#000000') ? '#64748b'
: color;
return ` style="--cookbook-server-color:${esc(color)};--cookbook-server-accent:${esc(accent)};"`;
}
function _shouldAutoExpandTaskOutput(task) {
return task?.type === 'download'
&& !task?.payload?._dep
&& ['running', 'queued', 'error', 'crashed'].includes(String(task?.status || ''));
}
function _selectTaskServer(task) {
const { host, server, key } = _taskServerSelection(task);
_envState.remoteHost = host;
@@ -381,6 +409,7 @@ let _soloExpandTaskId = null;
const TASKS_KEY = 'cookbook-tasks';
const STORAGE_KEY = 'cookbook-presets';
const SERVE_STATE_KEY = 'cookbook-serve-state';
const SERVE_FAVORITES_KEY = 'cookbook-serve-favorite-models';
// Polling / timeout intervals
const TASK_POLL_INTERVAL_MS = 3000; // delay between reconnect-loop iterations
@@ -504,7 +533,7 @@ function _refreshModelsAfterEndpointChange() {
pickerLabel.innerHTML = '<span style="opacity:0.4;">refreshing…</span>';
}
if (window.modelsModule && window.modelsModule.refreshModels) {
window.modelsModule.refreshModels(true);
window.modelsModule.refreshModels(false);
}
setTimeout(() => {
if (!window.sessionModule) return;
@@ -560,6 +589,53 @@ function _endpointFromAdvertisedUrl(rawUrl, currentHost, fallbackPort = '11434')
}
}
function _serveExpectedModel(task) {
const fields = task?.payload?._fields || {};
return String(
fields.served_model_name ||
fields.model_path ||
task?.payload?.repo_id ||
task?.model ||
task?.name ||
''
).trim();
}
function _modelIdMatchesExpected(modelId, expected) {
const got = String(modelId || '').trim().toLowerCase();
const want = String(expected || '').trim().toLowerCase();
if (!got || !want) return true;
if (got === want) return true;
const gotBase = got.split('/').pop();
const wantBase = want.split('/').pop();
return gotBase === wantBase || got.includes(wantBase) || want.includes(gotBase);
}
function _endpointMatchesServe(ep, task) {
const expected = _serveExpectedModel(task);
const models = [...(ep?.models || []), ...(ep?.pinned_models || [])];
if (!models.length) return true;
return models.some(mid => _modelIdMatchesExpected(mid, expected));
}
function _markServeEndpointMismatch(task, ep, host, port) {
const expected = _serveExpectedModel(task);
const actual = (ep?.models || []).join(', ') || 'no models';
const msg = `Port ${host}:${port} answered, but it is serving ${actual}, not ${expected || task?.name || 'the launched model'}. The new serve likely failed or the port is occupied by an older server.`;
_updateTask(task.sessionId || task.session_id, {
status: 'error',
_serveReady: false,
_endpointAdded: false,
output: `${task.output || ''}\n\n${msg}`.trim(),
});
uiModule.showError(msg);
}
function _appendPinnedServeModel(fd, task) {
const expected = _serveExpectedModel(task);
if (expected) fd.append('pinned_models', expected);
}
// ── Download queue — runs one at a time per server ──
function _processQueue() {
@@ -906,13 +982,17 @@ function _taskRemoteHost(task) {
return task?.remoteHost || task?.payload?.remote_host || '';
}
function _remoteTmuxPrefix() {
return 'PATH="$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"; ';
}
export function _tmuxCmd(task, tmuxArgs) {
if (_isWindows(task)) {
return _winSessionCmd(task, tmuxArgs);
}
const host = _taskRemoteHost(task);
if (host) {
return `ssh ${_sshPrefix(_getPort(task))}${host} 'tmux ${tmuxArgs}' 2>/dev/null`;
return `ssh ${_sshPrefix(_getPort(task))}${host} '${_remoteTmuxPrefix()}tmux ${tmuxArgs}' 2>/dev/null`;
}
return `tmux ${tmuxArgs} 2>/dev/null`;
}
@@ -945,7 +1025,7 @@ function _winSessionCmd(task, tmuxArgs) {
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -ErrorAction SilentlyContinue }`;
return _winPowerShellCmd(task, ps);
}
return host ? `ssh ${pf}${host} 'tmux ${tmuxArgs}' 2>/dev/null` : `tmux ${tmuxArgs} 2>/dev/null`;
return host ? `ssh ${pf}${host} '${_remoteTmuxPrefix()}tmux ${tmuxArgs}' 2>/dev/null` : `tmux ${tmuxArgs} 2>/dev/null`;
}
function _winPowerShellCmd(task, ps) {
@@ -972,7 +1052,7 @@ export function _tmuxGracefulKill(task) {
}
const host = _taskRemoteHost(task);
if (host) {
return `ssh ${_sshPrefix(_getPort(task))}${host} 'tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null'`;
return `ssh ${_sshPrefix(_getPort(task))}${host} '${_remoteTmuxPrefix()}tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null'`;
}
return `tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null`;
}
@@ -999,7 +1079,7 @@ export function _tmuxForceKill(task) {
`tmux kill-session -t ${sid} 2>/dev/null`;
const host = _taskRemoteHost(task);
if (host) {
return `ssh ${_sshPrefix(_getPort(task))}${host} ${_shQuote(inner)}`;
return `ssh ${_sshPrefix(_getPort(task))}${host} ${_shQuote(_remoteTmuxPrefix() + inner)}`;
}
return inner;
}
@@ -1016,7 +1096,7 @@ export function _tmuxIsAliveCheck(task) {
const inner = `if tmux has-session -t ${sid} 2>/dev/null; then echo ALIVE; else echo DEAD; fi`;
const host = _taskRemoteHost(task);
if (host) {
return `ssh ${_sshPrefix(_getPort(task))}${host} ${_shQuote(inner)}`;
return `ssh ${_sshPrefix(_getPort(task))}${host} ${_shQuote(_remoteTmuxPrefix() + inner)}`;
}
return inner;
}
@@ -1240,8 +1320,13 @@ function _syncToServer() {
presets: _loadPresets(),
env: _envState,
serveState: null,
serveFavorites: [],
};
try { state.serveState = JSON.parse(localStorage.getItem(SERVE_STATE_KEY)); } catch {}
try {
const favorites = JSON.parse(localStorage.getItem(SERVE_FAVORITES_KEY) || '[]');
state.serveFavorites = Array.isArray(favorites) ? favorites.filter(Boolean).map(String) : [];
} catch {}
await fetch('/api/cookbook/state', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
@@ -1251,6 +1336,10 @@ function _syncToServer() {
}, 400);
}
document.addEventListener('cookbook:state-dirty', () => {
_syncToServer();
});
// Normalize state from server: collapse legacy duplicate keys to canonical form.
// - server.modelDir (singular) → server.modelDirs[0] (canonical)
// - strip ✕/✖ pollution from modelDirs
@@ -1332,6 +1421,9 @@ export async function _syncFromServer() {
if (state.serveState) {
localStorage.setItem(SERVE_STATE_KEY, JSON.stringify(state.serveState));
}
if (Array.isArray(state.serveFavorites)) {
localStorage.setItem(SERVE_FAVORITES_KEY, JSON.stringify(state.serveFavorites.filter(Boolean).map(String)));
}
document.dispatchEvent(new CustomEvent('cookbook:state-synced', { detail: state }));
return true;
} catch { return false; }
@@ -1526,6 +1618,11 @@ export async function _serveAutoRetryReplace(panel, flag, value) {
_animateOutThenRemove(taskEl, taskId);
let newCmd = task.payload._cmd;
if (flag === '--cuda-graph-backend-decode') {
newCmd = newCmd.replace(/\s+--cuda-graph-max-bs-decode(?:\s+\S+|=\S+)/g, '');
} else if (flag === '--cuda-graph-max-bs-decode') {
newCmd = newCmd.replace(/\s+--cuda-graph-backend-decode(?:\s+\S+|=\S+)/g, '');
}
const re = new RegExp(flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s+\\S+');
if (re.test(newCmd)) {
newCmd = newCmd.replace(re, `${flag} ${value}`);
@@ -1657,6 +1754,7 @@ function _parseServeCmdToFields(cmd) {
const ex = (re) => { const m = cmd.match(re); return m ? m[1] : ''; };
const fields = {
backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp'
: cmd.includes('mlx_lm.server') ? 'mlx'
: cmd.includes('diffusion_server') ? 'diffusers'
: cmd.includes('sglang') ? 'sglang'
: cmd.includes('ollama') ? 'ollama' : 'vllm',
@@ -1694,6 +1792,100 @@ function _parseServeCmdToFields(cmd) {
return fields;
}
function _serveCmdNeedsGpuPreflight(cmd, repo) {
const c = String(cmd || '').toLowerCase();
const r = String(repo || '').toLowerCase();
if (!c || /gpu-cleanup|sglang-kernel|mlx-lm|pip\s+install|python\d*\s+-m\s+pip/.test(`${r} ${c}`)) return false;
return /\b(vllm\s+serve|sglang(?:\.launch_server|\s+serve)|mlx_lm\.server|llama-server|llama_cpp\.server|text-generation-launcher|aphrodite|ollama\s+(?:serve|run))\b/.test(c);
}
function _selectedGpuIndexes(gpus) {
const raw = String(gpus || '').trim();
if (!raw) return null;
const out = new Set();
raw.split(',').forEach(part => {
const p = part.trim();
const range = p.match(/^(\d+)\s*-\s*(\d+)$/);
if (range) {
const a = parseInt(range[1], 10);
const b = parseInt(range[2], 10);
for (let i = Math.min(a, b); i <= Math.max(a, b); i++) out.add(i);
return;
}
const n = parseInt(p, 10);
if (Number.isFinite(n)) out.add(n);
});
return out.size ? out : null;
}
function _gbFromMb(mb) {
const n = Number(mb || 0);
if (!Number.isFinite(n) || n <= 0) return '';
return n >= 1024 ? `${(n / 1024).toFixed(n >= 10240 ? 0 : 1)}G` : `${Math.round(n)}M`;
}
function _gpuPreflightIssues(data, selected) {
const backend = String(data?.backend || data?.source || '').toLowerCase();
const isCuda = backend.includes('cuda') || String(data?.source || '').toLowerCase().includes('nvidia');
const rows = Array.isArray(data?.gpus) ? data.gpus : [];
const issues = [];
rows.forEach(g => {
const idx = Number(g?.index);
if (selected && !selected.has(idx)) return;
const procs = Array.isArray(g?.processes) ? g.processes : [];
if (procs.length) {
procs.slice(0, 3).forEach(p => {
const name = String(p?.name || 'process').split(/[\\/]/).pop();
const used = _gbFromMb(p?.used_mb);
issues.push(`GPU ${idx}: ${name}${p?.pid ? ` #${p.pid}` : ''}${used ? ` (${used})` : ''}`);
});
if (procs.length > 3) issues.push(`GPU ${idx}: +${procs.length - 3} more process${procs.length - 3 === 1 ? '' : 'es'}`);
return;
}
const total = Number(g?.total_mb || 0);
const free = Number(g?.free_mb || 0);
const used = Number(g?.used_mb || 0);
const freeRatio = total > 0 ? free / total : 1;
// CUDA can have display/runtime crumbs; warn only for meaningful occupied memory.
if (isCuda && used > 4096 && freeRatio < 0.9) {
issues.push(`GPU ${idx}: ${_gbFromMb(used)} already used (${_gbFromMb(free)} free)`);
} else if (!isCuda && total > 0 && freeRatio < 0.2) {
issues.push(`${g?.name || `GPU ${idx}`}: low free memory (${_gbFromMb(free)} free of ${_gbFromMb(total)})`);
} else if (!isCuda && g?.busy && total <= 0) {
issues.push(`${g?.name || `GPU ${idx}`}: GPU device is busy`);
}
});
return issues;
}
async function _confirmGpuPreflight(reqBody, shortName, repo, cmd) {
if (!_serveCmdNeedsGpuPreflight(cmd, repo)) return true;
const params = new URLSearchParams();
if (reqBody.remote_host) params.set('host', reqBody.remote_host);
if (reqBody.ssh_port) params.set('ssh_port', reqBody.ssh_port);
try {
const res = await fetch(`/api/cookbook/gpus${params.toString() ? `?${params.toString()}` : ''}`, {
method: 'GET',
credentials: 'same-origin',
});
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) return true;
const selected = _selectedGpuIndexes(reqBody.gpus);
const issues = _gpuPreflightIssues(data, selected);
if (!issues.length) return true;
const where = reqBody.remote_host || 'local';
const list = issues.slice(0, 6).join('; ');
const more = issues.length > 6 ? `; +${issues.length - 6} more` : '';
const msg = `GPU preflight found existing load on ${where}: ${list}${more}. Launch ${shortName || 'model'} anyway?`;
const confirm = window.styledConfirm || uiModule?.styledConfirm;
if (confirm) return await confirm(msg, { confirmText: 'Launch anyway', cancelText: 'Cancel' });
return window.confirm ? window.confirm(msg) : true;
} catch (e) {
console.warn('[cookbook] GPU preflight failed; allowing launch', e);
return true;
}
}
export async function _launchServeTask(shortName, repo, cmd, fields, hostOverride, targetMeta = null) {
// Host resolution mirrors the download path: when the caller passes an explicit
// host (resolved from the dropdown the user actually picked), use it and look
@@ -1777,11 +1969,16 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
ssh_port: _getPort(_serverMetaKey || _host) || undefined,
env_prefix: envPrefix || undefined,
hf_token: _envState.hfToken || undefined,
gpus: _envState.gpus || undefined,
gpus: _usedGpus || undefined,
platform: _hplatform || undefined,
};
try {
const _preflightOk = await _confirmGpuPreflight(reqBody, shortName, repo, cmd);
if (!_preflightOk) {
uiModule.showToast('Launch cancelled — GPU is already in use');
return;
}
const res = await fetch('/api/model/serve', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
@@ -2005,7 +2202,8 @@ export function _renderRunningTab() {
// green when reachable, red if any serve task on it is crashed/unreachable.
const _secDot = (key && allTasks.some(_serveTaskFailed)) ? 'fail' : 'ok';
const _dotTitle = key ? (_secDot === 'fail' ? 'Server not responding' : 'Reachable') : 'Local (this machine)';
sec.insertAdjacentHTML('afterbegin', `<div class="cookbook-section-header" data-collapse="${bodyId}"><svg class="cookbook-section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg><span class="cookbook-srv-status ${_secDot}" title="${_dotTitle}" style="flex-shrink:0;position:relative;top:0px;"></span><span class="cookbook-section-title" style="margin:0;">${esc(sg.name)}</span><button class="cookbook-btn cookbook-stop-all-btn" data-stop-server="${esc(key)}" title="Stop all running servers"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg>Stop all</button><button class="cookbook-btn cookbook-clear-btn" data-clear-server="${esc(key)}" title="Clear finished tasks"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Clear finished</button></div><div id="${bodyId}" class="cookbook-section-body"></div>`);
const _srvColor = _serverColorForTaskGroup(key || 'local', allTasks);
sec.insertAdjacentHTML('afterbegin', `<div class="cookbook-section-header${_srvColor ? ' has-server-color' : ''}" data-collapse="${bodyId}"${_serverHeaderStyle(_srvColor)}><svg class="cookbook-section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg><span class="cookbook-srv-status ${_secDot}" title="${_dotTitle}" style="flex-shrink:0;position:relative;top:0px;"></span><span class="cookbook-section-title" style="margin:0;">${esc(sg.name)}</span><button class="cookbook-btn cookbook-stop-all-btn" data-stop-server="${esc(key)}" title="Stop all running servers"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="none" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><rect x="5" y="5" width="14" height="14" rx="1.5"/></svg>Stop all</button><button class="cookbook-btn cookbook-clear-btn" data-clear-server="${esc(key)}" title="Clear finished tasks"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="vertical-align:-1px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>Clear finished</button></div><div id="${bodyId}" class="cookbook-section-body"></div>`);
}
}
@@ -2186,7 +2384,7 @@ export function _renderRunningTab() {
<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>${(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>
<div class="cookbook-output-wrap cookbook-task-collapsible${(_mobileCollapseDefault && !_shouldAutoExpandTaskOutput(task)) ? ' 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');
@@ -2321,7 +2519,23 @@ export function _renderRunningTab() {
el.querySelector('.cookbook-task-header').addEventListener('click', (e) => {
if (e.target.closest('button')) return;
const wrap = el.querySelector('.cookbook-output-wrap');
if (wrap) wrap.classList.toggle('cookbook-task-collapsed');
if (!wrap) return;
const isOpening = wrap.classList.contains('cookbook-task-collapsed');
wrap.classList.toggle('cookbook-task-collapsed');
if (isOpening) {
_expandedTaskIds.add(task.sessionId);
_collapsedTaskIds.delete(task.sessionId);
if (task.sessionId && ['serve', 'download'].includes(task.type || '')) {
_reconnectTask(el, task);
}
} else {
_collapsedTaskIds.add(task.sessionId);
_expandedTaskIds.delete(task.sessionId);
if (el._abort) {
try { el._abort.abort(); } catch {}
el._abort = null;
}
}
});
// Wire menu button (also fire from a long-press anywhere on the card so
@@ -2757,7 +2971,9 @@ export function _renderRunningTab() {
// responds; without this, the user opens the Running tab and sees
// only the placeholder ("Launched by scheduled task …") because
// _reconnectTask never fires for status 'ready'/'loading'/'warming'.
if (_isRunningTabVisible() && ['running', 'ready', 'loading', 'warming', 'starting'].includes(task.status)) {
const _wrapForStream = el.querySelector('.cookbook-output-wrap');
const _streamExpanded = _wrapForStream && !_wrapForStream.classList.contains('cookbook-task-collapsed');
if (_isRunningTabVisible() && _streamExpanded && task.sessionId && ['serve', 'download'].includes(task.type || '')) {
_reconnectTask(el, task);
}
}
@@ -2789,13 +3005,19 @@ export function _renderRunningTab() {
// ── Reconnect task (polling loop) ──
async function _reconnectTask(el, task) {
if (!el || !task) return;
const wrap = el.querySelector('.cookbook-output-wrap');
if (!_isRunningTabVisible() || !wrap || wrap.classList.contains('cookbook-task-collapsed')) return;
if (el._abort && !el._abort.signal?.aborted) return;
const output = el.querySelector('.cookbook-output-pre');
if (!output) return;
const controller = new AbortController();
el._abort = controller;
let failCount = 0;
while (!controller.signal.aborted) {
if (!el.isConnected) {
const liveWrap = el.querySelector('.cookbook-output-wrap');
if (!el.isConnected || !_isRunningTabVisible() || !liveWrap || liveWrap.classList.contains('cookbook-task-collapsed')) {
controller.abort();
break;
}
@@ -3383,13 +3605,17 @@ async function _reconnectTask(el, task) {
// endpoints server-side. Mark so we don't retry, but STILL
// refresh the picker (and probe until online) so the new model
// shows up without the user having to manually refresh.
const _ex = eps.find(e => e.base_url === baseUrl);
if (_ex && !_endpointMatchesServe(_ex, task)) {
_markServeEndpointMismatch(task, _ex, host, port);
return null;
}
task._endpointAdded = true;
_updateTask(task.sessionId, { _endpointAdded: true });
_autoSaveWorkingConfig(task); // endpoint live → remember these settings
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(true);
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(false);
if (window.sessionModule?.updateModelPicker) window.sessionModule.updateModelPicker();
window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', { detail: { baseUrl, host, port, model: task.name } }));
const _ex = eps.find(e => e.base_url === baseUrl);
if (_ex && _ex.id && !(_ex.models || []).length) _probeEndpointUntilOnline(_ex.id, host, port);
return null;
}
@@ -3399,6 +3625,7 @@ async function _reconnectTask(el, task) {
fd.append('name', task.name);
fd.append('skip_probe', 'true');
_appendCookbookEndpointScope(fd, task.remoteHost || '');
_appendPinnedServeModel(fd, task);
if (_isDiffusion) fd.append('model_type', 'image');
return fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
})
@@ -3417,7 +3644,7 @@ async function _reconnectTask(el, task) {
}
window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', { detail: { baseUrl, host, port, model: task.name } }));
const _trySelectModel = async (attempt) => {
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(true);
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(false);
const items = window.modelsModule?.getCachedItems?.() || [];
for (const item of items) {
if (item.offline) continue;
@@ -3504,6 +3731,16 @@ function _isRunningTabVisible() {
return activeTab === 'Running';
}
function _isCookbookVisible() {
try {
if (window.cookbookModule && typeof window.cookbookModule.isVisible === 'function') {
return !!window.cookbookModule.isVisible();
}
} catch (_) {}
const modal = document.getElementById('cookbook-modal');
return !!modal && !modal.classList.contains('hidden');
}
function _foregroundChatBusy() {
try {
return !!window.__odysseusChatBusy || Date.now() < (window.__odysseusChatBusyUntil || 0);
@@ -3805,6 +4042,7 @@ function _stopBackgroundMonitor() {
// the endpoint reports models, then refreshes the picker. Bounded so a
// genuinely-dead server doesn't poll forever.
async function _probeEndpointUntilOnline(epId, host, port) {
if (!_isCookbookVisible() || _foregroundChatBusy()) return;
if (!epId) return;
// Big models (e.g. 70B+) can take several minutes to load weights before
// the server answers /v1/models. Probe for up to ~5 min, easing the
@@ -3813,6 +4051,7 @@ async function _probeEndpointUntilOnline(epId, host, port) {
for (let i = 0; i < MAX_TRIES; i++) {
const interval = i < 12 ? 5000 : 10000; // 5s for the first minute, then 10s
await new Promise(r => setTimeout(r, interval));
if (!_isCookbookVisible() || _foregroundChatBusy()) return;
try {
// Hit the probe endpoint — it re-probes server-side and updates
// cached_models. We consume (and discard) the SSE stream.
@@ -3822,7 +4061,7 @@ async function _probeEndpointUntilOnline(epId, host, port) {
const eps = await fetch('/api/model-endpoints', { credentials: 'same-origin' }).then(r => r.json()).catch(() => []);
const ep = (eps || []).find(e => e.id === epId);
if (ep && (ep.models || []).length) {
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(true);
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(false);
if (window.sessionModule?.updateModelPicker) window.sessionModule.updateModelPicker();
window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', {
detail: { baseUrl: ep.base_url || `http://${host}:${port}/v1`, host, port, model: (ep.models || [])[0] || '' },
@@ -3916,7 +4155,8 @@ async function _pollBackgroundStatus() {
// "stopped" by the backend (its pip package is never in the HF cache the
// dead-session check inspects). Recover "done" from the retained output's
// exit-0 sentinel so a clean install isn't downgraded to crashed.
const depDone = !!task.payload?._dep && _depInstallSucceeded(task.output);
const combinedOutput = `${task.output || ''}\n${live.output_tail || ''}`;
const depDone = !!task.payload?._dep && _depInstallSucceeded(combinedOutput);
// A finished model download whose tmux pane is gone is also reported
// "stopped" (the dead-session check can miss the landed snapshot).
// Recover "done" from the terminal `DOWNLOAD_OK` sentinel — emitted
@@ -3926,19 +4166,29 @@ async function _pollBackgroundStatus() {
// off the conclusive exit sentinel only, never the `/snapshots/` path,
// which can be printed mid-stream for multi-file downloads.
const downloadDone = task.type === 'download'
&& String(task.output || '').includes('DOWNLOAD_OK');
const nextStatus = live.status === 'completed'
&& String(combinedOutput || '').includes('DOWNLOAD_OK');
const serveReady = task.type === 'serve'
&& (live.status === 'ready' || _serveOutputLooksReady({ ...task, output: live.output_tail || task.output || '' }));
const completedByOutput = depDone || downloadDone;
const nextStatus = completedByOutput
? 'done'
: (serveReady
? 'ready'
: (live.status === 'completed'
? 'done'
: (live.status === 'error'
? 'error'
: (live.status === 'stopped'
? ((depDone || downloadDone) ? 'done' : (task.type === 'download' ? 'crashed' : 'stopped'))
: null));
: 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) {
if (serveReady && !task._serveReady) {
updates._serveReady = true;
}
if ((live.status === 'running' || live.status === 'ready') && task.status !== live.status && !serveReady && !completedByOutput) {
updates.status = live.status === 'ready' ? 'ready' : 'running';
}
if (live.progress && live.progress !== task.progress) updates.progress = live.progress;
@@ -4018,6 +4268,11 @@ async function _pollBackgroundStatus() {
const hostPort = `${host}:${port}`;
const existing = eps.find(e => e.base_url === baseUrl || e.base_url.includes(hostPort) || e.name === t.model);
if (existing) {
const taskForMatch = localTask || { sessionId: t.session_id, name: t.model, model: t.model, payload: { repo_id: t.model, _cmd } };
if (!_endpointMatchesServe(existing, taskForMatch)) {
_markServeEndpointMismatch(taskForMatch, existing, host, port);
return null;
}
// Already registered — but it may be showing offline because
// it was added while the server was still warming. Kick a
// re-probe so it flips online without manual toggle.
@@ -4029,6 +4284,7 @@ async function _pollBackgroundStatus() {
fd.append('name', t.model);
fd.append('skip_probe', 'true');
_appendCookbookEndpointScope(fd, localTask?.remoteHost || t.remote || '');
_appendPinnedServeModel(fd, localTask || { name: t.model, model: t.model, payload: { repo_id: t.model, _cmd } });
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 });
@@ -4041,7 +4297,7 @@ async function _pollBackgroundStatus() {
// probe, so it lands "offline". Retry-probe in the background
// until /v1/models responds — no manual enable/disable needed.
if (data && data.id) _probeEndpointUntilOnline(data.id, host, port);
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(true);
if (window.modelsModule?.refreshModels) await window.modelsModule.refreshModels(false);
if (window.sessionModule?.updateModelPicker) window.sessionModule.updateModelPicker();
}
})
+112 -29
View File
@@ -49,11 +49,25 @@ let _cachedAllModels = [];
const _CACHED_MODELS_SCAN_KEY = 'cookbook_cached_models_scan_v1';
const _CACHED_MODELS_SCAN_TTL = 6 * 3600 * 1000;
function _normalizeCookbookModelDir(dir) {
const d = String(dir || '').replaceAll('✕', '').replaceAll('✖', '').trim();
return /^(home|mnt|media|data|opt|srv|var)\//.test(d) ? `/${d}` : d;
}
function _readCachedModelScan(sig) {
try {
const all = JSON.parse(localStorage.getItem(_CACHED_MODELS_SCAN_KEY) || '{}');
const entry = all[sig];
if (entry && Date.now() - (entry.ts || 0) < _CACHED_MODELS_SCAN_TTL) return entry.data || null;
if (entry && Date.now() - (entry.ts || 0) < _CACHED_MODELS_SCAN_TTL) {
const data = entry.data || null;
const models = Array.isArray(data?.models) ? data.models : [];
const staleDownloading = models.some(m =>
(m?.status === 'downloading' || m?.has_incomplete) && !_isActivelyDownloading(m?.repo_id)
);
if (!staleDownloading) return data;
delete all[sig];
localStorage.setItem(_CACHED_MODELS_SCAN_KEY, JSON.stringify(all));
}
} catch {}
return null;
}
@@ -83,6 +97,7 @@ function _loadServeFavorites() {
function _saveServeFavorites(favorites) {
try {
localStorage.setItem(SERVE_FAVORITES_KEY, JSON.stringify(Array.from(favorites || [])));
document.dispatchEvent(new CustomEvent('cookbook:state-dirty', { detail: { key: SERVE_FAVORITES_KEY } }));
} catch {}
}
@@ -218,7 +233,21 @@ function _shellSplitForPreview(cmd) {
}
function _formatServeCmdPreview(cmd) {
const raw = String(cmd || '');
let raw = String(cmd || '');
const mlxDeepSeekV4Compat = /\bmlx_lm\.server\b/i.test(raw)
&& /--model\s+['"]?mlx-community\/[^'"\s]*deepseek-v4/i.test(raw);
if (mlxDeepSeekV4Compat) {
const modelMatch = raw.match(/--model\s+(['"]?)(mlx-community\/[^'"\s]*deepseek-v4[^'"\s]*)\1/i);
const homeMatch = raw.match(/((?:\/Users|\/home)\/[^/\s'"]+)/);
const shortName = modelMatch?.[2]?.split('/').pop();
if (homeMatch && shortName) {
const shimPath = `${homeMatch[1]}/.cache/odysseus/mlx-shims/${shortName}`;
raw = raw.replace(
/--model\s+(['"]?)mlx-community\/[^'"\s]*deepseek-v4[^'"\s]*\1/i,
`--model '${shimPath}'`
);
}
}
if (raw.startsWith('MODEL_FILE=$({')) {
const marker = /&&\s+([A-Za-z_][A-Za-z0-9_]*=\S+\s+)*(?:[A-Za-z_][A-Za-z0-9_]*=\S+\s+)?(?:llama-server|python3?\s+-m\s+llama_cpp\.server)\b/;
const match = raw.match(marker);
@@ -233,7 +262,7 @@ function _formatServeCmdPreview(cmd) {
const lines = [];
let i = 0;
while (i < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i])) {
lines.push(tokens[i]);
lines.push(`export ${tokens[i]}`);
i++;
}
if (tokens[i]) {
@@ -254,11 +283,43 @@ function _formatServeCmdPreview(cmd) {
lines.push(t);
}
}
return lines.join('\n');
const envCount = lines.findIndex(line => !line.startsWith('export '));
const firstCmdLine = envCount < 0 ? lines.length : envCount;
const formatted = lines.map((line, idx) => {
const isCommandPart = idx >= firstCmdLine;
const hasNextCommandPart = lines.slice(idx + 1).some(next => !next.startsWith('export '));
return isCommandPart && hasNextCommandPart ? `${line} \\` : line;
}).join('\n');
if (mlxDeepSeekV4Compat) {
return [
'# Odysseus runtime compatibility: using sanitized MLX DeepSeek-V4 shim.',
formatted,
].join('\n');
}
return formatted;
}
function _normalizeServeCmdForLaunch(cmd) {
return String(cmd || '')
let raw = String(cmd || '');
const lines = raw.split(/\r?\n/)
.map(s => s.trim().replace(/\s*\\$/, '').trim())
.filter(s => s && !s.startsWith('#'));
if (lines.some(line => /^(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*=/.test(line))) {
const env = [];
const body = [];
for (const line of lines) {
const m = line.match(/^export\s+([A-Za-z_][A-Za-z0-9_]*=.*)$/);
if (m) {
env.push(m[1]);
} else if (/^[A-Za-z_][A-Za-z0-9_]*=\S+$/.test(line)) {
env.push(line);
} else {
body.push(line);
}
}
raw = [...env, ...body].join(' ');
}
return raw
.replace(/MODEL_FILE=\$\(\{\s+/g, 'MODEL_FILE=$({ ')
.replace(/\s+\}\s+\|\s+head\s+-1\)/g, ' } | head -1)')
.replace(/\s*;\s*/g, '; ')
@@ -567,7 +628,13 @@ function _selectedServeTarget(panel) {
host = server?.host || '';
}
}
const venv = panel?.querySelector('[data-field="venv"]')?.value?.trim() || server?.envPath || _envState.envPath || '';
const typedVenv = panel?.querySelector('[data-field="venv"]')?.value?.trim() || '';
// For remote targets the server profile is authoritative. Otherwise a stale
// venv typed/loaded for another host can leak into this launch, e.g. a Linux
// /home/... Python path being used on an Apple Silicon MLX server.
const venv = host
? (server?.envPath || typedVenv || '')
: (typedVenv || server?.envPath || _envState.envPath || '');
const label = host
? (server?.name ? `${server.name} (${host})` : host)
: (server?.name || 'local server');
@@ -593,8 +660,8 @@ function _backendChoicesForTarget(target) {
return [['llamacpp','llama.cpp'],['diffusers','Diffusers']];
}
return _isMetal()
? [['llamacpp','llama.cpp'],['ollama','Ollama']]
: [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['diffusers','Diffusers']];
? [['mlx','MLX'],['llamacpp','llama.cpp'],['ollama','Ollama']]
: [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['mlx','MLX'],['diffusers','Diffusers']];
}
async function _fetchServeRuntimePackage(panel, backend) {
@@ -602,6 +669,7 @@ async function _fetchServeRuntimePackage(panel, backend) {
vllm: 'vllm',
sglang: 'sglang',
llamacpp: 'llama_cpp',
mlx: 'mlx_lm',
diffusers: 'diffusers',
};
const packageName = packageByBackend[backend];
@@ -621,7 +689,7 @@ async function _fetchServeRuntimePackage(panel, backend) {
}
function _runtimeNoteText(backend, pkg, target) {
const labels = { vllm: 'vLLM', sglang: 'SGLang', llamacpp: 'llama.cpp', diffusers: 'Diffusers' };
const labels = { vllm: 'vLLM', sglang: 'SGLang', llamacpp: 'llama.cpp', mlx: 'MLX', diffusers: 'Diffusers' };
const label = labels[backend] || backend;
if (!pkg) return `${label} readiness unavailable for ${target.label}.`;
const note = pkg.status_note || pkg.update_note || '';
@@ -1109,7 +1177,14 @@ function _rerenderCachedModels() {
const repo = item.dataset.repo;
if (!repo) return;
const m = allModels.find(x => x.repo_id === repo);
if (!m || m.status !== 'ready') return;
if (!m) return;
if (m.status !== 'ready') {
if (m.status === 'downloading' && !_isActivelyDownloading(m.repo_id)) {
uiModule.showToast?.('Refreshing cached model status…');
_fetchCachedModels(true);
}
return;
}
// Toggle — close if already open
if (item.classList.contains('doclib-card-expanded')) {
@@ -1266,7 +1341,7 @@ function _rerenderCachedModels() {
// stays as the source-of-truth so every existing change handler
// (updateBackendVisibility, runtime readiness, command builder)
// still fires via dispatchEvent('change') on selection.
panelHtml += `<label>${_l('Engine','Inference engine: vLLM, SGLang, llama.cpp, Ollama, or Diffusers')}<div class="hwfit-backend-picker" data-backend-picker style="position:relative;width:100%;"><select class="hwfit-sf hwfit-backend-source" data-field="backend" style="display:none;">${backendOpts}</select><button type="button" class="hwfit-backend-btn" data-backend-btn aria-haspopup="listbox" aria-expanded="false" style="display:flex;align-items:center;gap:6px;width:100%;height:32px;padding:0 8px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px;font:inherit;font-size:11px;cursor:pointer;text-align:left;position:relative;top:-4px;"><span class="hwfit-backend-btn-icon" data-backend-icon-slot aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;color:var(--accent, var(--red));flex-shrink:0;"></span><span class="hwfit-backend-btn-label" data-backend-label style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.6;flex-shrink:0;"><polyline points="6 9 12 15 18 9"/></svg></button><div class="hwfit-backend-menu" data-backend-menu role="listbox" hidden style="position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:100;background:var(--panel, var(--bg));border:1px solid var(--border);border-radius:6px;box-shadow:0 6px 20px rgba(0,0,0,0.22);padding:4px;"></div></div></label>`;
panelHtml += `<label>${_l('Engine','Inference engine: MLX, vLLM, SGLang, llama.cpp, Ollama, or Diffusers')}<div class="hwfit-backend-picker" data-backend-picker style="position:relative;width:100%;"><select class="hwfit-sf hwfit-backend-source" data-field="backend" style="display:none;">${backendOpts}</select><button type="button" class="hwfit-backend-btn" data-backend-btn aria-haspopup="listbox" aria-expanded="false" style="display:flex;align-items:center;gap:6px;width:100%;height:32px;padding:0 8px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px;font:inherit;font-size:11px;cursor:pointer;text-align:left;position:relative;top:-4px;"><span class="hwfit-backend-btn-icon" data-backend-icon-slot aria-hidden="true" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;color:var(--accent, var(--red));flex-shrink:0;"></span><span class="hwfit-backend-btn-label" data-backend-label style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.6;flex-shrink:0;"><polyline points="6 9 12 15 18 9"/></svg></button><div class="hwfit-backend-menu" data-backend-menu role="listbox" hidden style="position:absolute;top:calc(100% + 4px);left:0;right:0;z-index:100;background:var(--panel, var(--bg));border:1px solid var(--border);border-radius:6px;box-shadow:0 6px 20px rgba(0,0,0,0.22);padding:4px;"></div></div></label>`;
panelHtml += `<input type="hidden" class="hwfit-sf" data-field="host" value="${esc(_es.remoteHost || '')}" />`;
// Inference mode pill (llama.cpp only) — lives directly to the
// RIGHT of Backend in Row 1 so the engine and the GPU/CPU choice
@@ -1328,13 +1403,13 @@ function _rerenderCachedModels() {
// TP / Context / GPU / GPU Mem / Max Seqs / Dtype. Everything else
// (Swap, KV Cache, Attention backend, Env vars, llama.cpp batch/ubatch)
// moved to the Advanced fold below to keep this row scannable.
panelHtml += `<div class="hwfit-serve-row hwfit-serve-row-core hwfit-backend-vllm hwfit-backend-sglang hwfit-backend-llamacpp hwfit-backend-ollama">`;
panelHtml += `<div class="hwfit-serve-row hwfit-serve-row-core hwfit-backend-vllm hwfit-backend-sglang hwfit-backend-llamacpp hwfit-backend-ollama hwfit-backend-mlx">`;
// Order: Dtype → TP → Context → Max Seqs → GPUs → GPU Mem.
// Dtype moved down from Row 1 to make space for the Inference pill
// (llama.cpp GPU/CPU toggle, llamacpp-only). GPUs lives next to
// GPU Mem so "which devices + how much" sit adjacent. Max Seqs
// follows Context per the "request-shape" cluster.
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 hwfit-backend-sglang hwfit-backend-llamacpp">${_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 hwfit-backend-sglang">${_l('TP','Tensor Parallelism — split model across N GPUs')}<select class="hwfit-sf" data-field="tp">${tpOpts}</select></label>`;
// ctx resets to the model's max on every panel open (the real ctx slider
// lives in the Scan/Download toolbar — see cookbook.js .hwfit-ctx-control).
@@ -1405,7 +1480,9 @@ function _rerenderCachedModels() {
panelHtml += `<label>Width${_h('Default output width')} <input type="text" class="hwfit-sf" data-field="diff_width" value="${esc(sv('diff_width', ''))}" placeholder="1024" /></label>`;
panelHtml += `<label>Height${_h('Default output height')} <input type="text" class="hwfit-sf" data-field="diff_height" value="${esc(sv('diff_height', ''))}" placeholder="1024" /></label>`;
panelHtml += `</div>`;
// Row 3: Checkboxes (vLLM)
// Row 3: Advanced toggles for vLLM/SGLang. Several concepts overlap,
// but the actual flags differ; keep labels backend-neutral where a
// shared checkbox maps to different runtime flags.
// Order: Trust Remote → Auto Tool → Reasoning Parser (when the
// model has one) → Enforce Eager → Prefix Caching. Reasoning
// Parser was previously in a separate row below; the user wanted
@@ -1416,21 +1493,22 @@ function _rerenderCachedModels() {
const _rp_flag = _opts2_row3.flags.find(f => f.includes('--reasoning-parser'));
const _rp_name = _rp_flag ? _rp_flag.split(' ')[1] : '';
panelHtml += `<div class="hwfit-serve-checks hwfit-backend-vllm hwfit-backend-sglang">`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="trust_remote"${sv('trust_remote',_isMiniMaxMSeries)?' checked':''} /> Trust Remote Code${_h('Allow model to run custom code from HuggingFace')}</label>`;
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm hwfit-backend-sglang"><input type="checkbox" class="hwfit-sf" data-field="auto_tool"${sv('auto_tool',_nativeToolDefault)?' checked':''} /> Auto Tool Choice${_h('Enable function/tool calling for agent mode')}</label>`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="trust_remote"${sv('trust_remote',_isMiniMaxMSeries)?' checked':''} /> Trust Remote Code${_h('SGLang/vLLM: allow model code from HuggingFace via --trust-remote-code')}</label>`;
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm hwfit-backend-sglang"><input type="checkbox" class="hwfit-sf" data-field="auto_tool"${sv('auto_tool',_nativeToolDefault)?' checked':''} /> Auto Tool Choice${_h('SGLang/vLLM: enable native tool calling and auto-pick the detected tool-call parser')}</label>`;
// Always-render the Reasoning Parser, Expert Parallel, and MoE Env
// checkboxes — the model-family detection above is a hint, not a
// hard gate. User asked to keep these visible regardless so that
// a borderline-undetected MoE/reasoning model can still toggle
// them without dropping back to the raw command box.
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm hwfit-backend-sglang"><input type="checkbox" class="hwfit-sf" data-field="reasoning_parser" data-parser="${_rp_name || ''}"${sv('reasoning_parser',_reasoningDefault)?' checked':''} /> Reasoning Parser${_rp_name ? ` <span class="hwfit-parser-tag">${_rp_name}</span>` : ''}${_h('Splits <think> tokens into a separate channel. The tag (when shown) is the auto-detected parser; edit the command if you need a different one.')}</label>`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="enforce_eager"${sv('enforce_eager',false)?' checked':''} /> Enforce Eager${_h('Disable CUDA graphs. Slower but uses less memory')}</label>`;
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 hwfit-backend-sglang"><input type="checkbox" class="hwfit-sf" data-field="reasoning_parser" data-parser="${_rp_name || ''}"${sv('reasoning_parser',_reasoningDefault)?' checked':''} /> Reasoning Parser${_rp_name ? ` <span class="hwfit-parser-tag">${_rp_name}</span>` : ''}${_h('SGLang/vLLM: splits thinking tokens into a reasoning channel using the detected parser.')}</label>`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="enforce_eager"${sv('enforce_eager',false)?' checked':''} /> Disable CUDA Graphs${_h('vLLM: --enforce-eager. SGLang: --disable-cuda-graph. Slower, but useful for graph-capture crashes.')}</label>`;
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="prefix_cache"${sv('prefix_cache',false)?' checked':''} /> Prefix / Radix Cache${_h('vLLM: prefix caching. SGLang: RadixAttention prefix cache; when off Odysseus adds --disable-radix-cache.')}</label>`;
// Inline the previously-second vLLM checks row so Expert Parallel /
// Speculative / MoE Env sit next to Prefix Caching with no gap. All
// three are vLLM-only — class-gated so they hide on SGLang. Always
// render so the user can flip them on for any MoE model.
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm hwfit-backend-sglang"><input type="checkbox" class="hwfit-sf" data-field="expert_parallel"${sv('expert_parallel',_expertParallelDefault)?' checked':''} /> Expert Parallel${_h('MoE: shard expert layers across GPUs. Helps for MiniMax M-series, StepFun Step-3, Qwen3 A3B/A10B/A22B MoE, DeepSeek V3+/R1. Ignored / wasteful on dense models.')}</label>`;
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm hwfit-backend-sglang"><input type="checkbox" class="hwfit-sf" data-field="expert_parallel"${sv('expert_parallel',_expertParallelDefault)?' checked':''} /> Expert Parallel${_h('SGLang/vLLM MoE: shard expert layers across GPUs. Useful for DeepSeek/MiniMax/Qwen MoE; avoid on dense models.')}</label>`;
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-sglang">Decode Graph${_h('SGLang only: tune decode CUDA graph capture. Smaller batch can fix DeepSeek-V4 graph-capture errors; disabled is safest but slower.')} <select class="hwfit-sf" data-field="sglang_decode_graph" style="height:24px;max-width:92px;"><option value=""${sv('sglang_decode_graph','') === '' ? ' selected' : ''}>auto</option><option value="bs16"${sv('sglang_decode_graph','') === 'bs16' ? ' selected' : ''}>bs 16</option><option value="disabled"${sv('sglang_decode_graph','') === 'disabled' ? ' selected' : ''}>off</option></select></label>`;
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm"><input type="checkbox" class="hwfit-sf" data-field="language_model_only"${sv('language_model_only',_isMiniMaxM3)?' checked':''} /> Language Model Only${_h('vLLM --language-model-only. Needed by MiniMax M3 text serving when the repo also contains VL components.')}</label>`;
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm"><input type="checkbox" class="hwfit-sf" data-field="disable_custom_all_reduce"${sv('disable_custom_all_reduce',_isMiniMaxM3)?' checked':''} /> Disable Custom All Reduce${_h('vLLM --disable-custom-all-reduce. Useful for some 8-GPU/nightly configurations.')}</label>`;
{
@@ -1585,6 +1663,7 @@ function _rerenderCachedModels() {
const buildTarget = _selectedServeTarget(panel);
f.host = buildTarget.host || '';
f.platform = buildTarget.platform || '';
f.venv = buildTarget.venv || '';
const hostField = panel.querySelector('[data-field="host"]');
if (hostField) hostField.value = f.host;
const backend = f.backend || 'vllm';
@@ -1871,6 +1950,7 @@ function _rerenderCachedModels() {
const _BACKEND_GLYPHS = {
vllm: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
sglang: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
mlx: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"/><path d="M16 6v12"/><path d="M20 6v12"/></svg>',
llamacpp: '<svg width="14" height="14" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"/><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"/></svg>',
ollama: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;"></span>',
diffusers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
@@ -1984,7 +2064,7 @@ function _rerenderCachedModels() {
const backend = panel.querySelector('[data-field="backend"]')?.value || 'vllm';
const noteText = note.querySelector('.hwfit-serve-runtime-text');
const _writeNote = (s) => { if (noteText) noteText.textContent = s; else note.textContent = s; };
if (!['vllm', 'sglang', 'llamacpp', 'diffusers'].includes(backend)) {
if (!['vllm', 'sglang', 'llamacpp', 'mlx', 'diffusers'].includes(backend)) {
note.style.display = 'none';
_writeNote('');
return;
@@ -2024,7 +2104,7 @@ function _rerenderCachedModels() {
// recipe panel for this backend so the user has one click
// to the fix instead of hunting for the right row.
if (noteText) {
const pkgName = pkg?.name || ({ vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', diffusers: 'diffusers' }[backend]);
const pkgName = pkg?.name || ({ vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', mlx: 'mlx_lm', diffusers: 'diffusers' }[backend]);
const repo = (panel.closest('.doclib-card, .memory-item')?.dataset?.repo) || '';
const link = document.createElement('a');
link.href = '#';
@@ -2079,7 +2159,7 @@ function _rerenderCachedModels() {
});
} else {
const fields = {
backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp' : cmd.includes('diffusion_server') ? 'diffusers' : cmd.includes('sglang') ? 'sglang' : cmd.includes('ollama') ? 'ollama' : 'vllm',
backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp' : cmd.includes('mlx_lm.server') ? 'mlx' : cmd.includes('diffusion_server') ? 'diffusers' : cmd.includes('sglang') ? 'sglang' : cmd.includes('ollama') ? 'ollama' : 'vllm',
port: _ex(/--port\s+(\d+)/) || '8000',
tp: _ex(/--tensor-parallel-size\s+(\d+)/) || '1',
ctx: _ex(/--max-model-len\s+(\d+)/) || _ex(/--n_ctx\s+(\d+)/) || _ex(/-c\s+(\d+)/) || '8192',
@@ -3771,7 +3851,8 @@ export async function _fetchCachedModels(fresh = false, opts = {}) {
const modelDirs = [];
if (selectedServer && Array.isArray(selectedServer.modelDirs)) {
for (const d of selectedServer.modelDirs) {
if (d && d !== '~/.cache/huggingface/hub') modelDirs.push(d);
const normalized = _normalizeCookbookModelDir(d);
if (normalized && normalized !== '~/.cache/huggingface/hub') modelDirs.push(normalized);
}
}
// Sync the header dir pills to THIS server (the one whose models we're listing).
@@ -3783,7 +3864,7 @@ export async function _fetchCachedModels(fresh = false, opts = {}) {
const _allDirs = (Array.isArray(selectedServer.modelDirs) && selectedServer.modelDirs.length
? selectedServer.modelDirs
: [selectedServer.modelDir || '~/.cache/huggingface/hub'])
.map(d => (d || '').replaceAll('✕', '').replaceAll('✖', '').trim()).filter(Boolean);
.map(d => _normalizeCookbookModelDir(d)).filter(Boolean);
_dirsEl.innerHTML = _allDirs.map(d => `<span class="cookbook-serve-dir-pill">${esc(d)}</span>`).join('')
+ '<span class="cookbook-serve-dir-edit" title="Edit in Settings">edit</span>';
_dirsEl.querySelector('.cookbook-serve-dir-edit')?.addEventListener('click', () => {
@@ -3803,10 +3884,12 @@ export async function _fetchCachedModels(fresh = false, opts = {}) {
}
if (!allowNetwork) {
_dlWp.destroy();
list.innerHTML = '<div class="hwfit-loading" style="flex-direction:column;gap:8px;text-align:center;"><div>No cached model scan yet</div><div style="font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;">Check this server\'s model cache.</div><button type="button" class="hwfit-gpu-btn serve-empty-scan-btn" style="height:26px;padding:3px 10px;">Scan</button></div>';
list.querySelector('.serve-empty-scan-btn')?.addEventListener('click', () => {
_fetchCachedModels(true);
});
const wp = spinnerModule.createWhirlpool(22);
list.innerHTML = '<div class="hwfit-loading serve-empty-auto-scan" style="flex-direction:column;gap:8px;text-align:center;"><div class="serve-empty-auto-wp"></div><div>No cached model scan yet</div><div style="font-size:11px;opacity:0.55;max-width:420px;line-height:1.4;">Scanning this server\'s model cache…</div></div>';
list.querySelector('.serve-empty-auto-wp')?.appendChild(wp.element);
setTimeout(() => {
if (list.querySelector('.serve-empty-auto-scan')) _fetchCachedModels(true);
}, 60);
const tagContainer = document.getElementById('serve-tags');
if (tagContainer) tagContainer.innerHTML = '';
return;
+437 -32
View File
@@ -2288,6 +2288,61 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
return header + '\n---\n' + body;
}
function _looksLikeWrappedEmailContent(text) {
const t = String(text || '').replace(/\r\n/g, '\n').trim();
return /\n---\n/.test(t) && /^(To|Cc|Bcc|Subject|In-Reply-To|References|X-Source-UID|X-Source-Folder):\s*/im.test(t);
}
function _decodeBase64EmailWrapper(block) {
const compact = String(block || '').replace(/\s+/g, '');
if (compact.length < 24 || compact.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(compact)) return null;
try {
const bin = atob(compact);
let decoded = '';
if (typeof TextDecoder !== 'undefined') {
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
} else {
decoded = decodeURIComponent(escape(bin));
}
decoded = decoded.replace(/\r\n/g, '\n');
return _looksLikeWrappedEmailContent(decoded) ? decoded : null;
} catch (_) {
return null;
}
}
function _sanitizeOutgoingEmailBody(raw) {
let text = String(raw || '').replace(/\r\n/g, '\n');
const trimmed = text.trim();
const decodedWhole = _decodeBase64EmailWrapper(trimmed);
if (decodedWhole) text = _parseEmailHeader(decodedWhole).body || '';
else if (_looksLikeWrappedEmailContent(trimmed)) text = _parseEmailHeader(trimmed).body || '';
const parts = text.split(/(\n{2,})/);
let changed = false;
const clean = parts.map(part => {
if (/^\n+$/.test(part)) return part;
const decoded = _decodeBase64EmailWrapper(part);
if (!decoded) return part;
changed = true;
return _parseEmailHeader(decoded).body || '';
}).join('');
if (!changed && /<[^>]+>/.test(text) && typeof document !== 'undefined') {
const probe = document.createElement('div');
probe.innerHTML = text;
const plain = (probe.innerText || probe.textContent || '').trim();
const plainClean = plain ? _sanitizeOutgoingEmailBody(plain) : plain;
if (plainClean !== plain) return plainClean;
}
return (changed ? clean : text)
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function _emailLocalDraftKey(sourceUid, sourceFolder, inReplyTo) {
const uid = String(sourceUid || '').trim();
if (!uid) return '';
@@ -2328,7 +2383,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
references: draft.references ?? fields.references,
sourceUid: draft.sourceUid ?? fields.sourceUid,
sourceFolder: draft.sourceFolder ?? fields.sourceFolder,
body: draft.body ?? fields.body,
body: _sanitizeOutgoingEmailBody(draft.body ?? fields.body),
};
}
@@ -2629,6 +2684,15 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
function _splitEmailReplyQuote(text) {
const original = String(text || '');
if (!original) return { body: '', quote: '', stripped: false };
const literal = '---------- Previous message ----------';
const literalIdx = original.indexOf(literal);
if (literalIdx >= 0) {
return {
body: original.slice(0, literalIdx).trim(),
quote: original.slice(literalIdx).trim(),
stripped: true,
};
}
const htmlQuoteOffset = _emailQuoteStartOffset(original);
if (htmlQuoteOffset >= 0) {
const body = original.slice(0, htmlQuoteOffset).trim();
@@ -2749,7 +2813,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
if (_shouldAutoCollapseEmailHeader()) _setEmailHeaderCollapsed(true, { manual: false });
}
function _showEmailFields(doc) {
function _showEmailFields(doc, { applyLocalDraft = true } = {}) {
const emailHeader = document.getElementById('doc-email-header');
const emailActions = document.getElementById('doc-email-actions');
// Show MD toolbar for email too (B, I, etc.)
@@ -2782,11 +2846,12 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
document.getElementById('doc-editor-code')?.classList.add('email-mode');
document.getElementById('doc-editor-highlight')?.classList.add('email-mode');
let fields = _parseEmailHeader(doc.content || '');
fields = _emailFieldsWithLocalDraft(fields);
if (applyLocalDraft) fields = _emailFieldsWithLocalDraft(fields);
const preserveEmailHeader = !!(fields.sourceUid || fields.inReplyTo || fields.references);
const subjectInput = document.getElementById('doc-email-subject');
const textarea = document.getElementById('doc-editor-textarea');
_setEmailHeaderInputValue('doc-email-to', fields.to, { preserveNonEmpty: true });
_setEmailHeaderInputValue('doc-email-subject', fields.subject, { preserveNonEmpty: true });
_setEmailHeaderInputValue('doc-email-to', fields.to, { preserveNonEmpty: preserveEmailHeader });
_setEmailHeaderInputValue('doc-email-subject', fields.subject, { preserveNonEmpty: preserveEmailHeader });
_setEmailHeaderCollapsed(!!(doc && doc._emailHeaderCollapsed), { manual: false });
if (subjectInput && !subjectInput._emailTabBodyBound) {
subjectInput._emailTabBodyBound = true;
@@ -2797,10 +2862,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
}
});
}
_setEmailHeaderInputValue('doc-email-in-reply-to', fields.inReplyTo, { preserveNonEmpty: true });
_setEmailHeaderInputValue('doc-email-references', fields.references, { preserveNonEmpty: true });
_setEmailHeaderInputValue('doc-email-source-uid', fields.sourceUid || '', { preserveNonEmpty: true });
_setEmailHeaderInputValue('doc-email-source-folder', fields.sourceFolder || '', { preserveNonEmpty: true });
_setEmailHeaderInputValue('doc-email-in-reply-to', fields.inReplyTo, { preserveNonEmpty: preserveEmailHeader });
_setEmailHeaderInputValue('doc-email-references', fields.references, { preserveNonEmpty: preserveEmailHeader });
_setEmailHeaderInputValue('doc-email-source-uid', fields.sourceUid || '', { preserveNonEmpty: preserveEmailHeader });
_setEmailHeaderInputValue('doc-email-source-folder', fields.sourceFolder || '', { preserveNonEmpty: preserveEmailHeader });
// Show/hide unread button only if we have a source UID (came from inbox)
const unreadBtn = document.getElementById('doc-email-unread-btn');
if (unreadBtn) unreadBtn.style.display = fields.sourceUid ? '' : 'none';
@@ -2923,8 +2988,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const ccRow = document.getElementById('doc-email-cc-row');
const bccRow = document.getElementById('doc-email-bcc-row');
const ccToggle = document.getElementById('doc-email-show-cc');
_setEmailHeaderInputValue('doc-email-cc', fields.cc || '', { preserveNonEmpty: true });
_setEmailHeaderInputValue('doc-email-bcc', fields.bcc || '', { preserveNonEmpty: true });
_setEmailHeaderInputValue('doc-email-cc', fields.cc || '', { preserveNonEmpty: preserveEmailHeader });
_setEmailHeaderInputValue('doc-email-bcc', fields.bcc || '', { preserveNonEmpty: preserveEmailHeader });
const hasCcBcc = !!(
fields.cc ||
fields.bcc ||
@@ -3055,6 +3120,292 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
await _uploadComposeFiles(files);
}
let _odysseusAttachMenu = null;
function _closeOdysseusAttachMenu() {
if (_odysseusAttachMenu) {
_odysseusAttachMenu.remove();
_odysseusAttachMenu = null;
}
document.removeEventListener('click', _attachMenuOutsideClick, true);
document.removeEventListener('keydown', _attachMenuEscape, true);
}
function _attachMenuOutsideClick(e) {
if (_odysseusAttachMenu && !_odysseusAttachMenu.contains(e.target)) _closeOdysseusAttachMenu();
}
function _attachMenuEscape(e) {
if (e.key !== 'Escape') return;
_closeOdysseusAttachMenu();
}
function _positionOdysseusAttachMenu(anchor, menu) {
const r = anchor?.getBoundingClientRect?.();
if (!r) return;
menu.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - 310))}px`;
menu.style.top = `${r.bottom + 6}px`;
requestAnimationFrame(() => {
const mr = menu.getBoundingClientRect();
if (mr.bottom > window.innerHeight - 8) {
menu.style.top = `${Math.max(8, r.top - mr.height - 6)}px`;
}
});
}
function _odysseusAttachLabel(item, kind) {
if (kind === 'gallery') {
return item.caption || item.prompt || item.filename || 'Gallery image';
}
return item.title || 'Untitled document';
}
async function _stageOdysseusAttachment(kind, id) {
const doc = docs.get(activeDocId);
if (!doc || doc.language !== 'email') return null;
if (!doc._composeAtts) doc._composeAtts = [];
const res = await fetch(`${API_BASE}/api/email/compose-from-odysseus`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind, id }),
});
let data = null;
try { data = await res.json(); } catch (_) {}
if (!res.ok || !data?.success) throw new Error(data?.error || data?.detail || `HTTP ${res.status}`);
doc._composeAtts.push({
token: data.token,
filename: data.filename,
size: data.size || 0,
});
return data;
}
async function _stageOdysseusZip(items) {
const doc = docs.get(activeDocId);
if (!doc || doc.language !== 'email') return null;
if (!doc._composeAtts) doc._composeAtts = [];
const res = await fetch(`${API_BASE}/api/email/compose-from-odysseus-zip`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
});
let data = null;
try { data = await res.json(); } catch (_) {}
if (!res.ok || !data?.success) throw new Error(data?.error || data?.detail || `HTTP ${res.status}`);
doc._composeAtts.push({
token: data.token,
filename: data.filename,
size: data.size || 0,
});
return data;
}
function _afterOdysseusAttachmentsAdded(count, label) {
_renderComposeAttachments();
clearTimeout(_autoSaveDebounce);
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
if (uiModule) uiModule.showToast(count > 1 ? `Attached ${count} items` : `Attached ${label || 'item'}`);
}
async function _attachOdysseusItem(kind, id, label, opts = {}) {
try {
const data = await _stageOdysseusAttachment(kind, id);
if (!data) return;
_afterOdysseusAttachmentsAdded(1, label || data.filename);
if (!opts.keepOpen) _closeOdysseusAttachMenu();
} catch (err) {
console.error('Failed to attach Odysseus item:', err);
if (uiModule) uiModule.showError('Failed to attach from Odysseus');
}
}
function _selectedOdysseusAttachRows(menu) {
return Array.from(menu?.querySelectorAll?.('.email-odysseus-attach-row.is-selected') || []);
}
function _syncOdysseusAttachSelection(menu) {
const selected = _selectedOdysseusAttachRows(menu);
const bar = menu?.querySelector?.('.email-odysseus-attach-actions');
const count = menu?.querySelector?.('.email-odysseus-attach-count');
const attachBtn = menu?.querySelector?.('.email-odysseus-attach-selected');
if (bar) bar.style.display = '';
if (count) count.textContent = selected.length ? `${selected.length} selected` : 'Select items to attach';
if (attachBtn) attachBtn.disabled = selected.length === 0;
}
async function _attachSelectedOdysseusItems(menu) {
const rows = _selectedOdysseusAttachRows(menu);
if (!rows.length) return;
const btn = menu.querySelector('.email-odysseus-attach-selected');
if (btn) {
btn.disabled = true;
btn.classList.add('is-loading');
}
let added = 0;
try {
const items = rows.map(row => ({ kind: row.dataset.kind, id: row.dataset.id })).filter(x => x.kind && x.id);
let zip = false;
if (items.length > 5) {
const ask = window.styledConfirm || uiModule?.styledConfirm;
zip = ask
? await ask(`Attach ${items.length} files as one zip?`, { confirmText: 'Zip', cancelText: 'Separate' })
: window.confirm(`Attach ${items.length} files as one zip?`);
}
if (zip) {
await _stageOdysseusZip(items);
added = 1;
} else {
for (const item of items) {
await _stageOdysseusAttachment(item.kind, item.id);
added += 1;
}
}
_afterOdysseusAttachmentsAdded(added, zip ? 'odysseus-attachments.zip' : undefined);
_closeOdysseusAttachMenu();
} catch (err) {
console.error('Failed to attach selected Odysseus items:', err);
if (uiModule) uiModule.showError(added ? `Attached ${added}, then failed` : 'Failed to attach from Odysseus');
_renderComposeAttachments();
} finally {
if (btn) {
btn.classList.remove('is-loading');
btn.disabled = false;
}
}
}
async function _loadOdysseusAttachItems(menu, kind) {
const list = menu.querySelector('.email-odysseus-attach-list');
if (!list) return;
menu.dataset.odyAttachKind = kind;
list.replaceChildren(spinnerModule.createLoadingRow('Loading…', 14));
menu.querySelectorAll('[data-ody-attach-kind]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.odyAttachKind === kind);
});
const q = (menu.querySelector('.email-odysseus-attach-search')?.value || '').trim();
try {
const params = new URLSearchParams({ sort: 'recent', limit: '20' });
if (q) params.set('search', q);
const endpoint = kind === 'gallery'
? `${API_BASE}/api/gallery/library?${params}`
: `${API_BASE}/api/documents/library?${params}`;
const res = await fetch(endpoint, { credentials: 'same-origin' });
const data = await res.json();
if (!res.ok) throw new Error(data?.error || data?.detail || `HTTP ${res.status}`);
const items = kind === 'gallery'
? (Array.isArray(data?.items) ? data.items : Array.isArray(data?.images) ? data.images : [])
: (Array.isArray(data?.documents) ? data.documents : Array.isArray(data?.items) ? data.items : []);
if (!items.length) {
list.innerHTML = `<div class="email-odysseus-attach-empty">${q ? 'No matches' : `No ${kind === 'gallery' ? 'images' : 'documents'}`}</div>`;
_syncOdysseusAttachSelection(menu);
return;
}
list.innerHTML = '';
for (const item of items) {
const label = _odysseusAttachLabel(item, kind);
const row = document.createElement('button');
row.type = 'button';
row.className = `email-odysseus-attach-row ${kind === 'gallery' ? 'is-gallery' : ''}`;
row.dataset.id = item.id || '';
row.dataset.kind = kind;
if (kind === 'gallery') {
const src = item.url ? `${API_BASE}${item.url}` : '';
row.innerHTML = `
<span class="email-odysseus-attach-dot" aria-hidden="true"></span>
<span class="email-odysseus-attach-thumb">${src ? `<img src="${_escHtml(src)}" alt="">` : ''}</span>
<span class="email-odysseus-attach-main">
<span class="email-odysseus-attach-title">${_escHtml(label)}</span>
<span class="email-odysseus-attach-meta">${_escHtml(item.filename || 'image')}</span>
</span>
`;
} else {
row.innerHTML = `
<span class="email-odysseus-attach-dot" aria-hidden="true"></span>
<span class="email-odysseus-attach-icon">${langIcon(item.language || 'text', 14, { style: 'opacity:0.8;' })}</span>
<span class="email-odysseus-attach-main">
<span class="email-odysseus-attach-title">${_escHtml(label)}</span>
<span class="email-odysseus-attach-meta">${_escHtml(item.language || 'text')}</span>
</span>
`;
}
row.addEventListener('click', (ev) => {
ev.preventDefault();
row.classList.toggle('is-selected');
_syncOdysseusAttachSelection(menu);
});
row.addEventListener('dblclick', () => _attachOdysseusItem(kind, item.id, label, { keepOpen: false }));
list.appendChild(row);
}
_syncOdysseusAttachSelection(menu);
} catch (err) {
console.error('Failed to load Odysseus attach items:', err);
list.innerHTML = '<div class="email-odysseus-attach-empty">Could not load</div>';
}
}
function _showComposeAttachMenu(anchor) {
if (_activeDocLanguage() !== 'email') {
document.getElementById('doc-md-image-input')?.click();
return;
}
_closeOdysseusAttachMenu();
const menu = document.createElement('div');
menu.className = 'email-odysseus-attach-menu';
menu.innerHTML = `
<button type="button" class="email-odysseus-attach-local">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Upload file
</button>
<div class="email-odysseus-attach-tabs">
<button type="button" data-ody-attach-kind="document" class="active">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h6"/></svg>
<span>Documents</span>
</button>
<button type="button" data-ody-attach-kind="gallery">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
<span>Gallery</span>
</button>
</div>
<label class="email-odysseus-attach-search-wrap">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input type="search" class="email-odysseus-attach-search" placeholder="Search attachments">
</label>
<div class="email-odysseus-attach-list"></div>
<div class="email-odysseus-attach-actions">
<span class="email-odysseus-attach-count"></span>
<button type="button" class="email-odysseus-attach-selected" disabled>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg>
<span>Attach</span>
</button>
</div>
`;
document.body.appendChild(menu);
_odysseusAttachMenu = menu;
_positionOdysseusAttachMenu(anchor, menu);
menu.querySelector('.email-odysseus-attach-local')?.addEventListener('click', () => {
_closeOdysseusAttachMenu();
document.getElementById('doc-email-file-input')?.click();
});
menu.querySelectorAll('[data-ody-attach-kind]').forEach(btn => {
btn.addEventListener('click', () => _loadOdysseusAttachItems(menu, btn.dataset.odyAttachKind));
});
let attachSearchTimer = null;
menu.querySelector('.email-odysseus-attach-search')?.addEventListener('input', () => {
clearTimeout(attachSearchTimer);
attachSearchTimer = setTimeout(() => {
_loadOdysseusAttachItems(menu, menu.dataset.odyAttachKind || 'document');
}, 220);
});
menu.querySelector('.email-odysseus-attach-selected')?.addEventListener('click', () => _attachSelectedOdysseusItems(menu));
setTimeout(() => {
document.addEventListener('click', _attachMenuOutsideClick, true);
document.addEventListener('keydown', _attachMenuEscape, true);
}, 0);
_loadOdysseusAttachItems(menu, 'document');
}
function _isMarkdownImageFile(file) {
if (!file) return false;
if ((file.type || '').toLowerCase().startsWith('image/')) return true;
@@ -3241,13 +3592,23 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
}).filter(Boolean)
);
sugg.innerHTML = '';
sugg.dataset.navStarted = '0';
let count = 0;
for (const c of data.results) {
for (const em of (c.emails || [])) {
if (already.has(em.toLowerCase())) continue;
const item = document.createElement('div');
item.className = 'contact-suggestion';
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', 'false');
item.innerHTML = `<span class="contact-name">${_escHtml(c.name)}</span><span class="contact-email">${_escHtml(em)}</span>`;
item.addEventListener('mouseenter', () => {
sugg.dataset.navStarted = '1';
sugg.querySelectorAll('.contact-suggestion').forEach(it => {
it.classList.toggle('active', it === item);
it.setAttribute('aria-selected', it === item ? 'true' : 'false');
});
});
// mousedown fires before blur so the click doesn't get lost
item.addEventListener('mousedown', (e) => { e.preventDefault(); _commitRecipient(input, sugg, em); });
item.addEventListener('click', (e) => { e.preventDefault(); _commitRecipient(input, sugg, em); });
@@ -3256,9 +3617,6 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
}
}
if (count === 0) { sugg.style.display = 'none'; return; }
// Auto-highlight first suggestion so Enter accepts it.
const first = sugg.querySelector('.contact-suggestion');
if (first) first.classList.add('active');
sugg.style.display = '';
} catch (e) {
sugg.style.display = 'none';
@@ -3284,16 +3642,32 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const items = open ? sugg.querySelectorAll('.contact-suggestion') : [];
const active = open ? sugg.querySelector('.contact-suggestion.active') : null;
let idx = active ? Array.from(items).indexOf(active) : -1;
const setActive = (nextIdx) => {
items.forEach((it, i) => {
const on = i === nextIdx;
it.classList.toggle('active', on);
it.setAttribute('aria-selected', on ? 'true' : 'false');
});
if (items[nextIdx]) {
items[nextIdx].scrollIntoView({ block: 'nearest' });
}
};
if (open && e.key === 'ArrowDown') {
e.preventDefault();
idx = Math.min(items.length - 1, idx + 1);
items.forEach(it => it.classList.remove('active'));
if (items[idx]) items[idx].classList.add('active');
if (!items.length) return;
if (sugg.dataset.navStarted !== '1') {
idx = Math.max(0, idx);
sugg.dataset.navStarted = '1';
} else {
idx = Math.min(items.length - 1, idx + 1);
}
setActive(idx);
} else if (open && e.key === 'ArrowUp') {
e.preventDefault();
if (!items.length) return;
sugg.dataset.navStarted = '1';
idx = Math.max(0, idx - 1);
items.forEach(it => it.classList.remove('active'));
if (items[idx]) items[idx].classList.add('active');
setActive(idx);
} else if (e.key === 'Enter') {
// If a suggestion is highlighted, commit it. Otherwise — if the
// current fragment already looks like a complete email — commit
@@ -3410,8 +3784,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const _rich = _emailRichbodyActive();
if (_rich) _syncEmailRichbody(_rich);
const textarea = document.getElementById('doc-editor-textarea');
const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
const bodyHtml = _rich ? _rich.innerHTML : null;
const rawBody = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
const body = _sanitizeOutgoingEmailBody(rawBody);
let bodyHtml = _rich ? _rich.innerHTML : null;
if (_rich && body !== rawBody) bodyHtml = _emailBodyToHtml(body);
const doc = docs.get(activeDocId);
const attachments = (doc?._composeAtts || []).map(a => a.token);
if (!to || !body) {
@@ -3574,8 +3950,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const _rich = _emailRichbodyActive();
if (_rich) _syncEmailRichbody(_rich);
const textarea = document.getElementById('doc-editor-textarea');
const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
const bodyHtml = _rich ? _rich.innerHTML : null;
const rawBody = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
const body = _sanitizeOutgoingEmailBody(rawBody);
let bodyHtml = _rich ? _rich.innerHTML : null;
if (_rich && body !== rawBody) bodyHtml = _emailBodyToHtml(body);
const btn = document.getElementById('doc-email-draft-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Saving...'; }
const controller = new AbortController();
@@ -3917,10 +4295,11 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
const references = document.getElementById('doc-email-references')?.value?.trim();
const _rich = _emailRichbodyActive();
if (_rich) _syncEmailRichbody(_rich);
const body = (_rich
const rawBody = (_rich
? (_rich.innerText || _rich.textContent || '')
: (document.getElementById('doc-editor-textarea')?.value || '')
).trim();
const body = _sanitizeOutgoingEmailBody(rawBody);
const doc = docs.get(activeDocId);
const attachments = (doc?._composeAtts || []).map(a => a.token);
@@ -5139,12 +5518,12 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
}, true);
// Attachments
document.getElementById('doc-email-attach-btn')?.addEventListener('click', () => {
document.getElementById('doc-email-file-input')?.click();
document.getElementById('doc-email-attach-btn')?.addEventListener('click', (e) => {
_showComposeAttachMenu(e.currentTarget);
});
document.getElementById('md-toolbar-attach-btn')?.addEventListener('click', () => {
document.getElementById('md-toolbar-attach-btn')?.addEventListener('click', (e) => {
if (_activeDocLanguage() === 'email') {
document.getElementById('doc-email-file-input')?.click();
_showComposeAttachMenu(e.currentTarget);
} else {
document.getElementById('doc-md-image-input')?.click();
}
@@ -10056,11 +10435,33 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
if (!docs.has(docId)) {
const curSession = sessionModule?.getCurrentSessionId() || '';
let reuseId = null;
const incomingFields = _parseEmailHeader(newContent || '');
// Email subjects repeat constantly ("test", "Re: ..."). Match open
// compose docs by source email identity first; never let a same-title
// draft steal an update meant for a different open email.
if (incomingFields.sourceUid) {
const wantFolder = (incomingFields.sourceFolder || 'INBOX').trim();
for (const [existingId, existingDoc] of docs) {
const existingFields = _parseEmailHeader(existingDoc.content || '');
if (
String(existingFields.sourceUid || '') === String(incomingFields.sourceUid)
&& ((existingFields.sourceFolder || 'INBOX').trim() === wantFolder)
) {
reuseId = existingId;
break;
}
}
}
// First: match by title
if (data.title) {
if (!reuseId && data.title) {
for (const [existingId, existingDoc] of docs) {
if (existingDoc.title === data.title && existingDoc.sessionId === curSession) {
if (
existingDoc.title === data.title
&& existingDoc.sessionId === curSession
&& (existingDoc.language || '').toLowerCase() !== 'email'
) {
reuseId = existingId;
break;
}
@@ -10195,8 +10596,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
if (isEmailUpdate) {
const updatedDocForEmail = docs.get(docId);
if (updatedDocForEmail) {
const updatedFields = _parseEmailHeader(updatedDocForEmail.content || '');
_clearEmailLocalDraft(updatedFields.sourceUid, updatedFields.sourceFolder, updatedFields.inReplyTo);
_setMarkdownPreviewActive(false, { remember: false });
_showEmailFields(updatedDocForEmail);
_showEmailFields(updatedDocForEmail, { applyLocalDraft: false });
}
} else {
if (textarea) textarea.value = newContent;
@@ -10219,7 +10622,9 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
if (isEmailUpdate && updatedDoc) {
updatedDoc.language = 'email';
if (langSelect) langSelect.value = 'email';
_showEmailFields(updatedDoc);
const updatedFields = _parseEmailHeader(updatedDoc.content || '');
_clearEmailLocalDraft(updatedFields.sourceUid, updatedFields.sourceFolder, updatedFields.inReplyTo);
_showEmailFields(updatedDoc, { applyLocalDraft: false });
}
if (updatedDoc && !updatedDoc.userSetLanguage && !updatedDoc.language) {
setTimeout(attemptAutoDetect, 100);
+89 -93
View File
@@ -8,7 +8,7 @@ import sessionModule from './sessions.js';
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen } from './emailLibrary.js';
import * as Modals from './modalManager.js';
import { applyEdgeDock } from './modalSnap.js';
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
import { buildReplyAllCc, extractEmail } from './emailLibrary/replyRecipients.js';
import { emailApiUrl, emailAccountQuery } from './emailShared.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
@@ -29,6 +29,23 @@ const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
const _replySeparator = '---------- Previous message ----------';
const _DONE_RESPONSE_TAGS = new Set(['urgent', 'reply-soon', 'action-needed']);
function _splitEmailAddresses(raw) {
return (typeof raw === 'string' ? raw : '')
.split(',')
.map((x) => x.trim())
.filter(Boolean);
}
function _isMyEmailAddress(addr, myAddresses) {
const email = extractEmail(addr);
if (!email) return false;
return new Set((myAddresses || []).map(a => String(a || '').trim().toLowerCase()).filter(Boolean)).has(email);
}
function _withoutMyAddresses(raw, myAddresses) {
return _splitEmailAddresses(raw).filter(addr => !_isMyEmailAddress(addr, myAddresses));
}
function _openCalendarEventFromEmail(uid) {
const target = String(uid || '').trim();
if (!target) return;
@@ -832,13 +849,26 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
? window._myEmailAddresses
: (window._myEmailAddress ? [window._myEmailAddress] : []);
let toAddress = data.from_address;
const fromIsMe = _isMyEmailAddress(data.from_address, myAddresses);
const originalToWithoutMe = _withoutMyAddresses(data.to, myAddresses);
const originalCcWithoutMe = _withoutMyAddresses(data.cc, myAddresses);
let toAddress = fromIsMe
? (originalToWithoutMe.join(', ') || originalCcWithoutMe[0] || data.from_address)
: data.from_address;
let ccAddresses = '';
let subjectPrefix = 'Re: ';
if (mode === 'reply-all') {
// Build reply-all: TO = original sender, CC = everyone else (To + Cc minus me)
ccAddresses = buildReplyAllCc(data, myAddresses);
if (fromIsMe) {
// Replying from Sent should go back to the people I originally wrote
// to, not to myself. Keep original Cc recipients on Cc.
toAddress = originalToWithoutMe.join(', ') || originalCcWithoutMe[0] || data.from_address;
ccAddresses = originalCcWithoutMe.filter(addr => !originalToWithoutMe.some(t => extractEmail(t) === extractEmail(addr))).join(', ');
} else {
// Build reply-all: TO = original sender, CC = everyone else (To + Cc minus me)
ccAddresses = buildReplyAllCc(data, myAddresses);
}
} else if (mode === 'forward') {
toAddress = '';
subjectPrefix = 'Fwd: ';
@@ -921,7 +951,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
// Agent-provided reply text should land in the email draft the user
// already has open. Otherwise mobile users see the source email while the
// agent silently creates a second draft elsewhere.
const reuseExisting = (mode === 'view' || mode === 'open' || (!!aiSuggestedBody && mode !== 'forward'));
const reuseExisting = mode !== 'forward';
const existingDocId = (reuseExisting && _docModule.findEmailDocId)
? _docModule.findEmailDocId(em.uid, _currentFolder)
: null;
@@ -930,52 +960,22 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
await _docModule.loadDocument(existingDocId);
if (aiSuggestedBody && typeof _docModule.replaceEmailReplyBody === 'function') {
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody);
_bringEmailReplyDraftToFrontOnMobile();
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody, { force: true });
}
_bringEmailReplyDraftToFrontOnMobile();
} else {
// If the user already has a chat session open, reuse it instead of
// spawning a new one. They asked for this explicitly — opening reply
// mid-conversation shouldn't whip them out of context.
let activeSid = '';
try { activeSid = sessionModule?.getCurrentSessionId?.() || ''; } catch {}
const activeSid = await _createEmailChat(data);
if (!activeSid) {
// No chat in flight — keep the old behavior of creating a scoped
// email-thread chat, then RE-READ the now-current session id. The
// POST below requires a session_id (backend 400s without one), and
// the freshly-created chat is what should own the reply draft.
await _createEmailChat(data);
try { activeSid = sessionModule?.getCurrentSessionId?.() || ''; } catch {}
}
// Guarantee a session — _createEmailChat can't make one when there's
// no enabled default-chat endpoint, which left the reply POSTing a
// null session_id → 400. Create a bare session so the draft always
// has a home regardless of chat/endpoint config.
if (!activeSid) {
try {
const _fd = new FormData();
_fd.append('name', `Email: ${(data.subject || '').slice(0, 60)}`);
_fd.append('skip_validation', 'true');
const _sres = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: _fd, credentials: 'same-origin' });
if (_sres.ok) {
const _sdata = await _sres.json();
if (_sdata && _sdata.id) {
activeSid = _sdata.id;
if (sessionModule?.loadSessions) await sessionModule.loadSessions();
if (sessionModule?.selectSession) await sessionModule.selectSession(activeSid);
}
}
} catch (e) { console.error('reply: bare session create failed', e); }
console.error('reply: could not obtain a session_id');
import('./ui.js').then(m => m.showError && m.showError('Could not start a reply chat.')).catch(() => {});
return;
}
const docRes = await fetch(`${API_BASE}/api/document`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// Reuse the user's current chat session if there is one (so the
// reply draft lives in the chat they were just in); otherwise
// null and the new email-chat (created above) takes over.
session_id: activeSid || null,
session_id: activeSid,
title: data.subject,
content: content,
language: 'email',
@@ -1256,31 +1256,58 @@ async function _toggleDone(em, itemEl) {
}
async function _createEmailChat(emailData) {
const subject = String(emailData?.subject || 'New Email').trim() || 'New Email';
const title = subject === 'New Email' ? 'New Email' : `Email: ${subject.slice(0, 60)}`;
try {
// Try current session's endpoint first
const current = sessionModule.getSessions?.().find(s => s.id === sessionModule.getCurrentSessionId?.());
let url, model, endpointId;
if (current && current.endpoint_url && current.model) {
url = current.endpoint_url;
model = current.model;
endpointId = current.endpoint_id;
} else {
// Fall back to default chat config
const dcRes = await fetch(`${API_BASE}/api/default-chat`);
const dc = await dcRes.json();
url = dc.endpoint_url;
model = dc.model;
endpointId = dc.endpoint_id;
const currentSid = sessionModule.getCurrentSessionId?.() || '';
const current = sessionModule.getSessions?.().find(s => s.id === currentSid);
const currentIsBlank = !!current
&& !current.archived
&& !current.has_documents
&& !current.has_images
&& Number(current.message_count || 0) === 0
&& current.folder !== 'Assistant'
&& current.folder !== 'Tasks';
if (currentIsBlank) {
const meta = document.getElementById('current-meta');
if (meta) meta.textContent = title;
return current.id;
}
let url = current?.endpoint_url || '';
let model = current?.model || '';
let endpointId = current?.endpoint_id || '';
if (!url || !model) {
try {
const dcRes = await fetch(`${API_BASE}/api/default-chat`, { credentials: 'same-origin' });
const dc = dcRes.ok ? await dcRes.json() : {};
url = dc.endpoint_url || '';
model = dc.model || '';
endpointId = dc.endpoint_id || '';
} catch (_) {}
}
if (url && model) {
await sessionModule.createDirectChat(url, model, endpointId);
// Set a helpful title in the chat meta
const meta = document.getElementById('current-meta');
if (meta) meta.textContent = `Email: ${(emailData.subject || '').slice(0, 60)}`;
const fd = new FormData();
fd.append('name', title);
fd.append('skip_validation', 'true');
if (url) fd.append('endpoint_url', url);
if (model) fd.append('model', model);
if (endpointId) fd.append('endpoint_id', endpointId);
const res = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: fd, credentials: 'same-origin' });
if (!res.ok) {
console.error('email chat create failed', res.status, await res.text().catch(() => ''));
return '';
}
const payload = await res.json().catch(() => ({}));
const sid = payload?.id || '';
if (!sid) return '';
if (sessionModule?.loadSessions) await sessionModule.loadSessions();
if (sessionModule?.selectSession) await sessionModule.selectSession(sid);
const meta = document.getElementById('current-meta');
if (meta) meta.textContent = title;
return sid;
} catch (e) {
console.error('Failed to create email chat:', e);
return '';
}
}
@@ -1292,38 +1319,7 @@ async function _composeNew() {
// (doc shows for a frame, then slides up again). Mount once, at injectFreshDoc,
// after the session + doc exist.
try {
// /api/document requires a session_id (returns 400 if null), so reuse
// the active chat if there is one — otherwise spin up an email-scoped
// chat first, same pattern the reply path uses.
let sid = '';
try { sid = sessionModule?.getCurrentSessionId?.() || ''; } catch (_) {}
if (!sid) {
await _createEmailChat({ subject: 'New Email' });
try { sid = sessionModule?.getCurrentSessionId?.() || ''; } catch (_) {}
}
// Guarantee a session — _createEmailChat can't make one when there's no
// enabled default-chat endpoint, which left compose POSTing a null
// session_id → 400 (the draft silently never appeared). Same bare-session
// fallback the reply flow uses.
if (!sid) {
try {
const _fd = new FormData();
_fd.append('name', 'New Email');
_fd.append('skip_validation', 'true');
const _sres = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: _fd, credentials: 'same-origin' });
if (_sres.ok) {
const _sdata = await _sres.json();
if (_sdata && _sdata.id) {
sid = _sdata.id;
// NOTE: intentionally do NOT loadSessions()/selectSession() here.
// Re-selecting the (empty) session re-renders the chat and flashes
// the welcome splash for a frame before the draft opens — the
// "splash flickers like crazy then email opens" bug. The doc only
// needs the session_id; the draft opens in the doc panel regardless.
}
}
} catch (e) { console.error('compose: bare session create failed', e); }
}
const sid = await _createEmailChat({ subject: 'New Email' });
if (!sid) {
console.error('compose: could not obtain a session_id');
import('./ui.js').then(m => m.showError && m.showError('Could not start a new email (no session).')).catch(() => {});
+221 -45
View File
@@ -35,6 +35,8 @@ let _libSearchSeq = 0;
let _libSearchHadResults = false;
let _libSearchInFlight = false;
let _activeEmailReaderForSelectAll = null;
let _libAccountsLoadedAt = 0;
const _LIB_ACCOUNTS_TTL_MS = 5 * 60 * 1000;
function _isEmailTypingTarget(t) {
return !!(t && (
@@ -981,6 +983,28 @@ function _loadEmailsFresh() {
return _loadEmails({ force: true, useCache: false });
}
function _isChatInteractionBusy() {
try {
if (window.__odysseusChatBusy) return true;
const until = Number(window.__odysseusChatBusyUntil || 0);
return until > Date.now();
} catch (_) {
return false;
}
}
function _loadEmailsWhenChatIdle({ delay = 700, retries = 180, options = {} } = {}) {
const run = () => {
if (!state._libOpen || !document.getElementById('email-lib-modal')) return;
if (_isChatInteractionBusy() && retries > 0) {
setTimeout(() => _loadEmailsWhenChatIdle({ delay: 1000, retries: retries - 1, options }), 1000);
return;
}
_loadEmails(options);
};
setTimeout(run, Math.max(0, Number(delay) || 0));
}
export function prewarmEmailLibrary({ delay = 2500 } = {}) {
if (_libPrewarmTimer || _libPrewarmPromise) return;
const elapsed = Date.now() - _libLastPrewarmAt;
@@ -1011,7 +1035,10 @@ async function _prewarmEmailViews() {
const accountsRes = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
if (accountsRes.ok) {
const accountsData = await accountsRes.json().catch(() => ({}));
if (Array.isArray(accountsData.accounts)) state._libAccounts = accountsData.accounts;
if (Array.isArray(accountsData.accounts)) {
state._libAccounts = accountsData.accounts;
_libAccountsLoadedAt = Date.now();
}
}
} catch (_) {}
@@ -1737,7 +1764,13 @@ export function openEmailLibrary(opts = {}) {
};
document.addEventListener('keydown', state._libEscHandler, true);
_renderAccountsLoading();
const grid = document.getElementById('email-lib-grid');
if (grid && !grid.children.length) _renderEmailLoading(grid);
if (Array.isArray(state._libAccounts) && state._libAccounts.length) {
_renderAccountsStrip();
} else {
_renderAccountsLoading();
}
// Await accounts before loading emails so the list request carries the
// right account_id from the very first fetch (now that we auto-select
// an explicit account instead of relying on a 'Default' chip).
@@ -1745,17 +1778,31 @@ export function openEmailLibrary(opts = {}) {
await _loadAccounts();
_loadFolders();
_loadEmailReminderBellVisibility();
_loadEmails();
_loadEmailsWhenChatIdle();
})();
}
async function _loadAccounts() {
async function _loadAccounts({ force = false } = {}) {
const hasCachedAccounts = Array.isArray(state._libAccounts) && state._libAccounts.length;
const accountsFresh = _libAccountsLoadedAt && (Date.now() - _libAccountsLoadedAt) < _LIB_ACCOUNTS_TTL_MS;
if (!force && hasCachedAccounts && accountsFresh) {
if (!state._libAccountId) {
const def = state._libAccounts.find(a => a.is_default) || state._libAccounts[0];
state._libAccountId = def?.id || null;
_publishActiveAccount();
}
_renderAccountsStrip();
return;
}
try {
const r = await fetch(`${API_BASE}/api/email/accounts`, { credentials: 'same-origin' });
if (!r.ok) return;
const d = await r.json();
state._libAccounts = d.accounts || [];
} catch (_) { state._libAccounts = []; }
_libAccountsLoadedAt = Date.now();
} catch (_) {
if (!hasCachedAccounts) state._libAccounts = [];
}
// The 'Default' chip is gone — pick an explicit account so the email
// list and any per-email actions (open in new tab, mark read, etc.)
// always carry an account_id and can't desync from the server's
@@ -2080,6 +2127,60 @@ function _crossFolderCandidates() {
return Array.from(new Set(candidates.filter(Boolean)));
}
function _findEmailFolder(patterns, fallback) {
const available = Array.isArray(state._libFolders) ? state._libFolders.filter(Boolean) : [];
const lower = new Map(available.map(f => [String(f).toLowerCase(), f]));
for (const p of patterns) {
const direct = lower.get(String(p).toLowerCase());
if (direct) return direct;
}
return available.find(f => patterns.some(p => String(f).toLowerCase().includes(String(p).toLowerCase()))) || fallback;
}
function _sentFolderName() {
return _findEmailFolder(['[Gmail]/Sent Mail', 'Sent Mail', 'Sent Items', 'INBOX.Sent', 'Sent'], 'Sent');
}
function _deriveSearchScope(rawQuery) {
const original = String(rawQuery || '').trim();
const tokens = original.split(/\s+/).filter(Boolean);
let scope = 'all';
const kept = [];
let forced = '';
for (const token of tokens) {
const t = token.toLowerCase().replace(/^#+/, '').replace(/:$/, '');
if (['sent', 'sentmail', 'sent-mail', 'outbox'].includes(t)) {
forced = 'sent';
continue;
}
if (['inbox'].includes(t)) {
forced = 'inbox';
continue;
}
kept.push(token);
}
if (forced) scope = forced;
let folder = 'INBOX';
let serverScope = 'all';
if (scope === 'sent') {
folder = _sentFolderName();
serverScope = 'folder';
} else if (scope === 'inbox') {
folder = 'INBOX';
serverScope = 'folder';
} else if (scope === 'current') {
folder = state._libFolder || 'INBOX';
serverScope = 'folder';
}
return {
scope,
folder,
serverScope,
q: forced ? kept.join(' ').trim() : original,
forced,
};
}
// Snapshot of state._libEmails taken right before search starts so we
// can both filter locally and restore on clear without re-fetching.
let _libPreSearchEmails = null;
@@ -2429,6 +2530,15 @@ function _addSearchPill(pill) {
_applyPillFilter();
}
function _searchQueryFromPills() {
const parts = [];
for (const p of state._libSearchPills || []) {
if (p.type === 'text' && p.text) parts.push(String(p.text).trim());
else if (p.type === 'contact' && (p.email || p.name)) parts.push(String(p.email || p.name).trim());
}
return parts.filter(Boolean).join(' ').trim();
}
function _removeSearchPillAt(idx) {
if (!Array.isArray(state._libSearchPills)) return;
const removed = state._libSearchPills[idx];
@@ -2455,6 +2565,26 @@ function _removeSearchPillAt(idx) {
_loadEmails({ useCache: true });
return;
}
const remainingQuery = _searchQueryFromPills();
if (remainingQuery.length >= 2) {
state._libSearch = remainingQuery;
const _searchInput = document.getElementById('email-lib-search');
if (_searchInput) _searchInput.value = '';
state._libSearchDraft = '';
_doSearch();
return;
}
if ((state._libSearchPills || []).length && _libSearchHadResults) {
_libSearchHadResults = false;
_libPreSearchEmails = null;
_libPreSearchTotal = 0;
_libServerSearchEmails = null;
_libServerSearchTotal = 0;
state._libSearch = '';
state._libOffset = 0;
_loadEmails({ useCache: true });
return;
}
_applyPillFilter();
}
@@ -2724,8 +2854,9 @@ window.addEventListener('click', (e) => {
async function _doSearch() {
_exitEmailReaderModeForList();
const seq = ++_libSearchSeq;
const q = state._libSearch.trim();
if (q.length < 2) {
const derived = _deriveSearchScope(state._libSearch);
const q = derived.q;
if (q.length < 2 && !derived.forced) {
// Empty or too short — restore the normal folder if a previous search
// had replaced the grid contents.
if (_libSearchHadResults) {
@@ -2738,7 +2869,8 @@ async function _doSearch() {
return;
}
const accountAtStart = state._libAccountId || '';
const folderAtStart = state._libFolder || 'INBOX';
const folderAtStart = derived.folder || state._libFolder || 'INBOX';
const serverScopeAtStart = derived.serverScope || 'all';
// No grid-blanking spinner — the local filter already painted something
// useful. Surface progress in the stats badge instead so the user knows
// the server search is still grinding.
@@ -2753,25 +2885,67 @@ async function _doSearch() {
const stillCurrent = () => (
seq === _libSearchSeq &&
q === state._libSearch.trim() &&
q === _deriveSearchScope(state._libSearch).q &&
accountAtStart === (state._libAccountId || '') &&
folderAtStart === (state._libFolder || 'INBOX')
folderAtStart === (_deriveSearchScope(state._libSearch).folder || state._libFolder || 'INBOX')
);
const searchUrl = (localOnly = false) => {
const params = new URLSearchParams({
folder: folderAtStart,
q,
limit: '100',
scope: serverScopeAtStart,
});
if (accountAtStart) params.set('account_id', accountAtStart);
if (localOnly) params.set('local_only', '1');
return `${API_BASE}/api/email/search?${params.toString()}`;
};
const folderListUrl = () => {
const params = new URLSearchParams({
folder: folderAtStart,
limit: '100',
offset: '0',
filter: state._libFilter || 'all',
});
if (accountAtStart) params.set('account_id', accountAtStart);
return `${API_BASE}/api/email/list?${params.toString()}`;
};
const mergeSearchResults = (painted, incoming) => {
const byKey = new Map();
const out = [];
const add = (em) => {
if (!em) return;
const key = `${em.account_id || accountAtStart || ''}:${em.folder || folderAtStart || ''}:${em.uid || em.message_id || JSON.stringify(em)}`;
if (byKey.has(key)) return;
byKey.set(key, em);
out.push(em);
};
(painted || []).forEach(add);
const additions = [];
const addIncoming = (em) => {
if (!em) return;
const key = `${em.account_id || accountAtStart || ''}:${em.folder || folderAtStart || ''}:${em.uid || em.message_id || JSON.stringify(em)}`;
if (byKey.has(key)) return;
byKey.set(key, em);
additions.push(em);
};
(incoming || []).forEach(addIncoming);
additions.sort((a, b) => {
const ad = Number(a?.date_epoch || 0);
const bd = Number(b?.date_epoch || 0);
if (bd !== ad) return bd - ad;
return String(b?.date || '').localeCompare(String(a?.date || ''));
});
return out.concat(additions);
};
let paintedInterimResults = false;
const paintSearchData = (data, interim = false) => {
if (!stillCurrent()) return false;
if (data.error) throw new Error(data.error);
const results = data.emails || [];
let results = data.emails || [];
if (!interim && paintedInterimResults) {
results = mergeSearchResults(state._libEmails || [], results);
}
if (!interim && paintedInterimResults && results.length === 0) {
if (stats) {
const count = state._libTotal || (state._libEmails || []).length;
@@ -2789,7 +2963,7 @@ async function _doSearch() {
const preservingBase = !!(_libServerSearchEmails && pills.length > 1);
if (!preservingBase) {
_libServerSearchEmails = results.slice();
_libServerSearchTotal = data.total || results.length;
_libServerSearchTotal = Math.max(Number(data.total || 0), results.length);
_libPreSearchEmails = results.slice();
_libPreSearchTotal = _libServerSearchTotal;
state._libEmails = results;
@@ -2803,7 +2977,7 @@ async function _doSearch() {
if (!(state._libEmails || []).length && !preservingBase) state._libEmails = results;
}
_renderGrid();
const count = data.total || results.length;
const count = Math.max(Number(data.total || 0), results.length);
if (stats) {
if (interim) {
stats.textContent = `${count} cached match${count === 1 ? '' : 'es'} · searching…`;
@@ -2822,9 +2996,22 @@ async function _doSearch() {
};
try {
if (q.length < 2 && derived.forced) {
const res = await fetch(folderListUrl());
const data = await res.json();
if (!stillCurrent()) return;
paintSearchData({
emails: (data.emails || []).map(em => ({ ...em, folder: folderAtStart })),
total: data.total || (data.emails || []).length,
source: 'folder',
sync: { source: 'folder' },
}, false);
return;
}
const fullSearchPromise = fetch(searchUrl(false)).then(res => res.json());
const localSearchPromise = fetch(searchUrl(true)).then(res => res.json());
try {
const localRes = await fetch(searchUrl(true));
const localData = await localRes.json();
const localData = await localSearchPromise;
if (!stillCurrent()) return;
if (!localData.error && (localData.emails || []).length) {
paintSearchData(localData, true);
@@ -2833,8 +3020,7 @@ async function _doSearch() {
if (!stillCurrent()) return;
}
const res = await fetch(searchUrl(false));
const data = await res.json();
const data = await fullSearchPromise;
if (!stillCurrent()) return;
paintSearchData(data, false);
} catch (e) {
@@ -3401,9 +3587,12 @@ function _createCard(em) {
card.appendChild(cb);
}
// In Sent folder, show the recipient(s) — the sender is always you and
// hides the actually useful info. Outside Sent, show the sender as before.
const isSentFolderEarly = /sent/i.test(state._libFolder);
// In Sent results, show the recipient(s) — the sender is always you and
// hides the actually useful info. Search results can be stamped with their
// real folder while the visible folder selector still says INBOX, so use the
// email's folder first.
const cardFolder = em.folder || state._libFolder || 'INBOX';
const isSentFolderEarly = /sent/i.test(cardFolder);
let senderName;
let senderAddress;
if (isSentFolderEarly) {
@@ -3482,7 +3671,7 @@ function _createCard(em) {
}
// Done check + unread dot stay next to the subject on the left.
const isSentFolder = /sent/i.test(state._libFolder);
const isSentFolder = /sent/i.test(cardFolder);
if (!isSentFolder) {
const doneCheck = document.createElement('span');
doneCheck.className = 'email-card-done' + (em.is_answered ? ' active' : '');
@@ -3512,10 +3701,10 @@ function _createCard(em) {
}
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' });
await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(cardFolder)}${_acct()}`, { method: 'POST' });
await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(cardFolder)}${_acct()}`, { method: 'POST' });
} else {
await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(cardFolder)}${_acct()}`, { method: 'POST' });
}
} catch (err) { console.error(err); }
};
@@ -3571,8 +3760,14 @@ function _createCard(em) {
const meta = document.createElement('div');
meta.className = 'memory-item-meta';
meta.style.cssText = 'font-size:10px;opacity:0.7;margin-top:2px;';
const showFolderChip = !!(_libSearchHadResults && cardFolder);
const prettyFolder = folderDisplayName(cardFolder);
const sentChip = isSentFolderEarly ? '<span class="email-sent-chip" title="Sent email">Sent</span>' : '';
const folderChip = showFolderChip && !isSentFolderEarly
? `<span class="email-folder-chip" title="${_esc(cardFolder)}">${_esc(prettyFolder)}</span>`
: '';
const senderPrefix = isSentFolderEarly ? 'to ' : '';
meta.innerHTML = `<span class="email-meta-sender" data-email="${_esc(senderAddress || '')}" data-name="${_esc(senderName || '')}"><span style="opacity:0.55">${senderPrefix}</span><span style="color:${color};font-weight:600">${_esc(senderName)}</span></span><span class="email-meta-sep"> · </span><span class="email-meta-date">${_esc(dateStr)}</span>`;
meta.innerHTML = `${sentChip}${folderChip}<span class="email-meta-sender" data-email="${_esc(senderAddress || '')}" data-name="${_esc(senderName || '')}"><span style="opacity:0.55">${senderPrefix}</span><span style="color:${color};font-weight:600">${_esc(senderName)}</span></span><span class="email-meta-sep"> · </span><span class="email-meta-date">${_esc(dateStr)}</span>`;
content.appendChild(meta);
card.appendChild(content);
@@ -6411,26 +6606,7 @@ async function _translateEmail(reader, language, opts = {}) {
}
async function _maybeAutoTranslateEmail(reader) {
if (!reader || reader.dataset.autoTranslateChecked === '1') return;
reader.dataset.autoTranslateChecked = '1';
try {
const res = await fetch(`${API_BASE}/api/email/config`);
const cfg = await res.json();
if (!cfg || !cfg.email_auto_translate) return;
try {
const sid = window.sessionModule?.getCurrentSessionId?.() || '';
if (sid) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 800);
const statusRes = await fetch(`${API_BASE}/api/chat/stream_status/${encodeURIComponent(sid)}`, {
signal: ctrl.signal,
}).catch(() => null);
clearTimeout(timer);
if (statusRes && statusRes.ok) return;
}
} catch (_) {}
await _translateEmail(reader, cfg.email_translate_language || 'English', { auto: true });
} catch (_) {}
if (reader) reader.dataset.autoTranslateChecked = '1';
}
// Keep an email ⋮ dropdown inside the viewport: when it would spill past the
+146 -2
View File
@@ -24,6 +24,131 @@ const MAX_FILES = 10;
const MAX_VISIBLE = 3;
let _expanded = false;
function _isMobileViewport() {
return window.matchMedia && window.matchMedia('(max-width: 768px)').matches;
}
function _isCroppableImage(f) {
const mime = (f?.type || '').toLowerCase();
const name = (f?.name || '').toLowerCase();
if (!(mime.startsWith('image/') || /\.(png|jpe?g|webp|bmp)$/i.test(name))) return false;
return !mime.includes('svg') && !mime.includes('gif') && !/\.svg|\.gif$/i.test(name);
}
function _loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
}
function _canvasToBlob(canvas, type, quality) {
return new Promise((resolve) => canvas.toBlob(resolve, type || 'image/png', quality));
}
async function _openMobileCropper(file) {
const url = _getPreviewUrl(file);
const imgProbe = await _loadImage(url);
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.className = 'attach-crop-overlay';
overlay.innerHTML = `
<div class="attach-crop-panel" role="dialog" aria-modal="true" aria-label="Crop image">
<div class="attach-crop-stage">
<img class="attach-crop-img" alt="">
<div class="attach-crop-box"><span class="attach-crop-handle"></span></div>
</div>
<div class="attach-crop-actions">
<button type="button" class="attach-crop-btn" data-action="cancel">Cancel</button>
<button type="button" class="attach-crop-btn" data-action="original">Original</button>
<button type="button" class="attach-crop-btn attach-crop-primary" data-action="crop">Use crop</button>
</div>
</div>`;
document.body.appendChild(overlay);
const img = overlay.querySelector('.attach-crop-img');
const box = overlay.querySelector('.attach-crop-box');
img.src = url;
img.alt = file.name || 'image';
let crop = { x: 0.08, y: 0.08, w: 0.84, h: 0.84 };
let drag = null;
function applyCrop() {
const r = img.getBoundingClientRect();
const pr = overlay.querySelector('.attach-crop-stage').getBoundingClientRect();
box.style.left = (r.left - pr.left + crop.x * r.width) + 'px';
box.style.top = (r.top - pr.top + crop.y * r.height) + 'px';
box.style.width = (crop.w * r.width) + 'px';
box.style.height = (crop.h * r.height) + 'px';
}
function clampCrop() {
crop.w = Math.max(0.12, Math.min(1, crop.w));
crop.h = Math.max(0.12, Math.min(1, crop.h));
crop.x = Math.max(0, Math.min(1 - crop.w, crop.x));
crop.y = Math.max(0, Math.min(1 - crop.h, crop.y));
}
function finish(value) {
overlay.remove();
window.removeEventListener('resize', applyCrop);
resolve(value);
}
requestAnimationFrame(applyCrop);
img.addEventListener('load', applyCrop);
window.addEventListener('resize', applyCrop);
box.addEventListener('pointerdown', (e) => {
e.preventDefault();
box.setPointerCapture(e.pointerId);
drag = {
mode: e.target.classList.contains('attach-crop-handle') ? 'resize' : 'move',
sx: e.clientX,
sy: e.clientY,
start: { ...crop },
};
});
box.addEventListener('pointermove', (e) => {
if (!drag) return;
const r = img.getBoundingClientRect();
const dx = (e.clientX - drag.sx) / Math.max(1, r.width);
const dy = (e.clientY - drag.sy) / Math.max(1, r.height);
if (drag.mode === 'resize') {
crop.w = drag.start.w + dx;
crop.h = drag.start.h + dy;
} else {
crop.x = drag.start.x + dx;
crop.y = drag.start.y + dy;
}
clampCrop();
applyCrop();
});
box.addEventListener('pointerup', () => { drag = null; });
box.addEventListener('pointercancel', () => { drag = null; });
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => finish(null));
overlay.querySelector('[data-action="original"]').addEventListener('click', () => finish(file));
overlay.querySelector('[data-action="crop"]').addEventListener('click', async () => {
clampCrop();
const canvas = document.createElement('canvas');
const sx = Math.round(crop.x * imgProbe.naturalWidth);
const sy = Math.round(crop.y * imgProbe.naturalHeight);
const sw = Math.max(1, Math.round(crop.w * imgProbe.naturalWidth));
const sh = Math.max(1, Math.round(crop.h * imgProbe.naturalHeight));
canvas.width = sw;
canvas.height = sh;
const ctx = canvas.getContext('2d');
ctx.drawImage(imgProbe, sx, sy, sw, sh, 0, 0, sw, sh);
const type = file.type && file.type !== 'image/bmp' ? file.type : 'image/png';
const blob = await _canvasToBlob(canvas, type, 0.92);
if (!blob) { finish(file); return; }
const ext = type.includes('jpeg') ? 'jpg' : (type.split('/')[1] || 'png');
const base = (file.name || 'image').replace(/\.[^.]+$/, '');
finish(new File([blob], `${base}-cropped.${ext}`, { type, lastModified: Date.now() }));
});
});
}
function _getPreviewUrl(f) {
if (!f) return '';
let url = _previewUrls.get(f);
@@ -231,17 +356,35 @@ export async function uploadPending(opts = {}) {
/**
* Add files to pending list (capped at MAX_FILES)
*/
export function addFiles(files) {
export async function addFiles(files) {
for (const f of files) {
if (pendingFiles.length >= MAX_FILES) {
_showToast(`Max ${MAX_FILES} files allowed`);
break;
}
pendingFiles.push(f);
let nextFile = f;
if (_isMobileViewport() && _isCroppableImage(f)) {
try {
nextFile = await _openMobileCropper(f);
} catch (_) {
nextFile = f;
}
if (!nextFile) continue;
}
pendingFiles.push(nextFile);
}
renderAttachStrip();
}
export async function cropForMobileUpload(file) {
if (!_isMobileViewport() || !_isCroppableImage(file)) return file;
try {
return await _openMobileCropper(file);
} catch (_) {
return file;
}
}
function _showToast(msg) {
if (window.showToast) { window.showToast(msg); return; }
// Fallback inline toast
@@ -326,6 +469,7 @@ const fileHandlerModule = {
removePending,
uploadPending,
addFiles,
cropForMobileUpload,
getPendingCount,
getPendingInfo,
getPendingRaw,
+72 -3
View File
@@ -42,7 +42,7 @@ let _activeModel = null;
let _activeAlbum = null;
let _galleryCascaded = false; // play the domino-in cascade once per open
let _favoritesOnly = false;
let _sort = 'shuffle';
let _sort = 'recent';
let _shuffleSeed = Math.floor(Math.random() * 2 ** 31);
let _offset = 0;
// Page size — computed from the grid's visible area so taller / wider
@@ -1152,8 +1152,14 @@ function _wireUploadTile() {
input.type = 'file';
input.accept = 'image/*,video/*';
input.multiple = true;
input.addEventListener('change', () => {
if (input.files.length) _bulkUpload([...input.files], _activeAlbum);
input.addEventListener('change', async () => {
if (!input.files.length) return;
const files = [];
for (const file of [...input.files]) {
const nextFile = await fileHandlerModule.cropForMobileUpload(file);
if (nextFile) files.push(nextFile);
}
if (files.length) _bulkUpload(files, _activeAlbum);
});
input.click();
});
@@ -2810,6 +2816,68 @@ export function openGallery() {
searchInput.focus();
}
function _showImagesTab() {
const modal = document.getElementById('gallery-modal');
if (!modal) return;
modal.querySelectorAll('.gallery-tab').forEach(t => t.classList.remove('active'));
modal.querySelector('.gallery-tab[data-tab="images"]')?.classList.add('active');
const imagesContainer = document.getElementById('gallery-images-container');
const albumsContainer = document.getElementById('gallery-albums-container');
const editorContainer = document.getElementById('gallery-editor-container');
const settingsContainer = document.getElementById('gallery-settings-container');
if (imagesContainer) imagesContainer.style.display = '';
if (albumsContainer) albumsContainer.style.display = 'none';
if (editorContainer) editorContainer.style.display = 'none';
if (settingsContainer) settingsContainer.style.display = 'none';
}
export async function openGalleryImage(imageId) {
if (!imageId) {
openGallery();
return;
}
openGallery();
_showImagesTab();
_search = '';
_activeTags = [];
_activeModel = null;
_activeAlbum = null;
_favoritesOnly = false;
_sort = 'recent';
const searchInput = document.getElementById('gallery-search');
if (searchInput) searchInput.value = '';
const sortSel = document.getElementById('gallery-sort');
if (sortSel) sortSel.value = 'recent';
const detail = document.getElementById('gallery-detail');
if (detail) detail.style.display = 'none';
try {
await _fetchLibrary(false);
let img = _items.find(i => String(i.id) === String(imageId));
if (!img) {
const res = await fetch(`${API_BASE}/api/gallery/${encodeURIComponent(imageId)}`, { credentials: 'same-origin' });
if (res.ok) {
const data = await res.json();
img = data.image || data;
}
}
if (!img || !img.id) {
uiModule.showToast?.('Photo not found in gallery', 3000);
return;
}
_openDetail(img);
const card = document.querySelector(`.gallery-card[data-id="${CSS.escape(String(imageId))}"]`);
if (card) {
card.scrollIntoView({ block: 'center', behavior: 'smooth' });
card.classList.add('gallery-card-focus');
setTimeout(() => card.classList.remove('gallery-card-focus'), 1600);
}
} catch (err) {
console.error('[gallery] open image failed', err);
uiModule.showToast?.('Could not open photo in gallery', 3000);
}
}
function _doCloseGallery() {
const editorMounted = !!document.querySelector('#gallery-editor-container .gallery-editor');
if ((window.__galleryEditLive || isEditorOpen() || editorMounted) && !window.__galleryAllowCloseEditor) {
@@ -2882,6 +2950,7 @@ function _humanSize(bytes) {
const galleryModule = {
openGallery,
openGalleryImage,
closeGallery,
isGalleryOpen,
};
+4 -1
View File
@@ -6,7 +6,10 @@ import Storage from './storage.js';
function clearFreshComposerRestore() {
const msgInput = document.getElementById('message');
if (!msgInput) return;
const hasSessionTarget = !!(window.location.hash || Storage.get('lastSessionId'));
const hash = window.location.hash || '';
const isEntityHash = /^#(?:document|note|image|email|event|task|skill|research)-/.test(hash)
|| /^#open=notes&note=/.test(hash);
const hasSessionTarget = !!((hash && !isEntityHash) || Storage.get('lastSessionId'));
if (hasSessionTarget) return;
if (msgInput.value) {
msgInput.value = '';
+6
View File
@@ -565,6 +565,12 @@ export function mdToHtml(src, opts) {
new RegExp(`(^|[^\\[(])#(${ANCHOR_KIND}-[A-Za-z0-9_-]+)\\b`, 'g'),
'$1[#$2](#$2)',
);
// Legacy search_chats output used bare session hashes (`#<uuid>`). Upgrade
// those too so old answers and model summaries remain clickable.
s = s.replace(
/(^|[^\[(])#([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gi,
'$1[#session-$2](#session-$2)',
);
// Convert markdown images before links so ![alt](url) does not become
// literal "!" plus a normal link.
+20 -25
View File
@@ -121,7 +121,7 @@ async function _ensureDefaultPendingChat() {
if (!_deps || _defaultChatPickInFlight) return;
if (_deps.getCurrentSessionId && _deps.getCurrentSessionId()) return;
const pending = _deps.getPendingChat && _deps.getPendingChat();
if (pending && pending.modelId) return;
if (pending && pending.modelId && pending.source === 'manual') return;
_defaultChatPickInFlight = true;
try {
await _ensureModelCacheForFallback();
@@ -131,20 +131,26 @@ async function _ensureDefaultPendingChat() {
if (res.ok) dc = await res.json();
} catch (_) {}
if (dc && dc.endpoint_url && dc.model && _modelExists(dc.model, dc.endpoint_url)) {
const pendingUrl = String((pending && pending.url) || '').replace(/\/+$/, '');
const defaultUrl = String(dc.endpoint_url || '').replace(/\/+$/, '');
_deps.setPendingChat({
url: dc.endpoint_url,
modelId: dc.model,
endpointId: dc.endpoint_id || '',
source: 'default',
});
try { window.__odysseusDefaultChat = dc; } catch (_) {}
updateModelPicker();
if (!pending || pending.modelId !== dc.model || pendingUrl !== defaultUrl || pending.source !== 'default') {
updateModelPicker();
}
return;
}
if (pending && pending.modelId) return;
// No configured default, or the configured default is gone/offline:
// preserve the convenience fallback and keep the picker usable.
const fallback = _firstAvailableModel();
if (fallback) {
_deps.setPendingChat(fallback);
_deps.setPendingChat({ ...fallback, source: 'fallback' });
updateModelPicker();
}
} finally {
@@ -564,7 +570,7 @@ function _initModelPickerDropdown() {
}
if (!currentSessionId && _pendingChat) {
// Already have a deferred session — just update the model
_deps.setPendingChat({ url: m.url, modelId: m.mid, endpointId: m.endpointId });
_deps.setPendingChat({ url: m.url, modelId: m.mid, endpointId: m.endpointId, source: 'manual' });
// Header stays as session name — model switch only updates picker
updateModelPicker();
uiModule.showToast(`Using ${m.display}`);
@@ -607,7 +613,7 @@ function _initModelPickerDropdown() {
if ((current && current.model) || (pending && pending.modelId)) return;
if (window.modelsModule && window.modelsModule.refreshModels) {
try { await window.modelsModule.refreshModels(true); } catch (_) {}
try { await window.modelsModule.refreshModels(false); } catch (_) {}
}
const items = window.modelsModule && window.modelsModule.getCachedItems ? window.modelsModule.getCachedItems() : [];
const targetEndpointId = detail.endpointId ? String(detail.endpointId) : '';
@@ -656,12 +662,6 @@ function _initModelPickerDropdown() {
updateModelPicker();
}).catch(() => {});
}
// Kick off a local-endpoint probe — when it returns, re-render
// the list so stale local servers get dimmed. Cloud entries
// aren't probed; they stay visible.
_refreshLocalProbe().then(() => {
if (!menu.classList.contains('hidden')) _populate(search.value || '');
});
if (window.innerWidth >= 768) search.focus();
// Hide scroll button so it doesn't overlap
const _scrollBtn = document.getElementById('scroll-bottom-btn');
@@ -755,18 +755,6 @@ export function updateModelPicker() {
// we have no session model and no pending-chat pick, fall through to
// the "Select model" placeholder below.
//
// But if the server model cache already has an online endpoint, make the
// same safe fallback visible in the picker immediately. The send path can
// already resolve a usable model; the UI should not sit on "Select model"
// and make it look broken.
if (!modelId && !currentSessionId && window.modelsModule && window.modelsModule.getCachedItems) {
const fallback = _firstAvailableModel();
if (fallback) {
_deps.setPendingChat(fallback);
modelId = fallback.modelId;
}
}
// Check if selected model is still available — fall back ONLY for pending chats with no user selection
// Never override an existing session's model — the user explicitly chose it
if (modelId && !currentSessionId && _pendingChat && window.modelsModule && window.modelsModule.getCachedItems) {
@@ -781,11 +769,18 @@ export function updateModelPicker() {
const fallback = items.find(item => !item.offline && (item.models || []).length > 0);
if (fallback) {
modelId = fallback.models[0];
_deps.setPendingChat({ url: fallback.url, modelId, endpointId: fallback.endpoint_id });
_deps.setPendingChat({ url: fallback.url, modelId, endpointId: fallback.endpoint_id, source: 'fallback' });
}
}
}
if (!modelId && !_autoSelectingDefault && window.modelsModule && window.modelsModule.getCachedItems) {
const latestPending = _deps.getPendingChat && _deps.getPendingChat();
if (
!currentSessionId &&
!_autoSelectingDefault &&
window.modelsModule &&
window.modelsModule.getCachedItems &&
(!modelId || (latestPending && latestPending.source === 'fallback'))
) {
_ensureDefaultPendingChat();
}
+18 -9
View File
@@ -17,6 +17,7 @@ let API_BASE = '';
let _cachedItems = []; // cached /api/models items for model-switch dropdown
let _lastFetchTime = 0;
let _fetchInflight = null;
let _fetchSeq = 0;
const _FETCH_CACHE_TTL = 30000; // 30s client-side cache for /api/models
const COLLAPSE_KEY = 'odysseus-models-collapsed';
const FAVORITES_KEY = 'odysseus-model-favorites';
@@ -165,18 +166,21 @@ function _buildModelRow(mid, url, displayName, endpointId, offline, modelType) {
export async function refreshModels(force = false) {
const box = document.getElementById('models');
if (!box) return;
// Skip network fetch if cache is fresh and not forced — still re-render UI
const now = Date.now();
const needsFetch = force || _cachedItems.length === 0 || (now - _lastFetchTime) >= _FETCH_CACHE_TTL;
box.innerHTML = '';
if (box) box.innerHTML = '';
if (needsFetch) {
const _loadingSpinner = spinnerModule.create('', 'right', 'wave');
box.appendChild(_loadingSpinner.createElement());
_loadingSpinner.start();
let _loadingSpinner = null;
if (box) {
_loadingSpinner = spinnerModule.create('', 'right', 'wave');
box.appendChild(_loadingSpinner.createElement());
_loadingSpinner.start();
}
try {
if (force) _fetchInflight = null;
if (!_fetchInflight) {
// Pass ?refresh=true on forced refreshes so the BACKEND's 30s
// per-user cache also gets bypassed. Without this, `force=true`
@@ -184,25 +188,30 @@ export async function refreshModels(force = false) {
// back — newly-served endpoints don't appear until the cache
// ages out. (Bug repro: serve a model, picker is empty for ~30s
// even though the endpoint is in the DB and online.)
const _seq = ++_fetchSeq;
const _url = `${API_BASE}/api/models` + (force ? '?refresh=true' : '?background=false');
_fetchInflight = fetch(_url, { credentials: 'same-origin' })
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
const data = await res.json();
return { data, seq: _seq };
})
.finally(() => { _fetchInflight = null; });
}
const data = await _fetchInflight;
const { data, seq } = await _fetchInflight;
if (seq < _fetchSeq) return;
_lastFetchTime = Date.now();
_cachedItems = data.items || [];
} catch (e) {
console.error(e);
box.textContent = '(scan failed)';
if (box) box.textContent = '(scan failed)';
return;
} finally {
box.innerHTML = '';
try { _loadingSpinner && _loadingSpinner.stop && _loadingSpinner.stop(); } catch (_) {}
if (box) box.innerHTML = '';
}
}
if (!box) return;
try {
const collapseState = _loadCollapsed();
+20 -19
View File
@@ -5322,25 +5322,26 @@ async function _initReminders() {
// just opening the panel when the card isn't found (panel still
// loading, note in a different filter, etc.).
async function openNote(noteId) {
// If the panel is already open, openPanel() short-circuits and does
// nothing — including no re-fetch — so a freshly-created note added
// server-side never shows up. Force a refresh by closing first when
// open, then re-opening. Clicking the sidebar Notes button as a
// last resort keeps this working even if the module state got out
// of sync (rare but seen during HMR or after a stuck modal).
try {
if (isPanelOpen && isPanelOpen()) {
closePanel();
// give the close animation a frame to settle
await new Promise(r => setTimeout(r, 30));
}
} catch (_) {}
openPanel();
// openPanel() kicks off _fetchNotes() asynchronously, so the cards
// for newly-created notes may not be in the DOM yet. Also poll the
// _notes module array directly — if the note IS loaded but the
// active filter (e.g. archive view) is hiding it, we can still
// surface a confirmation toast.
const wasOpen = !!(isPanelOpen && isPanelOpen());
_showingArchived = false;
_activeLabel = null;
_activeFilter = null;
_searchQuery = '';
if (!wasOpen) {
openPanel();
} else {
_bringNotesToFront();
const searchEl = document.getElementById('notes-search');
if (searchEl) searchEl.value = '';
const pane = document.getElementById('notes-pane');
if (pane) pane.classList.remove('notes-pane-archive');
const archiveBtn = document.getElementById('notes-archive-toggle');
if (archiveBtn) archiveBtn.classList.remove('active');
await _fetchNotes();
_renderNotes();
}
// openPanel() kicks off _fetchNotes() asynchronously, so the cards for
// newly-created notes may not be in the DOM yet. Poll until the card exists.
if (!noteId) return;
let tries = 0;
const findAndFlash = () => {
+53 -62
View File
@@ -3,7 +3,6 @@
import Storage from './storage.js';
import uiModule, { autoResize, styledPrompt } from './ui.js';
import markdownModule from './markdown.js';
import chatRenderer from './chatRenderer.js';
import { providerLogo } from './providers.js';
import { initModelPicker, updateModelPicker } from './modelPicker.js';
@@ -110,6 +109,29 @@ function _historyUrl(id, { limit = null, offset = null } = {}) {
return url.toString();
}
function _addHistoryMessageWithFullRenderer(role, content, modelName, meta) {
const box = document.getElementById('chat-history');
if (!box) return [];
const marker = document.createComment('history-message');
box.appendChild(marker);
let rendered = null;
try {
rendered = chatRenderer.addMessage(role, content, modelName, meta);
} catch (e) {
marker.remove();
throw e;
}
const nodes = [];
let node = marker.nextSibling;
while (node) {
const next = node.nextSibling;
nodes.push(node);
node = next;
}
marker.remove();
return nodes.length ? nodes : (rendered ? [rendered] : []);
}
function _renderHistoryMessage(msg, modelName) {
const meta = msg.metadata ? { ...msg.metadata, _fromHistory: true } : null;
let displayContent;
@@ -137,54 +159,7 @@ function _renderHistoryMessage(msg, modelName) {
displayContent = `[Doc edit: ${docEditMatch[1]}] ${docEditMatch[3]}`;
}
}
const box = document.getElementById('chat-history');
if (!box) return null;
if (chatRenderer.hideWelcomeScreen) chatRenderer.hideWelcomeScreen();
const wrap = document.createElement('div');
wrap.className = 'msg ' + (msg.role === 'user' ? 'msg-user' : 'msg-ai');
wrap.dataset.raw = displayContent;
if (meta?._db_id) wrap.dataset.dbId = meta._db_id;
const roleEl = document.createElement('div');
roleEl.className = 'role';
if (msg.role === 'user') {
roleEl.textContent = 'You';
} else {
const pair = chatRenderer.replyModelPair ? chatRenderer.replyModelPair(modelName, meta) : {};
const resolved = pair.actualModel || pair.requestedModel || modelName;
roleEl.textContent = chatRenderer.modelRouteLabel
? chatRenderer.modelRouteLabel(pair.requestedModel, resolved)
: (resolved || 'Odysseus');
if (chatRenderer.applyModelColor) chatRenderer.applyModelColor(roleEl, resolved);
}
const timestamp = meta?.timestamp;
if (timestamp) {
const ts = document.createElement('span');
ts.className = 'msg-time';
try {
ts.textContent = new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch {
ts.textContent = '';
}
roleEl.appendChild(ts);
}
const body = document.createElement('div');
body.className = 'body';
body.innerHTML = markdownModule.processWithThinking(
markdownModule.squashOutsideCode(markdownModule.renderContent(displayContent || ''))
);
if (msg.role === 'user' && Array.isArray(meta?.attachments) && meta.attachments.length) {
if (chatRenderer.buildAttachCards) {
body.appendChild(chatRenderer.buildAttachCards(meta.attachments));
}
}
wrap.appendChild(roleEl);
wrap.appendChild(body);
box.appendChild(wrap);
return wrap;
return _addHistoryMessageWithFullRenderer(msg.role, displayContent, modelName, meta);
}
function _clearHistoryPager() {
@@ -232,8 +207,8 @@ function _installHistoryPager(id, pageInfo, modelName) {
const newEls = [];
for (const msg of data.history || []) {
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
const el = _renderHistoryMessage(msg, _historyPager.modelName);
if (el) newEls.push(el);
const els = _renderHistoryMessage(msg, _historyPager.modelName);
if (Array.isArray(els)) newEls.push(...els);
}
for (const el of newEls) {
box.insertBefore(el, anchor || box.firstChild);
@@ -1661,7 +1636,10 @@ export async function loadSessions() {
// most recently appended a message.
const _isTransient = (s) => !!s && (s.folder === 'Assistant' || s.folder === 'Tasks');
const _realSessions = activeSessions.filter(s => !_isTransient(s));
const hashId = window.location.hash.replace('#', '');
let hashId = window.location.hash.replace('#', '');
if (/^(document|note|image|email|event|task|skill|research)-/.test(hashId) || /^open=notes&note=/.test(hashId)) {
hashId = '';
}
let savedId = Storage.get('lastSessionId');
// If the persisted lastSessionId points to a transient session (legacy
// state from before the persistence-guard was added), drop it.
@@ -1780,7 +1758,7 @@ export async function loadSessions() {
}
}
export async function selectSession(id, { keepSidebar = false, showLoading = true } = {}) {
export async function selectSession(id, { keepSidebar = false, showLoading = true, immediateLoading = false } = {}) {
// Exit compare mode cleanly if active
if (window.compareModule && window.compareModule.isActive()) {
window.compareModule.deactivate(true);
@@ -1898,7 +1876,7 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
let loadingPaintReady = Promise.resolve();
if (!isOC) {
if (showLoading && chatHistory && prevSessionId !== id) {
const loadingDelayMs = window.innerWidth <= 768 ? 900 : 500;
const loadingDelayMs = immediateLoading ? 0 : (window.innerWidth <= 768 ? 900 : 500);
loadingTimer = setTimeout(() => {
if (navToken !== _sessionNavToken || currentSessionId !== id) return;
_paintSessionLoading(chatHistory, 'Loading chat');
@@ -1990,8 +1968,13 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
}
} else {
if (window.chatModule && window.chatModule.showWelcomeScreen) window.chatModule.showWelcomeScreen();
// Don't highlight empty sessions — feels like nothing is selected
document.querySelectorAll('.list-item.active-session').forEach(el => el.classList.remove('active-session'));
// Don't highlight ordinary empty sessions — feels like nothing is
// selected. Keep document/email-scoped sessions highlighted though: a
// new email/reply chat starts empty but immediately owns an email doc.
const isDocScopedEmptySession = !!(meta && (meta.has_documents || /^Email:|^New Email$/i.test(meta.name || '')));
if (!isDocScopedEmptySession) {
document.querySelectorAll('.list-item.active-session').forEach(el => el.classList.remove('active-session'));
}
}
uiModule.scrollHistoryInstant();
if (!isOC && msgHistory.length) {
@@ -2078,9 +2061,16 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
}
uiModule.showError('Failed to load session: ' + error.message);
} finally {
// Ensure memories are loaded after session selection
// Memory warmup must not block chat switching. The memories panel can load
// on demand; this is only a delayed cache refresh when the foreground chat
// is idle.
if (window.memoryModule && window.memoryModule.loadMemories) {
await window.memoryModule.loadMemories();
setTimeout(() => {
const busy = !!window.__odysseusChatBusy
|| Date.now() < (window.__odysseusChatBusyUntil || 0)
|| !!document.querySelector('.send-btn[data-mode="streaming"], .send-btn.send-pending');
if (!busy) window.memoryModule.loadMemories().catch(() => {});
}, 2500);
}
// Auto-focus message input (unless session list has keyboard focus).
// Skip on mobile — focusing the textarea pops up the on-screen keyboard,
@@ -2208,10 +2198,11 @@ export async function materializePendingSession() {
Storage.set('lastSessionId', payload.id);
history.replaceState(null, '', '#' + payload.id);
// Reload sidebar to show the new session — await it so the session
// is fully registered before the caller proceeds (prevents race conditions)
// Reload the sidebar in the background. Awaiting this used to block the first
// prompt in a new/pending chat behind startup fetches and slow /api/sessions
// calls, so the user's message could sit for 20s+ before streaming began.
_suppressNextSessionLoading = true;
await loadSessions().catch(() => {});
loadSessions().catch(() => {});
return true;
}
@@ -2345,7 +2336,7 @@ export function initDragSort() {
// session navigation (which would reset the active chat).
window.addEventListener('hashchange', () => {
const hashId = window.location.hash.replace('#', '');
if (/^(document|note|image|email|event|task|skill|research)-/.test(hashId)) return;
if (/^(document|note|image|email|event|task|skill|research)-/.test(hashId) || /^open=notes&note=/.test(hashId)) return;
if (hashId && hashId !== currentSessionId) {
const target = sessions.find(s => s.id === hashId && !s.archived);
if (target) selectSession(hashId);
+29 -16
View File
@@ -3105,6 +3105,28 @@ async function initEmailSettings() {
const root = el('settings-modal');
if (!root || !root.querySelector('[data-settings-panel="email"]')) return;
const styleKey = 'odysseus-email-writing-style';
const styleEl = el('set-email-style');
// The account/CardDAV config endpoints can be slow when remote mail servers
// are cold. Populate the Writing Style box independently so saved prose does
// not appear seconds after the panel opens.
try {
const cachedStyle = localStorage.getItem(styleKey);
if (styleEl && cachedStyle !== null && !styleEl.value) styleEl.value = cachedStyle;
} catch (_) {}
const loadWritingStyle = async () => {
try {
const res = await fetch('/api/email/style');
const data = await res.json();
const style = data.style || '';
if (styleEl) styleEl.value = style;
try { localStorage.setItem(styleKey, style); } catch (_) {}
} catch (_) {}
};
loadWritingStyle();
// Load current email config
try {
const res = await fetch('/api/email/config');
@@ -3118,8 +3140,6 @@ async function initEmailSettings() {
if (el('set-email-smtp-user')) el('set-email-smtp-user').value = cfg.smtp_user || '';
if (el('set-email-smtp-pass')) el('set-email-smtp-pass').value = '';
if (el('set-email-from')) el('set-email-from').value = cfg.from_address || '';
if (el('set-email-auto-translate')) el('set-email-auto-translate').checked = !!cfg.email_auto_translate;
if (el('set-email-translate-language')) el('set-email-translate-language').value = cfg.email_translate_language || 'English';
} catch (_) {}
// Load contacts config
@@ -3131,13 +3151,6 @@ async function initEmailSettings() {
if (el('set-carddav-pass')) el('set-carddav-pass').value = '';
} catch (_) {}
// Load writing style
try {
const res = await fetch('/api/email/style');
const data = await res.json();
if (el('set-email-style')) el('set-email-style').value = data.style || '';
} catch (_) {}
// Save email config
el('set-email-save')?.addEventListener('click', async () => {
const msg = el('set-email-msg');
@@ -3150,8 +3163,6 @@ async function initEmailSettings() {
smtp_port: parseInt(el('set-email-smtp-port').value) || 0,
smtp_user: el('set-email-smtp-user').value,
email_from: el('set-email-from').value,
email_auto_translate: !!el('set-email-auto-translate')?.checked,
email_translate_language: (el('set-email-translate-language')?.value || 'English').trim() || 'English',
};
const imapPass = el('set-email-imap-pass').value;
const smtpPass = el('set-email-smtp-pass').value;
@@ -3165,10 +3176,7 @@ async function initEmailSettings() {
});
const result = await res.json();
if (msg) msg.textContent = result.success ? '✓ Saved' : (result.error || 'Failed');
const translateMsg = el('set-email-translate-msg');
if (translateMsg) translateMsg.textContent = result.success ? '✓ Saved' : (result.error || 'Failed');
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
setTimeout(() => { const translateMsg = el('set-email-translate-msg'); if (translateMsg) translateMsg.textContent = ''; }, 3000);
} catch (e) {
if (msg) msg.textContent = 'Failed';
}
@@ -3233,7 +3241,8 @@ async function initEmailSettings() {
});
const data = await res.json();
if (data.success && data.style) {
if (el('set-email-style')) el('set-email-style').value = data.style;
if (styleEl) styleEl.value = data.style;
try { localStorage.setItem(styleKey, data.style); } catch (_) {}
if (msg) msg.textContent = '✓ Style extracted';
} else {
if (msg) msg.textContent = data.error || 'Failed';
@@ -3252,12 +3261,16 @@ async function initEmailSettings() {
const msg = el('set-email-style-msg');
if (msg) msg.textContent = 'Saving...';
try {
const style = styleEl ? styleEl.value : '';
const res = await fetch('/api/email/style', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ style: el('set-email-style').value }),
body: JSON.stringify({ style }),
});
const result = await res.json();
if (result.success) {
try { localStorage.setItem(styleKey, style); } catch (_) {}
}
if (msg) msg.textContent = result.success ? '✓ Saved' : 'Failed';
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
} catch (e) {
+4 -3
View File
@@ -92,10 +92,11 @@ export function initSidebarLayout(Storage, opts) {
});
}
// New chat buttons — same as clicking brand
// Header-only new-chat aliases. #sidebar-new-chat-btn is wired in app.js
// because it needs the full default-model/pending-chat flow; wiring it here
// as well caused duplicate click handling and occasional no-op/race behavior.
const chatNewBtn = document.getElementById('chat-new-btn');
const sidebarNewChat = document.getElementById('sidebar-new-chat-btn');
[chatNewBtn, sidebarNewChat].forEach(btn => {
[chatNewBtn].forEach(btn => {
if (btn) btn.addEventListener('click', () => {
const brandBtn = document.getElementById('sidebar-brand-btn');
if (brandBtn) brandBtn.click();
+595 -11
View File
@@ -3579,6 +3579,10 @@ body.bg-pattern-sparkles {
display: inline-flex; align-items: center; justify-content: center;
}
.footer-copy-btn:hover { color:var(--accent); }
.footer-open-gallery-btn {
gap: 4px;
font-size: 11px;
}
/* Delete action same chrome as copy/download/edit but hover reveals
the destructive red tint so the user can tell it's not a benign op. */
.footer-delete-btn:hover { color: var(--red); }
@@ -4135,7 +4139,7 @@ body.bg-pattern-sparkles {
to { stroke-dashoffset: 0; }
}
.toast-close-btn {
margin-left: 8px;
margin-left: auto;
padding: 0;
width: 22px;
height: 22px;
@@ -8168,6 +8172,11 @@ button.hamburger {
.msg a:hover {
border-bottom-color: var(--hl-function, #5b8def);
}
.msg a.is-loading {
opacity: 0.65;
border-bottom-color: var(--accent, #bd93f9);
cursor: progress;
}
/* Tables */
.msg table {
@@ -8675,6 +8684,98 @@ button.hamburger {
.thumb.thumb-image button:active {
transform: scale(0.96);
}
.attach-crop-overlay {
position: fixed;
inset: 0;
z-index: 100000;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: center;
justify-content: center;
padding: 14px;
box-sizing: border-box;
}
.attach-crop-panel {
width: min(94vw, 520px);
max-height: min(86vh, 720px);
background: var(--panel, #111);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 18px 50px rgba(0,0,0,0.45);
overflow: hidden;
display: flex;
flex-direction: column;
}
.attach-crop-stage {
position: relative;
min-height: 280px;
height: min(66vh, 560px);
background: #050505;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
touch-action: none;
}
.attach-crop-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
user-select: none;
pointer-events: none;
}
.attach-crop-box {
position: absolute;
border: 2px solid var(--accent, var(--red));
box-shadow: 0 0 0 9999px rgba(0,0,0,0.42);
cursor: move;
touch-action: none;
box-sizing: border-box;
}
.attach-crop-box::before,
.attach-crop-box::after {
content: '';
position: absolute;
inset: 33.333% 0 auto;
border-top: 1px solid rgba(255,255,255,0.38);
}
.attach-crop-box::after {
inset: 66.666% 0 auto;
}
.attach-crop-handle {
position: absolute;
right: -9px;
bottom: -9px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--accent, var(--red));
border: 2px solid var(--bg);
box-sizing: border-box;
cursor: nwse-resize;
}
.attach-crop-actions {
display: flex;
gap: 8px;
padding: 10px;
background: color-mix(in srgb, var(--bg) 78%, transparent);
}
.attach-crop-btn {
flex: 1;
height: 34px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg);
color: var(--fg);
font: inherit;
font-size: 12px;
}
.attach-crop-primary {
background: var(--accent, var(--red));
color: #fff;
border-color: var(--accent, var(--red));
}
@media (max-width: 768px) {
/* Collapsed "N files" badge: use the same corner-X accent badge as image thumbs. */
.thumb-collapsed { position: relative; }
@@ -18075,6 +18176,10 @@ body.gallery-selecting .gallery-dl-btn,
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
}
.gallery-card.gallery-card-focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 36%, transparent);
}
/* Upload-affordance tile pinned to the top-left of the Photos grid. Matches
the Upload album tile in the Albums tab dashed border, centered icon. */
.gallery-card-upload {
@@ -19067,6 +19172,14 @@ body.gallery-selecting .gallery-dl-btn,
}
.cookbook-field-input:focus { border-color: var(--red); }
#hwfit-dl-server, #hwfit-usecase, #hwfit-server-select, #hwfit-cache-server, #serve-sort { height: 28px; width: 88px; }
#hwfit-dl-server.cookbook-server-select-colored,
#hwfit-server-select.cookbook-server-select-colored,
#hwfit-cache-server.cookbook-server-select-colored,
#hwfit-deps-server.cookbook-server-select-colored {
color: var(--cookbook-server-color, var(--fg));
border-color: color-mix(in srgb, var(--cookbook-server-color) 55%, var(--border));
background-color: color-mix(in srgb, var(--cookbook-server-color) 12%, var(--bg));
}
/* Serve tab match Documents sizing, but leave room for the active "Cancel"
label and center it vertically so the descenders don't clip. */
#hwfit-cache-select {
@@ -19723,6 +19836,25 @@ body.gallery-selecting .gallery-dl-btn,
background: color-mix(in srgb, var(--fg) 9%, transparent);
border-color: color-mix(in srgb, var(--fg) 22%, transparent);
}
.cookbook-section-header.has-server-color {
color: var(--fg);
border-color: color-mix(in srgb, var(--cookbook-server-accent, var(--cookbook-server-color)) 45%, var(--border));
background: color-mix(in srgb, var(--cookbook-server-accent, var(--cookbook-server-color)) 4%, var(--bg));
box-shadow: inset 6px 0 0 var(--cookbook-server-accent, var(--cookbook-server-color));
}
.cookbook-section-header.has-server-color:hover {
background: color-mix(in srgb, var(--cookbook-server-accent, var(--cookbook-server-color)) 7%, var(--bg));
border-color: color-mix(in srgb, var(--cookbook-server-accent, var(--cookbook-server-color)) 60%, var(--border));
}
.cookbook-section-header.has-server-color .cookbook-section-title,
.cookbook-section-header.has-server-color .cookbook-section-chevron {
color: var(--cookbook-server-accent, var(--cookbook-server-color));
}
.cookbook-section-header.has-server-color .cookbook-srv-status.ok {
background: var(--cookbook-server-accent, var(--cookbook-server-color));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--cookbook-server-accent, var(--cookbook-server-color)) 18%, transparent),
0 0 8px color-mix(in srgb, var(--cookbook-server-accent, var(--cookbook-server-color)) 55%, transparent);
}
.cookbook-section-header .cookbook-section-title {
flex: 1;
}
@@ -22152,7 +22284,7 @@ body.gallery-selecting .gallery-dl-btn,
margin-bottom: 8px;
padding: 8px 10px;
border: 1px solid var(--border);
border-left: 3px solid color-mix(in srgb, var(--fg) 30%, transparent);
border-left: 3px solid var(--cookbook-server-color, color-mix(in srgb, var(--fg) 30%, transparent));
border-radius: 8px;
background: color-mix(in srgb, var(--fg) 3%, var(--bg));
box-sizing: border-box;
@@ -22165,6 +22297,113 @@ body.gallery-selecting .gallery-dl-btn,
.cookbook-server-row .cookbook-srv-path { flex: 1 1 100% !important; width: auto !important; min-width: 0 !important; }
}
.cookbook-server-row .cookbook-srv-name { width: 60px; flex-shrink: 0; flex-grow: 0; }
.cookbook-server-row .cookbook-srv-color-wrap {
width: 86px;
flex-shrink: 0;
flex-grow: 0;
display: inline-flex;
align-items: center;
position: relative;
color: var(--cookbook-server-color, var(--fg-muted));
}
.cookbook-server-row select.cookbook-srv-color {
display: none !important;
}
.cookbook-server-row .cookbook-srv-color-dot {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
opacity: 0.35;
z-index: 1;
}
.cookbook-server-row .cookbook-srv-color-wrap.has-color .cookbook-srv-color-dot {
opacity: 0.8;
}
.cookbook-server-row .cookbook-srv-color-btn {
width: 100%;
padding-left: 22px !important;
padding-right: 18px !important;
position: relative;
text-align: left;
cursor: pointer;
overflow: hidden;
}
.cookbook-server-row .cookbook-srv-color-label {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cookbook-server-row .cookbook-srv-color-caret {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
opacity: 0.65;
}
.cookbook-server-row .cookbook-srv-color-wrap.has-color .cookbook-srv-color-btn {
color: var(--fg);
border-color: color-mix(in srgb, var(--cookbook-server-color, var(--border)) 55%, var(--border));
background-color: color-mix(in srgb, var(--cookbook-server-color, var(--fg)) 14%, var(--bg));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--cookbook-server-color, var(--border)) 18%, transparent);
}
.cookbook-server-row .cookbook-srv-color-wrap.has-color .cookbook-srv-color-dot {
background: var(--cookbook-server-color, currentColor);
}
.cookbook-server-row .cookbook-srv-color-wrap.has-color .cookbook-srv-color-caret {
color: var(--fg-muted);
}
.cookbook-srv-color-menu {
position: absolute;
z-index: 5000;
top: calc(100% + 4px);
left: 0;
width: 138px;
padding: 4px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
box-shadow: 0 8px 24px color-mix(in srgb, #000 35%, transparent);
}
.cookbook-srv-color-menu.hidden {
display: none;
}
.cookbook-srv-color-item {
width: 100%;
height: 24px;
border: 1px solid transparent;
border-radius: 4px;
background: color-mix(in srgb, var(--swatch-color, var(--fg)) 10%, transparent);
color: var(--fg);
display: flex;
align-items: center;
gap: 7px;
padding: 0 7px;
font: inherit;
font-size: 11px;
cursor: pointer;
text-align: left;
}
.cookbook-srv-color-item:hover,
.cookbook-srv-color-item.active {
border-color: color-mix(in srgb, var(--swatch-color, var(--accent, var(--red))) 55%, var(--border));
background: color-mix(in srgb, var(--swatch-color, var(--accent, var(--red))) 18%, var(--bg));
}
.cookbook-srv-color-item-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--swatch-color, var(--fg-muted));
border: 1px solid color-mix(in srgb, var(--fg) 18%, transparent);
flex-shrink: 0;
}
.cookbook-server-row .cookbook-srv-host { flex: 1; min-width: 100px; }
.cookbook-server-row .cookbook-srv-host[readonly] { opacity: 0.4; cursor: default; }
.cookbook-server-row .cookbook-srv-port { width: 40px; flex-shrink: 0; flex-grow: 0; }
@@ -24203,6 +24442,36 @@ input.settings-select::placeholder { color: color-mix(in srgb, var(--fg) 35%, tr
border-color: var(--red);
}
@media (max-width: 768px) {
/* Deep Research settings contain desktop-sized inline widths. On mobile,
let each control claim the card width instead of escaping the admin block. */
.settings-row:has(#set-researchSearch),
.settings-row:has(#set-researchMaxTokens),
.settings-row:has(#set-researchExtractTimeout),
.settings-row:has(#set-researchExtractConcurrency),
.settings-row:has(#set-researchRunTimeout) {
flex-wrap: wrap;
align-items: stretch;
}
.settings-row:has(#set-researchSearch) .settings-label,
.settings-row:has(#set-researchMaxTokens) .settings-label,
.settings-row:has(#set-researchExtractTimeout) .settings-label,
.settings-row:has(#set-researchExtractConcurrency) .settings-label,
.settings-row:has(#set-researchRunTimeout) .settings-label {
flex: 1 1 auto;
}
#set-researchSearch,
#set-researchMaxTokens,
#set-researchRunTimeout,
.settings-row div:has(> #set-researchExtractTimeout),
.settings-row div:has(> #set-researchExtractConcurrency) {
width: 100% !important;
max-width: 100% !important;
flex: 1 1 100% !important;
margin-left: 0 !important;
}
}
/* Default-chat fallback chain editor. Each row mirrors the primary
endpoint/model selectors, indented under them to read as a chain. */
.settings-fallbacks {
@@ -24741,8 +25010,17 @@ details.hwfit-serve-advanced > .hwfit-serve-checks:last-of-type {
}
#hwfit-cache-scan {
position: relative;
top: 1px;
width: 51px;
top: -1px;
width: 26px;
height: 26px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
#hwfit-cache-scan.spinning svg {
animation: modelPickerRefreshSpin 0.75s linear infinite;
}
#serve-search {
height: 28px;
@@ -31662,6 +31940,34 @@ body.doc-find-active mark.doc-find-mark.current {
.email-filter-chip-clear:hover { opacity: 1; }
.email-date { font-size: 10px; opacity: 0.5; white-space: nowrap; flex-shrink: 0; }
.email-subject { font-size: 11px; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
.email-sent-chip,
.email-folder-chip {
display: inline-flex;
align-items: center;
height: 14px;
padding: 0 5px;
margin-right: 5px;
border-radius: 999px;
font-size: 8px;
line-height: 1;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0;
color: var(--accent, var(--red));
background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent);
border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 28%, transparent);
vertical-align: 1px;
}
.email-folder-chip {
color: color-mix(in srgb, var(--fg) 75%, transparent);
background: rgba(127, 127, 127, 0.13);
border-color: rgba(127, 127, 127, 0.24);
}
@media (max-width: 768px) {
.email-search-row {
flex-wrap: wrap;
}
}
.email-tags { display: inline-flex; gap: 3px; margin-left: 6px; vertical-align: middle; position: relative; top: -2px; }
.email-card-expanded .email-tags,
.email-card-reader .email-tags { top: -4px; }
@@ -32418,23 +32724,269 @@ body.doc-find-active mark.doc-find-mark.current {
/* Compose attachment chips (when sending new email) */
.email-compose-atts {
display: flex; flex-wrap: wrap; gap: 6px;
padding: 6px 0 0 58px;
display: flex; flex-direction: row; flex-wrap: wrap; align-items: center; gap: 6px;
padding: 6px 0 0 0;
}
.email-compose-chip {
display: inline-flex; align-items: center; gap: 4px;
flex: 0 1 auto;
min-width: 0;
max-width: min(240px, 100%);
padding: 4px 4px 4px 8px; font-size: 11px;
background: var(--hover-bg, rgba(255,255,255,0.05));
border: 1px solid var(--border); border-radius: 12px; color: var(--fg);
}
.email-compose-chip .compose-chip-name { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.email-compose-chip .att-size { opacity: 0.5; font-size: 10px; }
.email-compose-chip .compose-chip-name { max-width: 180px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.email-compose-chip .att-size { opacity: 0.5; font-size: 10px; flex-shrink: 0; }
.email-compose-chip .compose-chip-remove {
background: none; border: none; color: var(--fg); opacity: 0.5;
font-size: 16px; line-height: 1; padding: 0 4px; cursor: pointer;
flex-shrink: 0;
}
.email-compose-chip .compose-chip-remove:hover { opacity: 1; color: var(--red, #e55); }
.email-odysseus-attach-menu {
position: fixed;
z-index: 10050;
width: 300px;
max-width: calc(100vw - 16px);
max-height: min(420px, calc(100vh - 16px));
padding: 7px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg);
color: var(--fg);
box-shadow: 0 12px 30px rgba(0,0,0,0.28);
overflow: hidden;
}
.email-odysseus-attach-local {
width: 100%;
height: 30px;
display: flex;
align-items: center;
gap: 7px;
padding: 0 9px;
border: 1px solid color-mix(in srgb, var(--accent-primary, var(--red)) 35%, var(--border));
border-radius: 5px;
background: color-mix(in srgb, var(--accent-primary, var(--red)) 9%, transparent);
color: var(--fg);
font: inherit;
font-size: 11px;
cursor: pointer;
}
.email-odysseus-attach-local:hover {
background: color-mix(in srgb, var(--accent-primary, var(--red)) 16%, transparent);
border-color: color-mix(in srgb, var(--accent-primary, var(--red)) 55%, var(--border));
}
.email-odysseus-attach-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 5px;
margin: 7px 0;
}
.email-odysseus-attach-tabs button {
height: 26px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid var(--border);
border-radius: 5px;
background: transparent;
color: var(--fg);
opacity: 0.72;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.email-odysseus-attach-tabs button.active {
opacity: 1;
color: var(--accent-primary, var(--red));
border-color: color-mix(in srgb, var(--accent-primary, var(--red)) 45%, var(--border));
background: color-mix(in srgb, var(--accent-primary, var(--red)) 8%, transparent);
}
.email-odysseus-attach-tabs button svg {
flex-shrink: 0;
}
.email-odysseus-attach-search-wrap {
height: 28px;
display: flex;
align-items: center;
gap: 6px;
margin: -1px 0 7px;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 5px;
background: color-mix(in srgb, var(--fg) 3%, transparent);
color: color-mix(in srgb, var(--fg) 48%, transparent);
}
.email-odysseus-attach-search-wrap:focus-within {
border-color: color-mix(in srgb, var(--accent-primary, var(--red)) 45%, var(--border));
color: var(--accent-primary, var(--red));
background: color-mix(in srgb, var(--accent-primary, var(--red)) 6%, transparent);
}
.email-odysseus-attach-search {
min-width: 0;
flex: 1;
border: 0;
outline: 0;
background: transparent;
color: var(--fg);
font: inherit;
font-size: 11px;
}
.email-odysseus-attach-search::placeholder {
color: color-mix(in srgb, var(--fg) 38%, transparent);
}
.email-odysseus-attach-list {
max-height: 320px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 4px;
}
.email-odysseus-attach-row {
width: 100%;
min-height: 38px;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 7px;
border: 1px solid transparent;
border-radius: 5px;
background: transparent;
color: var(--fg);
text-align: left;
font: inherit;
cursor: pointer;
}
.email-odysseus-attach-row:hover {
background: var(--hover-bg, rgba(255,255,255,0.06));
border-color: var(--border);
}
.email-odysseus-attach-row.is-selected {
background: color-mix(in srgb, var(--accent-primary, var(--red)) 10%, transparent);
border-color: color-mix(in srgb, var(--accent-primary, var(--red)) 45%, var(--border));
}
.email-odysseus-attach-dot {
width: 8px;
height: 8px;
flex: 0 0 auto;
display: inline-block;
border: 1px solid var(--border);
border-radius: 50%;
background: color-mix(in srgb, var(--fg) 4%, transparent);
}
.email-odysseus-attach-row.is-selected .email-odysseus-attach-dot {
border-color: var(--accent-primary, var(--red));
background: var(--accent-primary, var(--red));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-primary, var(--red)) 18%, transparent);
}
.email-odysseus-attach-icon,
.email-odysseus-attach-thumb {
width: 28px;
height: 28px;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background: color-mix(in srgb, var(--fg) 7%, transparent);
overflow: hidden;
}
.email-odysseus-attach-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.email-odysseus-attach-main {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.email-odysseus-attach-title,
.email-odysseus-attach-meta {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.email-odysseus-attach-title {
font-size: 11px;
line-height: 1.2;
}
.email-odysseus-attach-meta {
font-size: 10px;
line-height: 1.15;
opacity: 0.52;
}
.email-odysseus-attach-empty {
padding: 14px 8px;
color: var(--muted, #888);
font-size: 11px;
text-align: center;
}
.email-odysseus-attach-actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 7px;
padding-top: 7px;
border-top: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
background: var(--bg);
position: sticky;
bottom: 0;
}
.email-odysseus-attach-count {
flex: 1;
min-width: 0;
color: var(--muted, #888);
font-size: 11px;
}
.email-odysseus-attach-selected {
height: 28px;
min-width: 72px;
padding: 0 12px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--accent-primary, var(--red)) 45%, var(--border));
background: var(--accent-primary, var(--red));
color: #fff;
font: inherit;
font-size: 11px;
cursor: pointer;
}
.email-odysseus-attach-selected svg {
flex-shrink: 0;
}
.email-odysseus-attach-selected:disabled {
opacity: 0.45;
cursor: default;
}
.email-odysseus-attach-selected.is-loading {
opacity: 0.72;
pointer-events: none;
}
@media (max-width: 768px) {
.email-odysseus-attach-menu {
left: 8px !important;
right: 8px;
bottom: 8px;
top: auto !important;
width: auto;
max-height: 58vh;
}
.email-odysseus-attach-list {
max-height: calc(58vh - 88px);
}
}
.email-cc-toggle {
background: none; border: none; color: var(--fg);
opacity: 0.4; font-size: 11px; cursor: pointer;
@@ -32472,8 +33024,12 @@ body.doc-find-active mark.doc-find-mark.current {
border-bottom: 1px solid var(--border);
}
.contact-suggestion:last-child { border-bottom: none; }
.contact-suggestion:hover, .contact-suggestion.active {
background: color-mix(in srgb, var(--accent) 15%, transparent);
.contact-suggestion:hover,
.contact-suggestion.active,
.contact-suggestion[aria-selected="true"] {
background: color-mix(in srgb, var(--accent-primary, var(--accent, var(--red))) 15%, transparent);
outline: 1px solid color-mix(in srgb, var(--accent-primary, var(--accent, var(--red))) 40%, transparent);
outline-offset: -1px;
}
.contact-suggestion .contact-name { font-weight: 600; color: var(--fg); }
.contact-suggestion .contact-email { opacity: 0.6; font-size: 11px; }
@@ -36294,7 +36850,15 @@ button.cal-add-btn.cal-add-btn-text {
transition: background 0.15s, border-color 0.15s;
}
/* Settings "+ Add server" matches the model-dir "+ Add" path button (22px). */
#cookbook-server-add.cal-add-btn-text { height: 21px; border-radius: 11px; position: relative; top: 3px; }
#cookbook-server-add.cal-add-btn-text {
height: 28px;
min-width: 62px;
border-radius: 14px;
padding: 0 12px 0 8px;
position: relative;
top: 1px;
font-size: 12px;
}
button.cal-add-btn.cal-add-btn-text:hover {
background: color-mix(in srgb, var(--fg) 14%, transparent);
border-color: var(--accent);
@@ -39245,6 +39809,26 @@ body.theme-frosted .modal {
/* Nudge the Delete button 4px left. */
.research-job-action[data-action="delete"] { position: relative; right: 2px; }
@media (max-width: 768px) {
/* Mobile: the "Delete" label is too wide in Deep Research job rows; keep the
familiar trash action but make it icon-only. */
.research-job-action[data-action="delete"] {
width: 28px;
min-width: 28px;
padding-left: 0;
padding-right: 0;
gap: 0;
justify-content: center;
font-size: 0;
}
.research-job-action[data-action="delete"] svg {
width: 13px;
height: 13px;
margin: 0;
flex-shrink: 0;
}
}
/* "Standard" (uncategorized) research uses green everywhere set its --cat-color
to the success green so the Visual Report button matches the green badge. */
.research-job-card.done:not([data-category]) { --cat-color: var(--color-success); }
+1 -1
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v336';
const CACHE_NAME = 'odysseus-v344';
// Core shell precached on install so repeat opens are instant without any
// network wait. Keep this list in sync with the <script type="module"> tags