From 265f0911d5298d6c397bda046fa7227e8f5be5ec Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Wed, 1 Jul 2026 14:23:42 +0000 Subject: [PATCH 01/12] Support mobile enter for queued agent prompts --- static/app.js | 148 ++++++++++++++++++++++++++++++++++++++++++---- static/js/chat.js | 46 +++++++++----- 2 files changed, 169 insertions(+), 25 deletions(-) diff --git a/static/app.js b/static/app.js index 927ee8b28..c4a733217 100644 --- a/static/app.js +++ b/static/app.js @@ -53,6 +53,69 @@ window.uiModule = uiModule; window.adminModule = adminModule; window.cookbookModule = cookbookModule; +function _isMobileChatInput() { + return window.innerWidth <= 768 || (window.matchMedia && window.matchMedia('(pointer: coarse)').matches); +} + +function _isForegroundChatBusy() { + const sendBtn = document.querySelector('.send-btn'); + return !!window.__odysseusChatBusy + || Date.now() < (window.__odysseusChatBusyUntil || 0) + || !!document.querySelector('.send-btn[data-mode="streaming"], .send-btn.send-pending') + || (sendBtn && (sendBtn.title || '').toLowerCase().includes('stop')); +} + +function _shouldQueueFromMobileEnter(e, input) { + return e.key === 'Enter' + && !e.shiftKey + && !e.ctrlKey + && !e.metaKey + && !e.altKey + && !e.isComposing + && _isMobileChatInput() + && _isForegroundChatBusy() + && !!(input && input.value && input.value.trim()); +} + +function _shouldQueueFromMobileLineBreak(input) { + return _isMobileChatInput() + && _isForegroundChatBusy() + && !!(input && input.value && input.value.trim()); +} + +function _isLineBreakInputEvent(e) { + return e + && (e.inputType === 'insertLineBreak' + || e.inputType === 'insertParagraph' + || e.data === '\n'); +} + +function _submitMobileQueuedInput(input) { + if (!input || !_shouldQueueFromMobileLineBreak(input)) return false; + const now = Date.now(); + const last = Number(input.dataset.mobileQueueSubmitAt || 0); + if (now - last < 300) return true; + input.dataset.mobileQueueSubmitAt = String(now); + if (chatModule && chatModule.queueStreamingComposerRequest && chatModule.queueStreamingComposerRequest()) { + return true; + } + 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 })); + return true; +} + +function _syncMobileEnterKeyHint(input) { + if (!input) return; + input.setAttribute('enterkeyhint', (_isMobileChatInput() && _isForegroundChatBusy()) ? 'send' : 'enter'); +} + +function _countLineBreaks(s) { + return ((s || '').match(/\n/g) || []).length; +} + function initForegroundActivityHeartbeat() { let lastSent = 0; const minGapMs = 12000; @@ -3226,17 +3289,38 @@ function initializeEventListeners() { // Textarea auto-resize const textarea = el('message'); if (textarea) { + _syncMobileEnterKeyHint(textarea); + window.addEventListener('odysseus:chat-busy-change', () => _syncMobileEnterKeyHint(textarea)); uiModule.autoResize(textarea); - textarea.addEventListener('input', () => { + let previousTextareaValue = textarea.value || ''; + textarea.addEventListener('beforeinput', (e) => { + if (_isLineBreakInputEvent(e) && _shouldQueueFromMobileLineBreak(textarea)) { + e.preventDefault(); + e.stopPropagation(); + _submitMobileQueuedInput(textarea); + } + }); + textarea.addEventListener('input', (e) => { + const currentValue = textarea.value || ''; + const insertedLineBreak = _isLineBreakInputEvent(e) + || _countLineBreaks(currentValue) > _countLineBreaks(previousTextareaValue); + if (insertedLineBreak && _shouldQueueFromMobileLineBreak(textarea)) { + textarea.value = currentValue.replace(/\n+$/g, ''); + previousTextareaValue = textarea.value || ''; + _submitMobileQueuedInput(textarea); + return; + } + previousTextareaValue = currentValue; uiModule.autoResize(textarea); + _syncMobileEnterKeyHint(textarea); }); textarea.addEventListener('paste', () => { setTimeout(() => uiModule.autoResize(textarea), 1); }); textarea.addEventListener('keydown', (e) => { - const isMobile = window.innerWidth <= 768 + const isMobile = _isMobileChatInput(); - if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile) { + if (_shouldQueueFromMobileEnter(e, textarea) || (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile)) { // If ghost autocomplete is active, accept the suggestion instead of submitting if (window._ghostAutocomplete && window._ghostAutocomplete.isActive()) { e.preventDefault(); @@ -3249,8 +3333,14 @@ function initializeEventListeners() { // Check if already submitting before triggering form submission const form = el('chat-form'); if (form) { - const submitBtn = form.querySelector('button[type="submit"]'); - if (submitBtn) submitBtn.click(); + if (_isForegroundChatBusy() && textarea.value && textarea.value.trim()) { + if (chatModule && chatModule.queueStreamingComposerRequest && chatModule.queueStreamingComposerRequest()) { + return; + } + window.__odysseusQueueStreamingSubmit = Date.now(); + } + const submitBtn = form.querySelector('button[type="submit"]'); + if (submitBtn) submitBtn.click(); } } }); @@ -3685,10 +3775,31 @@ function startOdysseusApp() { return fileHandlerModule.getPendingCount && fileHandlerModule.getPendingCount() > 0; } + function _updateStreamingSubmitButton() { + if (!sendBtn || sendBtn.dataset.mode !== 'streaming') return false; + const hasText = messageInput && messageInput.value.trim().length > 0; + const nextPhase = hasText ? 'queue' : 'processing'; + if (sendBtn.dataset.phase === nextPhase) return true; + sendBtn.dataset.phase = nextPhase; + sendBtn.classList.remove('mic-mode', 'newchat-mode', 'newchat-expanded', 'anim-spin', 'anim-launch', 'anim-land'); + if (hasText) { + sendBtn.innerHTML = _sendIcon; + sendBtn.title = 'Queue message'; + } else { + sendBtn.innerHTML = _stopIcon; + sendBtn.title = 'Stop generation'; + } + return true; + } + function _updateSendBtnIcon() { if (!sendBtn) return; - // Don't override if streaming (stop button) or recording - if (sendBtn.dataset.mode === 'streaming' || sendBtn.dataset.mode === 'recording') return; + if (sendBtn.dataset.mode === 'streaming') { + _updateStreamingSubmitButton(); + return; + } + // Don't override if recording + if (sendBtn.dataset.mode === 'recording') return; const prevMode = sendBtn.dataset.mode || ''; const hasText = messageInput && messageInput.value.trim().length > 0; const hasFiles = _hasAttachments(); @@ -3784,6 +3895,12 @@ function startOdysseusApp() { const hasText = messageInput && messageInput.value.trim().length > 0; const hasFiles = _hasAttachments(); + if (sendBtn.dataset.mode === 'streaming') { + if (hasText) window.__odysseusQueueStreamingSubmit = Date.now(); + handleSubmit(e); + return; + } + // New chat mode — empty input, no attachments, no STT if (!hasText && !hasFiles && sendBtn.dataset.mode === 'newchat') { if (sessionModule) { @@ -3823,9 +3940,10 @@ function startOdysseusApp() { // Enter to send (shift+enter for newline), or new chat when empty if (messageInput) { messageInput.addEventListener('keydown', (e) => { - const isMobile = window.innerWidth <= 768 + if (e.defaultPrevented) return; + const isMobile = _isMobileChatInput(); - if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile) { + if (_shouldQueueFromMobileEnter(e, messageInput) || (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !isMobile)) { e.preventDefault(); // Flush the debounced icon update so dataset.mode reflects the current // text state. Without this, a fast type-and-Enter would still see the @@ -3836,6 +3954,12 @@ function startOdysseusApp() { if (railNew) railNew.click(); return; } + if (_isForegroundChatBusy() && messageInput.value && messageInput.value.trim()) { + if (chatModule && chatModule.queueStreamingComposerRequest && chatModule.queueStreamingComposerRequest()) { + return; + } + window.__odysseusQueueStreamingSubmit = Date.now(); + } handleSubmit(e); } }); @@ -3855,7 +3979,11 @@ function startOdysseusApp() { _syncModelPickerAutohide(); messageInput.addEventListener('input', () => { _syncModelPickerAutohide(); - _debouncedUpdateIcon(); + if (sendBtn && sendBtn.dataset.mode === 'streaming') { + _updateSendBtnIcon(); + } else { + _debouncedUpdateIcon(); + } }, { passive: true }); } diff --git a/static/js/chat.js b/static/js/chat.js index 6bca955b7..df68d7e33 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -48,6 +48,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer try { window.__odysseusChatBusy = !!active; window.__odysseusChatBusyUntil = active ? Date.now() + 120000 : Date.now() + 1200; + window.dispatchEvent(new CustomEvent('odysseus:chat-busy-change', { detail: { active: !!active } })); } catch (_) {} } let _pendingContinue = null; // Stores the stopped AI element to merge with new response @@ -326,7 +327,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer // arrow out for the stop icon — otherwise the swap happens mid-flight // and the user sees nothing fly out. setTimeout(() => { - submitBtn.innerHTML = _stopSvg; + if (submitBtn.dataset.mode !== 'streaming') return; + const msgInput = uiModule.el('message'); + const hasQueuedText = !!(msgInput && msgInput.value && msgInput.value.trim()); + submitBtn.innerHTML = hasQueuedText && icons ? icons.send : _stopSvg; + submitBtn.dataset.phase = hasQueuedText ? 'queue' : 'processing'; + submitBtn.title = hasQueuedText ? 'Queue message' : 'Stop generation'; submitBtn.classList.remove('anim-launch'); void submitBtn.offsetWidth; submitBtn.classList.add('anim-land'); @@ -471,6 +477,24 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer return true; } + export function queueStreamingComposerRequest() { + if (!isStreaming) return false; + const queuedInput = uiModule.el('message'); + const queuedText = (queuedInput && queuedInput.value || '').trim(); + if (!queuedText) return false; + if (fileHandlerModule.getPendingCount && fileHandlerModule.getPendingCount()) { + try { uiModule.showError && uiModule.showError('Finish the current response before queueing messages with attachments.'); } catch (_) {} + return true; + } + if (_queueAgentRequest(queuedText)) { + queuedInput.value = ''; + queuedInput.dispatchEvent(new Event('input', { bubbles: true })); + if (uiModule.autoResize) uiModule.autoResize(queuedInput); + try { window._updateSendBtnIcon && window._updateSendBtnIcon(); } catch (_) {} + } + return true; + } + function _drainQueuedAgentRequests() { if (isStreaming || _sendInFlight || !_queuedAgentRequests.length) return; if (_queuedDrainTimer) return; @@ -507,21 +531,13 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer return; } - // If currently streaming, a non-empty composer means "queue this next". - // Empty composer keeps the existing Stop behavior. + // If currently streaming, keyboard Enter can queue a non-empty composer. + // Clicking the stop icon should still stop normally, even if text exists. if (isStreaming) { - const queuedInput = uiModule.el('message'); - const queuedText = (queuedInput && queuedInput.value || '').trim(); - if (queuedText) { - if (fileHandlerModule.getPendingCount && fileHandlerModule.getPendingCount()) { - try { uiModule.showError && uiModule.showError('Finish the current response before queueing messages with attachments.'); } catch (_) {} - return; - } - if (_queueAgentRequest(queuedText)) { - queuedInput.value = ''; - queuedInput.dispatchEvent(new Event('input', { bubbles: true })); - if (uiModule.autoResize) uiModule.autoResize(queuedInput); - } + const queueRequestedAt = Number(window.__odysseusQueueStreamingSubmit || 0); + const shouldQueueStreamingSubmit = queueRequestedAt && Date.now() - queueRequestedAt < 1200; + window.__odysseusQueueStreamingSubmit = 0; + if (shouldQueueStreamingSubmit && queueStreamingComposerRequest()) { return; } if (fileHandlerModule.isUploading && fileHandlerModule.isUploading()) { From 9718f7874b2224747b0d1217bc53e08a36ea2e63 Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Fri, 3 Jul 2026 00:45:43 +0000 Subject: [PATCH 02/12] Fix stale streams and cookbook task controls --- static/js/chat.js | 62 ++++++++++++++++++++++++++++++++++-- static/js/cookbookRunning.js | 29 +++++++++++++++-- static/style.css | 6 ++++ 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/static/js/chat.js b/static/js/chat.js index df68d7e33..ab280d9b8 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -205,6 +205,8 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer let _streamSessionId = null; // Session ID for the currently active reader loop let _lastReaderActivity = 0; // Timestamp of last reader.read() success — used to detect frozen streams let _webLockRelease = null; // Function to release the Web Lock held during streaming + let _staleStreamProbeInFlight = false; + const STALE_LOCAL_STREAM_MS = 15000; /** Check if an SSE reader is still actively connected for a session. */ function hasActiveStream(sessionId) { @@ -3108,6 +3110,21 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer return; } + if (abortReason === 'stale-local') { + const staleMsg = 'Stream connection ended. Composer unlocked; send again if needed.'; + if (holder && !accumulated) { + holder.querySelector('.body').innerHTML = + `
[${staleMsg}]
`; + } else if (holder && accumulated) { + const staleNote = document.createElement('div'); + staleNote.className = 'stopped-indicator'; + staleNote.innerHTML = `[${staleMsg}]`; + holder.querySelector('.body').appendChild(staleNote); + } + currentAbort = null; + return; + } + // User-initiated stop (or browser navigation abort). // Stopped before any text arrived — keep the bubble as a // "Cancelled by user" record (so it survives a refresh). @@ -3414,12 +3431,51 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer box.appendChild(bar); if (uiModule.scrollHistory) uiModule.scrollHistory(); } + async function _probeStaleLocalStream() { + if (!isStreaming || _staleStreamProbeInFlight) return; + if (Date.now() - _lastReaderActivity < STALE_LOCAL_STREAM_MS) return; + const sid = _streamSessionId || (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId()); + if (!sid) return; + if (_backgroundStreams.has(sid) || (sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId() !== sid)) return; + _staleStreamProbeInFlight = true; + try { + const res = await fetch(`${API_BASE}/api/chat/stream_status/${encodeURIComponent(sid)}`, { + credentials: 'same-origin', + cache: 'no-store', + }); + if (!isStreaming || _backgroundStreams.has(sid)) return; + if (res.status !== 404) return; + + console.warn('[stream-watchdog] Local stream was stale and server has no active stream. Unlocking composer.'); + if (currentAbort && !currentAbort.signal.aborted) { + currentAbort._reason = 'stale-local'; + currentAbort.abort(); + } + isStreaming = false; + _setForegroundChatBusy(false); + _sendInFlight = false; + if (_webLockRelease) { + _webLockRelease(); + _webLockRelease = null; + } + const submitBtn = document.querySelector('.send-btn'); + if (submitBtn) updateSubmitButton('idle', submitBtn); + const messageInput = uiModule.el('message'); + if (messageInput) messageInput.disabled = false; + _drainQueuedAgentRequests(); + } catch (err) { + console.warn('[stream-watchdog] Stream status probe failed:', err); + } finally { + _staleStreamProbeInFlight = false; + } + } + function _startStallWatchdog() { - // Disabled: the server-side stall detector / auto-continue (agent - // loop-breaker) handles quiet/stalled streams now, so the manual - // "Quiet for Nm — still working?" banner is redundant (and annoying). + // Keep the old noisy stall banner disabled. This watchdog only unlocks + // a dead local stream after the backend confirms no active stream exists. if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; } _removeStallBanner(); + _stallWatchdog = setInterval(_probeStaleLocalStream, 5000); } function _stopStallWatchdog() { if (_stallWatchdog) { clearInterval(_stallWatchdog); _stallWatchdog = null; } diff --git a/static/js/cookbookRunning.js b/static/js/cookbookRunning.js index 3fd77cfad..3f2a2e2d6 100644 --- a/static/js/cookbookRunning.js +++ b/static/js/cookbookRunning.js @@ -57,9 +57,24 @@ function _downloadDisplayName(name, task) { return part ? `${name} · ${part}` : name; } +function _downloadNameFromPayload(name, payload) { + const rawName = String(name || '').trim(); + // Defensive: failed/restarted downloads can inherit the wrapper executable + // name if older state was saved from a command preview. The row title should + // always be the model/repo, never "bash" or "python". + const looksLikeLauncher = /^(?:bash|sh|zsh|python|python3|pwsh|powershell|cmd|tmux)$/i.test(rawName); + const base = (!rawName || looksLikeLauncher) + ? String(payload?.repo_id || payload?.repo || '').split('/').pop() + : rawName; + const include = payload?.include || ''; + if (!include || String(base || '').includes(' · ')) return base || rawName || 'download'; + const part = _ggufDisplayPartFromPath(String(include).replace(/\*/g, '')); + return part ? `${base} · ${part}` : (base || rawName || 'download'); +} + function _taskDisplayName(task) { const name = String(task?.name || '').trim(); - if (task?.type === 'download') return _downloadDisplayName(name, task); + if (task?.type === 'download') return _downloadDisplayName(_downloadNameFromPayload(name, task?.payload), task); if (task?.type !== 'serve') return name; const gguf = task?.payload?._fields?.gguf_file || task?.payload?.gguf_file || ''; if (!gguf || name.includes(' · ')) return name; @@ -1384,6 +1399,7 @@ async function _retryDownload(name, payload, replaceSessionId = '') { const tasks = _loadTasks(); const task = tasks.find(t => t.sessionId === replaceSessionId); if (task) { + task.name = _downloadNameFromPayload(name || task.name, _payload); task.id = data.session_id; task.sessionId = data.session_id; task.status = 'running'; @@ -2344,10 +2360,18 @@ export function _renderRunningTab() { el.addEventListener('touchcancel', _lpCancel, { passive: true }); menuBtn.addEventListener('click', (e) => { e.stopPropagation(); + const existing = document.querySelector('.cookbook-task-dropdown'); + if (existing && existing._anchor === menuBtn) { + if (typeof existing._dismiss === 'function') existing._dismiss(); + else existing.remove(); + return; + } document.querySelectorAll('.cookbook-task-dropdown').forEach(d => { if (typeof d._dismiss === 'function') d._dismiss(); else d.remove(); }); const dropdown = document.createElement('div'); dropdown.className = 'cookbook-task-dropdown'; + dropdown._anchor = menuBtn; + menuBtn.classList.add('cookbook-menu-active'); const items = []; // ── Run section ───────────────────────────────────────────── @@ -2549,7 +2573,7 @@ export function _renderRunningTab() { } const closeHandler = (ev) => { - if (!dropdown.contains(ev.target) && ev.target !== menuBtn) { + if (!dropdown.contains(ev.target) && ev.target !== menuBtn && !menuBtn.contains(ev.target)) { _cleanup(); } }; @@ -2561,6 +2585,7 @@ export function _renderRunningTab() { const _cleanup = () => { _unreg(); _unreg = () => {}; dropdown.remove(); + menuBtn.classList.remove('cookbook-menu-active'); document.removeEventListener('click', closeHandler); window.removeEventListener('scroll', scrollClose, true); window.visualViewport?.removeEventListener('scroll', scrollClose); diff --git a/static/style.css b/static/style.css index 3d48db469..c3869f596 100644 --- a/static/style.css +++ b/static/style.css @@ -20795,6 +20795,12 @@ body.gallery-selecting .gallery-dl-btn, border-color: var(--border); color: var(--fg); } +.cookbook-task-menu-btn.cookbook-menu-active { + opacity: 1 !important; + background: color-mix(in srgb, var(--fg) 9%, transparent); + border-color: var(--border); + color: var(--fg); +} @media (max-width: 768px) { .cookbook-task .cookbook-task-menu-btn { opacity: 0.72; From 0fc98c4a17a0efc7110b634db62b9363c6a3a027 Mon Sep 17 00:00:00 2001 From: pewdiepie-archdaemon Date: Fri, 3 Jul 2026 01:15:40 +0000 Subject: [PATCH 03/12] Add bulk email attachment downloads --- routes/email_routes.py | 65 ++++++++++++++++++++++++++++++++++++++- static/js/emailLibrary.js | 61 ++++++++++++++++++++++++++++++++++++ static/style.css | 33 ++++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) diff --git a/routes/email_routes.py b/routes/email_routes.py index 7c3af62e2..c8728af1b 100644 --- a/routes/email_routes.py +++ b/routes/email_routes.py @@ -23,6 +23,8 @@ import smtplib import json import re import html +import io +import zipfile from html.parser import HTMLParser as _HTMLParser import logging import uuid @@ -33,7 +35,7 @@ from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart from fastapi import APIRouter, Query, UploadFile, File, BackgroundTasks, HTTPException, Depends, Request -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, StreamingResponse from src.constants import DATA_DIR from src.llm_core import llm_call_async @@ -65,6 +67,14 @@ ODYSSEUS_MAIL_ORIGIN = "odysseus-ui" EMAIL_READ_ATTACHMENT_VERSION = 2 +def _safe_attachment_zip_name(name: str, fallback: str) -> str: + """Return a zip entry filename without path traversal or empty names.""" + base = Path(str(name or "")).name.strip() or fallback + base = re.sub(r"[\x00-\x1f\x7f]+", "_", base) + base = base.replace("/", "_").replace("\\", "_").strip(". ") or fallback + return base[:180] or fallback + + def _coerce_port(value, default): """Coerce a user-supplied port to int. @@ -2530,6 +2540,59 @@ def setup_email_routes(): logger.error(f"Failed to download attachment {uid}/{index}: {e}") return {"error": "Mail operation failed"} + @router.get("/attachments-download/{uid}") + async def download_all_attachments(uid: str, folder: str = Query("INBOX"), account_id: str | None = Query(None), owner: str = Depends(require_owner)): + """Download all visible attachments for an email as a zip archive.""" + try: + with _imap(account_id, owner=owner) as conn: + conn.select(_q(folder), readonly=True) + status, msg_data = _imap_uid_fetch(conn, uid, "(RFC822)") + if status != "OK": + raise HTTPException(status_code=404, detail="Email not found") + raw = msg_data[0][1] + msg = email_mod.message_from_bytes(raw) + attachments = [ + att for att in _list_attachments_from_msg(msg) + if not _is_likely_signature_image_attachment(att) + ] + if not attachments: + raise HTTPException(status_code=404, detail="No downloadable attachments") + + target_dir = attachment_extract_dir(folder, uid) + zip_buf = io.BytesIO() + used_names: dict[str, int] = {} + with zipfile.ZipFile(zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for att in attachments: + idx = att.get("index") + if idx is None: + continue + filepath = _extract_attachment_to_disk(msg, int(idx), target_dir) + if not filepath or not Path(filepath).exists(): + continue + fallback = f"attachment-{idx}" + arcname = _safe_attachment_zip_name(att.get("filename") or Path(filepath).name, fallback) + stem = Path(arcname).stem + suffix = Path(arcname).suffix + seen = used_names.get(arcname, 0) + used_names[arcname] = seen + 1 + if seen: + arcname = f"{stem}-{seen + 1}{suffix}" + zf.write(str(filepath), arcname) + zip_buf.seek(0) + if not zip_buf.getbuffer().nbytes: + raise HTTPException(status_code=404, detail="No downloadable attachments") + zip_name = _safe_attachment_zip_name(f"email-{uid}-attachments.zip", "attachments.zip") + return StreamingResponse( + zip_buf, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{zip_name}"'}, + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to download attachments zip {uid}: {e}") + raise HTTPException(status_code=500, detail="Mail operation failed") + @router.get("/inline-image/{uid}") async def inline_image( uid: str, diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js index c240bf3a5..da705c6f3 100644 --- a/static/js/emailLibrary.js +++ b/static/js/emailLibrary.js @@ -5284,6 +5284,63 @@ function _wireAttachmentHandlers(reader, folder) { // a ReferenceError when this fn is called from contexts that don't have // _isMobileUA in scope (e.g. _openEmailAsTab, _openEmailWindow). const _isMobileUA = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); + reader.querySelectorAll('.email-attachments-download-all').forEach(btn => { + if (btn.dataset.wired === '1') return; + btn.dataset.wired = '1'; + btn.addEventListener('click', async (ev) => { + ev.stopPropagation(); + ev.preventDefault(); + if (btn.dataset.downloading === '1') return; + const uid = btn.dataset.attUid; + const sourceFolder = btn.dataset.attFolder || useFolder; + const count = Number(btn.dataset.attCount || 0); + if (!uid) return; + const originalHtml = btn.innerHTML; + const originalTitle = btn.title; + btn.dataset.downloading = '1'; + btn.classList.add('is-loading'); + try { + const sp = window.spinnerModule || (await import('./spinner.js')).default; + const wp = sp.createWhirlpool(12); + wp.element.style.margin = '0'; + btn.textContent = ''; + btn.appendChild(wp.element); + const label = document.createElement('span'); + label.textContent = 'All'; + btn.appendChild(label); + } catch (_) { + btn.textContent = 'All...'; + } + try { + const url = `${API_BASE}/api/email/attachments-download/${encodeURIComponent(uid)}?folder=${encodeURIComponent(sourceFolder)}${_acct()}`; + const res = await fetch(url, { credentials: 'same-origin' }); + if (!res.ok) { + const msg = await res.text().catch(() => ''); + console.error('attachments zip download failed', res.status, msg); + location.href = url; + return; + } + const blob = await res.blob(); + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = blobUrl; + a.download = `email-${uid}-attachments.zip`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(blobUrl), 1000); + try { uiModule.showToast && uiModule.showToast(`Downloading ${count || 'all'} attachments`); } catch (_) {} + } catch (e) { + console.error('attachments zip download error', e); + try { const { showError } = await import('./ui.js'); showError('Could not download attachments'); } catch (_) {} + } finally { + delete btn.dataset.downloading; + btn.classList.remove('is-loading'); + btn.title = originalTitle; + btn.innerHTML = originalHtml; + } + }); + }); reader.querySelectorAll('.email-attachment-open').forEach(openBtn => { if (openBtn.dataset.wired === '1') return; openBtn.dataset.wired = '1'; @@ -5487,11 +5544,15 @@ function _buildAttsHtmlFor(uid, data) { ? `Thread attachments (${related.length})` : `Hidden inline attachments (${hidden.length})`; const startCollapsed = !visible.length && !related.length; + const downloadAllBtn = visible.length > 4 + ? `` + : ''; return ( ` -
-

Translation

-
-
-
Auto translate
-
When an opened email appears to be in another language, prepare a translated view.
-
- -
-
- - - - - - - - - - - - - - - -
-
- @@ -2122,7 +2094,7 @@
-

Add Local Models (Endpoint) +

Add Local Models (Endpoint)

diff --git a/static/js/admin.js b/static/js/admin.js index 5d071c6bd..d409614a8 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -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(); diff --git a/static/js/chat.js b/static/js/chat.js index 74746d5c6..f9a035a8c 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -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 = '' + _delta; _thinkOpen = true; } } else if (_thinkOpen) { _delta = '' + _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 { diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js index d31243d88..82e4c2b5b 100644 --- a/static/js/chatRenderer.js +++ b/static/js/chatRenderer.js @@ -474,6 +474,10 @@ const XML_INVOKE_RE = /[\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¬e=([^&]+)/); + 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¬e=)/.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 = 'Open in gallery'; + 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) { '' + metadata.thinking + '\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 diff --git a/static/js/chatStream.js b/static/js/chatStream.js index 7f292de25..7b117d746 100644 --- a/static/js/chatStream.js +++ b/static/js/chatStream.js @@ -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 }); diff --git a/static/js/cookbook-deps-recipes.js b/static/js/cookbook-deps-recipes.js index ba4f1b444..47c3dbf62 100644 --- a/static/js/cookbook-deps-recipes.js +++ b/static/js/cookbook-deps-recipes.js @@ -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 diff --git a/static/js/cookbook-diagnosis.js b/static/js/cookbook-diagnosis.js index a8bb31419..1d02813fa 100644 --- a/static/js/cookbook-diagnosis.js +++ b/static/js/cookbook-diagnosis.js @@ -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. SGLang’s 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 |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 = '
Running command...
'; + } 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 = '' + + `
${ok ? 'Command completed.' : 'Command failed.'}
` + + `
Exit code: ${_diagEsc(data.exit_code ?? 'unknown')}
` + + (out ? `
${_diagEsc(out)}
` : ''); + } } catch (e) { - if (diag) diag.textContent = `Error: ${e.message}`; + if (diag) { + diag.innerHTML = `
Command error.
${_diagEsc(e.message)}
`; + } } } diff --git a/static/js/cookbook-hwfit.js b/static/js/cookbook-hwfit.js index 841a81baf..3636b0dc5 100644 --- a/static/js/cookbook-hwfit.js +++ b/static/js/cookbook-hwfit.js @@ -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 += `${modelLogo(m.name)}${esc(_short)}${_quantSuffix}${moeBadge}${imgBadge}${dlDot}`; - html += `${esc(pcount)}`; + html += `${vramLabel}`; + html += `${esc(pcount)}`; // 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 += `${esc(_qShort)}`; - html += `${vramLabel}`; html += `${m.is_image_gen ? '\u2014' : ctx}`; html += `${m.is_image_gen ? '\u2014' : tps + ' t/s'}`; html += `${score}`; @@ -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 = { '': '', vllm: '', sglang: '', + mlx: '', llamacpp: '', ollama: '', diffusers: '', @@ -2040,15 +2101,22 @@ export function _hwfitInit() { ]; for (const sel of selectors) { if (!sel) continue; - const currentVal = sel.value; - let html = ``; + 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 = ``; _envState.servers.forEach((s, i) => { if (!s.host) return; const label = s.name || s.host || `Server ${i + 1}`; - html += ``; + const color = /^#[0-9a-fA-F]{6}$/.test(String(s.color || '').trim()) ? String(s.color).trim() : ''; + html += ``; }); 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) + 'default'; + 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 = '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 = '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(); } diff --git a/static/js/cookbook.js b/static/js/cookbook.js index 73965faf4..e209f76ae 100644 --- a/static/js/cookbook.js +++ b/static/js/cookbook.js @@ -60,6 +60,11 @@ if (typeof window !== 'undefined' && !window._tagScrollGuardWired) { export const _MODELDIR_CHECK_OFF = ''; export const _MODELDIR_CHECK_ON = ''; +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 = ``; + const _localColor = _localSrv ? _resolvedServerColor(_localSrv, _localIdx) : ''; + let html = ``; 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 += ``; + html += ``; } 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 = '
No packages found
'; return; } const _winUnsupported = new Set(['hf_transfer', 'vllm', 'rembg', 'gfpgan']); + const _systemInstallable = new Set(['tmux']); const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => { if (winBlocked) return `N/A`; @@ -944,8 +1080,12 @@ async function _fetchDependencies() { if (pkg.installed) return ``; 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 ``; + } const depLabel = pkg.applicable === false ? 'N/A ?' : 'Missing'; - return `${depLabel}`; + const depStyle = pkg.name === 'docker' ? ' style="width:87.7px;justify-content:center;"' : ''; + return `${depLabel}`; } return ``; }; @@ -957,6 +1097,7 @@ async function _fetchDependencies() { const _DEP_GLYPHS = { vllm: '', sglang: '', + mlx_lm: '', llama_cpp: '', ollama: '', diffusers: '', @@ -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 => `${esc(d)}`).join('') + 'edit'; 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 ? '' : ''; + return `${check}default`; +} + 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]) => ``).join(''); + const srvColor = _serverColor(s); + const resolvedSrvColor = _resolvedServerColor(s, i); + const colorOpts = _SERVER_COLOR_CHOICES.map(([value, label]) => { + const displayLabel = label; + return ``; + }).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 ``; + }).join(''); let html = ''; - html += `
`; + html += `
`; 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 += `${_checkBtn}${_keyBtn}`; } else { - html += `${!isLocal ? _checkBtn + _keyBtn : ''}${_isDefaultSrv ? _MODELDIR_CHECK_ON : _MODELDIR_CHECK_OFF}default`; + html += `${!isLocal ? _checkBtn + _keyBtn : ''}${_serverDefaultHtml(_isDefaultSrv)}`; } html += ``; html += `
`; html += ``; - html += ``; + html += ``; + html += ``; html += ``; html += ``; html += ``; @@ -2567,13 +2796,15 @@ export function _serverEntryHtml(s, i, defaultServer, forceRemote, isNew) { html += `${dlBtn} ${esc(modelDirs[j])}${rmBtn}`; } html += ``; - 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 += ``; + html += ``; } else if (!isLocal) { - html += ``; + html += ``; + html += ``; } html += `
`; if (!isLocal) { @@ -2709,6 +2940,7 @@ function _renderRecipes() { html += ''; html += ''; html += ''; + html += ''; html += ''; html += ''; html += ''; @@ -2726,7 +2958,7 @@ function _renderRecipes() { html += ''; html += ''; @@ -2744,9 +2976,8 @@ function _renderRecipes() { html += _buildServerOpts(false); html += ''; html += '
'; - // (Rescan button removed — Edit handles manual hardware updates; - // automatic re-probe runs on container restart.) html += ''; + html += ''; // Sort state — the clickable column headers read/write this (pewds' original // sort paradigm). Newest is reachable by clicking the Model column header. html += ''; html += ''; html += ''; + html += ''; html += '
'; html += '
'; html += '
'; @@ -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(); } }); diff --git a/static/js/cookbookDownload.js b/static/js/cookbookDownload.js index 295189c28..330d7d9aa 100644 --- a/static/js/cookbookDownload.js +++ b/static/js/cookbookDownload.js @@ -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 / 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`); diff --git a/static/js/cookbookRunning.js b/static/js/cookbookRunning.js index 3f2a2e2d6..e9ee597c4 100644 --- a/static/js/cookbookRunning.js +++ b/static/js/cookbookRunning.js @@ -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 = 'refreshing…'; } 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', `
${esc(sg.name)}
`); + const _srvColor = _serverColorForTaskGroup(key || 'local', allTasks); + sec.insertAdjacentHTML('afterbegin', `
${esc(sg.name)}
`); } } @@ -2186,7 +2384,7 @@ export function _renderRunningTab() {
${esc(task.sessionId)}${(task.type === 'download') ? `Dir: ${esc(task.payload?.local_dir || '~/.cache/huggingface/hub')}` : ''}
-
${esc(task.output || '')}
+
${esc(task.output || '')}
`; 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(); } }) diff --git a/static/js/cookbookServe.js b/static/js/cookbookServe.js index d026c1e5b..d7a2a793f 100644 --- a/static/js/cookbookServe.js +++ b/static/js/cookbookServe.js @@ -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 += ``; + panelHtml += ``; panelHtml += ``; // 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 += `
`; + panelHtml += `
`; // 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 += ``; + panelHtml += ``; panelHtml += ``; // 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 += ``; panelHtml += ``; panelHtml += `
`; - // 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 += `
`; - panelHtml += ``; - panelHtml += ``; + panelHtml += ``; + panelHtml += ``; // 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 += ``; - panelHtml += ``; - panelHtml += ``; + panelHtml += ``; + panelHtml += ``; + panelHtml += ``; // 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 += ``; + panelHtml += ``; + panelHtml += ``; panelHtml += ``; panelHtml += ``; { @@ -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: '', sglang: '', + mlx: '', llamacpp: '', ollama: '', diffusers: '', @@ -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 => `${esc(d)}`).join('') + 'edit'; _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 = '
No cached model scan yet
Check this server\'s model cache.
'; - list.querySelector('.serve-empty-scan-btn')?.addEventListener('click', () => { - _fetchCachedModels(true); - }); + const wp = spinnerModule.createWhirlpool(22); + list.innerHTML = '
No cached model scan yet
Scanning this server\'s model cache…
'; + 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; diff --git a/static/js/document.js b/static/js/document.js index b245ec852..0d51d455b 100644 --- a/static/js/document.js +++ b/static/js/document.js @@ -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 = ``; + _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 = ` + + + + `; + } else { + row.innerHTML = ` + + + + `; + } + 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 = ''; + } + } + + 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 = ` + + + + + + `; + 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 = `${_escHtml(c.name)}${_escHtml(em)}`; + 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); diff --git a/static/js/emailInbox.js b/static/js/emailInbox.js index 7a8abe5cc..0a864050f 100644 --- a/static/js/emailInbox.js +++ b/static/js/emailInbox.js @@ -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) => `${svg}`; 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(() => {}); diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js index da705c6f3..2a6a31d9b 100644 --- a/static/js/emailLibrary.js +++ b/static/js/emailLibrary.js @@ -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 ? '' : ''; + const folderChip = showFolderChip && !isSentFolderEarly + ? `` + : ''; const senderPrefix = isSentFolderEarly ? 'to ' : ''; - meta.innerHTML = ``; + meta.innerHTML = `${sentChip}${folderChip}`; 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 diff --git a/static/js/fileHandler.js b/static/js/fileHandler.js index 09cb8caf7..01a33ede2 100644 --- a/static/js/fileHandler.js +++ b/static/js/fileHandler.js @@ -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 = ` + `; + 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, diff --git a/static/js/gallery.js b/static/js/gallery.js index b74a90f53..be95378b5 100644 --- a/static/js/gallery.js +++ b/static/js/gallery.js @@ -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, }; diff --git a/static/js/init.js b/static/js/init.js index a15365c01..37915445c 100644 --- a/static/js/init.js +++ b/static/js/init.js @@ -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¬e=/.test(hash); + const hasSessionTarget = !!((hash && !isEntityHash) || Storage.get('lastSessionId')); if (hasSessionTarget) return; if (msgInput.value) { msgInput.value = ''; diff --git a/static/js/markdown.js b/static/js/markdown.js index 0bfee5f41..d2623da89 100644 --- a/static/js/markdown.js +++ b/static/js/markdown.js @@ -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 (`#`). 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. diff --git a/static/js/modelPicker.js b/static/js/modelPicker.js index 75a4a4af4..a7b1ac64c 100644 --- a/static/js/modelPicker.js +++ b/static/js/modelPicker.js @@ -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(); } diff --git a/static/js/models.js b/static/js/models.js index 8f7b1305e..cbcdacc9e 100644 --- a/static/js/models.js +++ b/static/js/models.js @@ -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(); diff --git a/static/js/notes.js b/static/js/notes.js index 193c22c9c..de198ca5e 100644 --- a/static/js/notes.js +++ b/static/js/notes.js @@ -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 = () => { diff --git a/static/js/sessions.js b/static/js/sessions.js index fb13af3b8..39634428d 100644 --- a/static/js/sessions.js +++ b/static/js/sessions.js @@ -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¬e=/.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¬e=/.test(hashId)) return; if (hashId && hashId !== currentSessionId) { const target = sessions.find(s => s.id === hashId && !s.archived); if (target) selectSession(hashId); diff --git a/static/js/settings.js b/static/js/settings.js index 4bff7ad8c..d43f9b9f0 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -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) { diff --git a/static/js/sidebar-layout.js b/static/js/sidebar-layout.js index 44875825f..398a530f5 100644 --- a/static/js/sidebar-layout.js +++ b/static/js/sidebar-layout.js @@ -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(); diff --git a/static/style.css b/static/style.css index ea04342f1..097e38ffd 100644 --- a/static/style.css +++ b/static/style.css @@ -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); } diff --git a/static/sw.js b/static/sw.js index df2d3aeae..05b3d4e9b 100644 --- a/static/sw.js +++ b/static/sw.js @@ -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