mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-17 02:05:22 -04:00
Merge remote-tracking branch 'origin/main' into visual-pr-playground
# Conflicts: # routes/cookbook_routes.py # routes/hwfit_routes.py # services/hwfit/fit.py # services/hwfit/models.py # static/js/cookbook-diagnosis.js # static/js/cookbook-hwfit.js # static/js/cookbook.js # static/js/cookbookRunning.js
This commit is contained in:
+122
-47
@@ -85,6 +85,39 @@ async function _refreshDefaultChat() {
|
||||
// synchronously; later reads should call _refreshDefaultChat() first.
|
||||
_refreshDefaultChat();
|
||||
|
||||
async function _createDirectChatFromPreferredModel() {
|
||||
if (!sessionModule) return false;
|
||||
|
||||
const pending = sessionModule.getPendingChat && sessionModule.getPendingChat();
|
||||
if (pending && pending.url && pending.modelId) {
|
||||
sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId);
|
||||
return true;
|
||||
}
|
||||
|
||||
const sessions = sessionModule.getSessions();
|
||||
const currentId = sessionModule.getCurrentSessionId();
|
||||
const current = sessions.find(s => s.id === currentId);
|
||||
if (current && current.endpoint_url && current.model) {
|
||||
sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
const dc = await _refreshDefaultChat();
|
||||
if (dc) {
|
||||
sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
const withModel = sessions.filter(s => s.endpoint_url && s.model);
|
||||
if (withModel.length > 0) {
|
||||
const last = withModel[0]; // sessions are sorted by recent
|
||||
sessionModule.createDirectChat(last.endpoint_url, last.model, last.endpoint_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// EVENT LISTENERS INITIALIZATION
|
||||
// ============================================
|
||||
@@ -270,7 +303,9 @@ function initializeEventListeners() {
|
||||
label = (raw || '').trim() || 'Assistant';
|
||||
}
|
||||
const body = child.querySelector('.body');
|
||||
const text = body ? (body.innerText || body.textContent || '').trim() : '';
|
||||
// Prefer dataset.raw (original markdown) over innerText (rendered HTML as text)
|
||||
// to avoid extra newlines and formatting artifacts.
|
||||
const text = body ? (body.dataset.raw || body.innerText || body.textContent || '').trim() : '';
|
||||
if (text) parts.push(`${label}: ${text}`);
|
||||
} else if (child.classList?.contains('agent-thread')) {
|
||||
const lines = ['[Tool calls]'];
|
||||
@@ -499,6 +534,13 @@ function initializeEventListeners() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Model picker popup — close before opening any modals
|
||||
const modelPickerMenu = document.getElementById('model-picker-menu');
|
||||
if (modelPickerMenu && modelPickerMenu.classList.contains('open')) {
|
||||
modelPickerMenu.classList.remove('open');
|
||||
return;
|
||||
}
|
||||
|
||||
// Close one modal at a time (last in DOM = topmost)
|
||||
// Map modal id → sidebar list-item id to clear active state
|
||||
const modalItemMap = {
|
||||
@@ -510,7 +552,7 @@ function initializeEventListeners() {
|
||||
};
|
||||
|
||||
// Dynamic modals (removed from DOM on close)
|
||||
const dynamicModals = ['library-modal', 'archive-modal', 'doclib-modal', 'gallery-modal', 'tasks-modal'];
|
||||
const dynamicModals = ['library-modal', 'archive-modal', 'doclib-modal', 'gallery-modal', 'tasks-modal', 'email-lib-modal'];
|
||||
for (const id of dynamicModals) {
|
||||
const m = document.getElementById(id);
|
||||
if (id === 'gallery-modal') {
|
||||
@@ -1564,6 +1606,8 @@ function initializeEventListeners() {
|
||||
saveToggleState(st);
|
||||
agentBtn.classList.toggle('active', mode === 'agent');
|
||||
chatBtn.classList.toggle('active', mode === 'chat');
|
||||
agentBtn.setAttribute('aria-pressed', String(mode === 'agent'));
|
||||
chatBtn.setAttribute('aria-pressed', String(mode === 'chat'));
|
||||
// Slide the pill to the active button
|
||||
const toggle = agentBtn.closest('.mode-toggle');
|
||||
if (toggle) toggle.classList.toggle('mode-chat', mode === 'chat');
|
||||
@@ -1621,11 +1665,13 @@ function initializeEventListeners() {
|
||||
const chk = el(checkboxId);
|
||||
if (chk) chk.checked = saved;
|
||||
btn.classList.toggle('active', saved);
|
||||
btn.setAttribute('aria-pressed', String(saved));
|
||||
btn.addEventListener('click', () => {
|
||||
const curMode = (loadToggleState().mode) || 'chat';
|
||||
const chk = el(checkboxId);
|
||||
chk.checked = !chk.checked;
|
||||
btn.classList.toggle('active', chk.checked);
|
||||
btn.setAttribute('aria-pressed', String(chk.checked));
|
||||
saveToolPref(stateKey, curMode, chk.checked);
|
||||
showToolToggleToast(stateKey, chk.checked);
|
||||
if (chk.checked) _showToolSplash(stateKey);
|
||||
@@ -3011,27 +3057,7 @@ function initializeEventListeners() {
|
||||
// Clear research mode if active
|
||||
const _resChk = el('research-toggle');
|
||||
if (_resChk && _resChk.checked) _syncResearchIndicator(false);
|
||||
// Use default chat if configured — always re-fetch so setting changes apply immediately
|
||||
const dc = await _refreshDefaultChat();
|
||||
if (dc) {
|
||||
sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id);
|
||||
return;
|
||||
}
|
||||
const sessions = sessionModule.getSessions();
|
||||
const currentId = sessionModule.getCurrentSessionId();
|
||||
const current = sessions.find(s => s.id === currentId);
|
||||
// Try current session's model first
|
||||
if (current && current.endpoint_url && current.model) {
|
||||
sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id);
|
||||
return;
|
||||
}
|
||||
// Fallback: find any recent session with a model
|
||||
const withModel = sessions.filter(s => s.endpoint_url && s.model);
|
||||
if (withModel.length > 0) {
|
||||
const last = withModel[0]; // sessions are sorted by recent
|
||||
sessionModule.createDirectChat(last.endpoint_url, last.model, last.endpoint_id);
|
||||
return;
|
||||
}
|
||||
if (await _createDirectChatFromPreferredModel()) return;
|
||||
// No models at all — show welcome screen
|
||||
sessionModule.setCurrentSessionId(null);
|
||||
if (documentModule && documentModule.isPanelOpen && documentModule.isPanelOpen()) documentModule.closePanel();
|
||||
@@ -3076,23 +3102,7 @@ function initializeEventListeners() {
|
||||
if (presetsModule && presetsModule.deactivateCharacter) presetsModule.deactivateCharacter();
|
||||
// Clear research toggle when starting a fresh chat (not via research button)
|
||||
_syncResearchIndicator(false);
|
||||
const dc = await _refreshDefaultChat();
|
||||
if (dc) {
|
||||
sessionModule.createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id);
|
||||
return;
|
||||
}
|
||||
const sessions = sessionModule.getSessions();
|
||||
const currentId = sessionModule.getCurrentSessionId();
|
||||
const current = sessions.find(s => s.id === currentId);
|
||||
if (current && current.endpoint_url && current.model) {
|
||||
sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id);
|
||||
return;
|
||||
}
|
||||
const withModel = sessions.filter(s => s.endpoint_url && s.model);
|
||||
if (withModel.length > 0) {
|
||||
sessionModule.createDirectChat(withModel[0].endpoint_url, withModel[0].model, withModel[0].endpoint_id);
|
||||
return;
|
||||
}
|
||||
if (await _createDirectChatFromPreferredModel()) return;
|
||||
// No models at all — show welcome screen
|
||||
sessionModule.setCurrentSessionId(null);
|
||||
if (documentModule && documentModule.isPanelOpen && documentModule.isPanelOpen()) documentModule.closePanel();
|
||||
@@ -3129,10 +3139,7 @@ function initializeEventListeners() {
|
||||
const idx = sessions.findIndex(s => s.id === currentId);
|
||||
const nextSession = sessions.filter(s => !s.archived && s.id !== currentId)[Math.max(0, idx)] ||
|
||||
sessions.find(s => !s.archived && s.id !== currentId);
|
||||
const res = await fetch(`${API_BASE}/api/session/${currentId}/archive`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
const res = await fetch(`${API_BASE}/api/session/${currentId}`, { method: 'DELETE' });
|
||||
if (res.ok) {
|
||||
await sessionModule.loadSessions();
|
||||
if (nextSession) {
|
||||
@@ -3159,7 +3166,7 @@ function initializeEventListeners() {
|
||||
setTimeout(() => uiModule.autoResize(textarea), 1);
|
||||
});
|
||||
textarea.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
// If ghost autocomplete is active, accept the suggestion instead of submitting
|
||||
if (window._ghostAutocomplete && window._ghostAutocomplete.isActive()) {
|
||||
e.preventDefault();
|
||||
@@ -3732,7 +3739,7 @@ function startOdysseusApp() {
|
||||
// Enter to send (shift+enter for newline), or new chat when empty
|
||||
if (messageInput) {
|
||||
messageInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
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
|
||||
@@ -3856,7 +3863,75 @@ function startOdysseusApp() {
|
||||
e.preventDefault();
|
||||
attachStrip.style.backgroundColor = '';
|
||||
});
|
||||
|
||||
|
||||
// ── Compare-mode file drop shield ──────────────────────────────────────────
|
||||
// Compare reuses #chat-container, but each pane renders into a sandboxed
|
||||
// <iframe>. Iframes swallow drag-and-drop events: a file dropped on a pane is
|
||||
// handled by the iframe, not the parent, so the browser loads the file *inside
|
||||
// the pane* ("behind" the app) instead of attaching it. The chatContainer drop
|
||||
// handler above never sees it because the event doesn't bubble out of the frame.
|
||||
//
|
||||
// Fix: while a file drag is active in Compare, raise a single full-window shield
|
||||
// that sits above every pane/iframe and becomes the drop target. The drop then
|
||||
// lands on the parent document and we route the files into the shared composer
|
||||
// (the same pending-files pipeline the picker and paste use). Scoped to Compare
|
||||
// via the .compare-active class, so normal chat and the tool dropzones (gallery,
|
||||
// RAG, document editor, …) are unaffected.
|
||||
let _cmpDropShield = null;
|
||||
const _isFileDrag = (e) => {
|
||||
const types = e.dataTransfer && e.dataTransfer.types;
|
||||
return !!types && Array.prototype.indexOf.call(types, 'Files') !== -1;
|
||||
};
|
||||
const _compareActive = () => {
|
||||
const c = el('chat-container');
|
||||
return !!c && c.classList.contains('compare-active');
|
||||
};
|
||||
const _showCmpShield = () => {
|
||||
if (!_cmpDropShield) {
|
||||
_cmpDropShield = document.createElement('div');
|
||||
_cmpDropShield.id = 'compare-drop-shield';
|
||||
_cmpDropShield.setAttribute('aria-hidden', 'true');
|
||||
_cmpDropShield.style.cssText = 'position:fixed;inset:0;z-index:2147483646;' +
|
||||
'display:none;align-items:center;justify-content:center;' +
|
||||
'background:color-mix(in srgb, var(--accent, #0af) 16%, rgba(0,0,0,0.5));' +
|
||||
'backdrop-filter:blur(2px);';
|
||||
const _box = document.createElement('div');
|
||||
_box.style.cssText = 'pointer-events:none;border:2px dashed rgba(255,255,255,0.9);' +
|
||||
'border-radius:14px;padding:20px 28px;background:rgba(0,0,0,0.4);' +
|
||||
'font:600 16px/1.4 system-ui,sans-serif;color:#fff;';
|
||||
_box.textContent = 'Drop files to attach';
|
||||
_cmpDropShield.appendChild(_box);
|
||||
document.body.appendChild(_cmpDropShield);
|
||||
}
|
||||
_cmpDropShield.style.display = 'flex';
|
||||
};
|
||||
const _hideCmpShield = () => { if (_cmpDropShield) _cmpDropShield.style.display = 'none'; };
|
||||
// Capture phase so we raise the shield before the pointer reaches an iframe.
|
||||
window.addEventListener('dragenter', (e) => {
|
||||
if (_isFileDrag(e) && _compareActive()) _showCmpShield();
|
||||
}, true);
|
||||
window.addEventListener('dragover', (e) => {
|
||||
if (!_isFileDrag(e) || !_compareActive()) return;
|
||||
e.preventDefault(); // mark as a valid drop target
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||||
_showCmpShield();
|
||||
}, true);
|
||||
window.addEventListener('dragleave', (e) => {
|
||||
// Hide only when the drag actually leaves the window (no relatedTarget).
|
||||
if (_compareActive() && !e.relatedTarget) _hideCmpShield();
|
||||
}, true);
|
||||
window.addEventListener('dragend', _hideCmpShield, true);
|
||||
window.addEventListener('drop', (e) => {
|
||||
if (!_isFileDrag(e) || !_compareActive()) return;
|
||||
e.preventDefault();
|
||||
_hideCmpShield();
|
||||
const files = Array.from(e.dataTransfer.files || []);
|
||||
if (!files.length) return;
|
||||
fileHandlerModule.addFiles(files);
|
||||
fileHandlerModule.renderAttachStrip();
|
||||
uiModule.showToast(`Added ${files.length} file${files.length > 1 ? 's' : ''} to attach`);
|
||||
}, true);
|
||||
|
||||
// Load initial data
|
||||
presetsModule.loadPresets(uiModule.showError);
|
||||
|
||||
|
||||
+74
-62
@@ -242,7 +242,7 @@
|
||||
</script>
|
||||
<!-- Memory Management Modal -->
|
||||
<div id="memory-modal" class="modal hidden">
|
||||
<div class="modal-content memory-modal-content" style="background:var(--bg)">
|
||||
<div class="modal-content memory-modal-content" role="dialog" aria-label="Brain" style="background:var(--bg)">
|
||||
<div class="modal-header">
|
||||
<h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/><path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/></svg>Brain</h4>
|
||||
<button class="close-btn" id="close-memory-modal" aria-label="Close memory modal">✖</button>
|
||||
@@ -265,7 +265,7 @@
|
||||
<p class="memory-desc doclib-desc" style="margin-top:6px;">Long-term facts the AI remembers across chats — recall, edit, or curate.</p>
|
||||
<div class="memory-toolbar">
|
||||
<div class="memory-toolbar-row">
|
||||
<select id="memory-sort" class="memory-sort-select">
|
||||
<select id="memory-sort" class="memory-sort-select" aria-label="Sort memories">
|
||||
<option value="newest">Newest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
<option value="alpha">A-Z</option>
|
||||
@@ -274,7 +274,7 @@
|
||||
<button id="memory-select-btn" class="memory-toolbar-btn" title="Select multiple memories">Select</button>
|
||||
<button id="memory-tidy-btn" class="memory-toolbar-btn" title="AI tidy: deduplicate and clean up memories"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg> Tidy</button>
|
||||
</div>
|
||||
<input type="text" id="memory-search" placeholder="Search memories…" class="memory-search-input" />
|
||||
<input type="text" id="memory-search" placeholder="Search memories…" class="memory-search-input" aria-label="Search memories" />
|
||||
<div id="memory-category-filters" class="memory-category-filters">
|
||||
<button class="memory-cat-chip active" data-cat="all">all</button>
|
||||
</div>
|
||||
@@ -304,7 +304,7 @@
|
||||
</p>
|
||||
<div class="memory-add-row" style="margin-top:8px;">
|
||||
<div class="skill-ph-wrap" style="flex:1;min-width:0;">
|
||||
<input type="text" id="new-memory-input" placeholder=" " class="memory-add-input skill-hint-input" />
|
||||
<input type="text" id="new-memory-input" placeholder=" " class="memory-add-input skill-hint-input" aria-label="New memory text" />
|
||||
<span class="skill-rich-ph"><span class="k">Add a memory</span> — e.g. 'I prefer concise replies' <svg class="k" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-left:4px;" aria-hidden="true"><polyline points="9 10 4 15 9 20"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></svg></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,19 +315,19 @@
|
||||
</div>
|
||||
<p class="memory-desc doclib-desc" style="margin-top:6px;">Create a skill by hand — title, what it solves, and an approach.</p>
|
||||
<div class="skill-ph-wrap" style="margin-top:4px;margin-bottom:6px;">
|
||||
<input type="text" id="new-skill-title" placeholder=" " class="memory-add-input skill-hint-input" />
|
||||
<input type="text" id="new-skill-title" placeholder=" " class="memory-add-input skill-hint-input" aria-label="Skill title" />
|
||||
<span class="skill-rich-ph"><span class="k">Title</span> — short name, e.g. “build-vllm-wheel”</span>
|
||||
</div>
|
||||
<div class="skill-ph-wrap" style="margin-bottom:6px;">
|
||||
<input type="text" id="new-skill-problem" placeholder=" " class="memory-add-input skill-hint-input" />
|
||||
<input type="text" id="new-skill-problem" placeholder=" " class="memory-add-input skill-hint-input" aria-label="When to use this skill" />
|
||||
<span class="skill-rich-ph"><span class="k">When to use</span> — what problem does this skill solve?</span>
|
||||
</div>
|
||||
<div class="skill-ph-wrap" style="margin-bottom:6px;">
|
||||
<textarea id="new-skill-solution" placeholder=" " class="memory-add-input skill-hint-input" rows="2" style="resize:vertical;"></textarea>
|
||||
<textarea id="new-skill-solution" placeholder=" " class="memory-add-input skill-hint-input" rows="2" style="resize:vertical;" aria-label="How — the approach or steps"></textarea>
|
||||
<span class="skill-rich-ph skill-rich-ph-top"><span class="k">How</span> — the approach, steps, commands, or rules to follow</span>
|
||||
</div>
|
||||
<div class="skill-ph-wrap" style="margin-bottom:8px;">
|
||||
<input type="text" id="new-skill-tags" placeholder=" " class="memory-add-input skill-hint-input" />
|
||||
<input type="text" id="new-skill-tags" placeholder=" " class="memory-add-input skill-hint-input" aria-label="Tags" />
|
||||
<span class="skill-rich-ph"><span class="k">Tags</span> — comma-separated, e.g. python, build, vllm</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-end;">
|
||||
@@ -368,7 +368,7 @@
|
||||
<button id="skills-select-btn" class="memory-toolbar-btn" title="Select multiple skills">Select</button>
|
||||
<button id="skills-audit-btn" class="memory-toolbar-btn" title="Test every skill, auto-fix the weak ones, flag what still fails"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Audit all</button>
|
||||
</div>
|
||||
<input type="text" id="skills-search" placeholder="Search skills…" class="memory-search-input" />
|
||||
<input type="text" id="skills-search" placeholder="Search skills…" class="memory-search-input" aria-label="Search skills" />
|
||||
</div>
|
||||
<div id="skills-audit-panel" class="skills-audit-panel hidden"></div>
|
||||
<div id="skills-bulk-bar" class="memory-bulk-bar hidden">
|
||||
@@ -407,7 +407,7 @@
|
||||
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.6">Controls how many relevant published or approved skills are added to each agent request.</span>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:8px">
|
||||
<span class="admin-toggle-sub" style="margin:0">Max skills per request</span>
|
||||
<input type="number" id="skill-max-input" min="0" max="12" step="1" value="3" style="flex-shrink:0;width:72px;background:var(--input-bg,var(--panel));color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:12px;text-align:right;font-variant-numeric:tabular-nums" />
|
||||
<input type="number" id="skill-max-input" min="0" max="12" step="1" value="3" aria-label="Max skills to inject" style="flex-shrink:0;width:72px;background:var(--input-bg,var(--panel));color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:12px;text-align:right;font-variant-numeric:tabular-nums" />
|
||||
</div>
|
||||
<span class="admin-toggle-sub" style="display:block;margin-top:6px;opacity:0.5">Set to 0 to disable skill injection.</span>
|
||||
</div>
|
||||
@@ -432,14 +432,14 @@
|
||||
|
||||
<!-- Theme Popup (floating panel) -->
|
||||
<div id="theme-modal" class="modal hidden">
|
||||
<div id="theme-popup" class="modal-content admin-modal-content" style="background:var(--bg)">
|
||||
<div id="theme-popup" class="modal-content admin-modal-content" role="dialog" aria-label="Theme" style="background:var(--bg)">
|
||||
<div class="modal-header theme-popup-header" id="theme-popup-header">
|
||||
<h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><circle cx="12" cy="12" r="10"/><path d="M12 2a7 7 0 0 0 0 20 4 4 0 0 1 0-8 4 4 0 0 0 0-8"/><circle cx="8" cy="9" r="1.5" fill="currentColor"/><circle cx="15" cy="14" r="1.5" fill="currentColor"/><circle cx="9" cy="15" r="1.5" fill="currentColor"/></svg>Theme</h4>
|
||||
<button type="button" class="theme-opacity-wrap theme-opacity-toggle hidden" id="theme-opacity-wrap" title="Fade this window to preview the page behind it" aria-pressed="false">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<span class="theme-opacity-label">Peek</span>
|
||||
</button>
|
||||
<button class="close-btn" id="close-theme-popup">✖</button>
|
||||
<button class="close-btn" id="close-theme-popup" aria-label="Close theme">✖</button>
|
||||
</div>
|
||||
<!-- Theme tabs -->
|
||||
<div class="admin-tabs" id="theme-tabs">
|
||||
@@ -464,12 +464,12 @@
|
||||
<div class="admin-card">
|
||||
<h2>Colors</h2>
|
||||
<div class="theme-custom" id="themeCustom">
|
||||
<div class="color-row"><label>Background</label><input type="color" id="clr-bg"><button class="color-reset-btn" data-reset="bg" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Text</label><input type="color" id="clr-fg"><button class="color-reset-btn" data-reset="fg" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Panel</label><input type="color" id="clr-panel"><button class="color-reset-btn" data-reset="panel" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Sidebar</label><input type="color" id="adv-sidebarBg"><button class="color-reset-btn" data-reset-adv="sidebarBg" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Border</label><input type="color" id="clr-border"><button class="color-reset-btn" data-reset="border" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Accent</label><input type="color" id="clr-red"><button class="color-reset-btn" data-reset="red" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Background</label><input type="color" id="clr-bg"><button class="color-reset-btn" data-reset="bg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Text</label><input type="color" id="clr-fg"><button class="color-reset-btn" data-reset="fg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Panel</label><input type="color" id="clr-panel"><button class="color-reset-btn" data-reset="panel" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Sidebar</label><input type="color" id="adv-sidebarBg"><button class="color-reset-btn" data-reset-adv="sidebarBg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Border</label><input type="color" id="clr-border"><button class="color-reset-btn" data-reset="border" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Accent</label><input type="color" id="clr-red"><button class="color-reset-btn" data-reset="red" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-adv-toggle" id="theme-adv-toggle">
|
||||
@@ -479,38 +479,38 @@
|
||||
<div class="theme-adv-group">
|
||||
<div class="theme-adv-group-label">Chat Bubbles</div>
|
||||
<div class="theme-custom">
|
||||
<div class="color-row"><label>User Chat Bubble</label><input type="color" id="adv-userBubbleBg"><button class="color-reset-btn" data-reset-adv="userBubbleBg" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>AI Chat Bubble</label><input type="color" id="adv-aiBubbleBg"><button class="color-reset-btn" data-reset-adv="aiBubbleBg" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Border Chat Bubble</label><input type="color" id="adv-bubbleBorder"><button class="color-reset-btn" data-reset-adv="bubbleBorder" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>User Chat Bubble</label><input type="color" id="adv-userBubbleBg"><button class="color-reset-btn" data-reset-adv="userBubbleBg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>AI Chat Bubble</label><input type="color" id="adv-aiBubbleBg"><button class="color-reset-btn" data-reset-adv="aiBubbleBg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Border Chat Bubble</label><input type="color" id="adv-bubbleBorder"><button class="color-reset-btn" data-reset-adv="bubbleBorder" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-adv-group">
|
||||
<div class="theme-adv-group-label">Sidebar</div>
|
||||
<div class="theme-custom">
|
||||
<div class="color-row"><label>Odysseus Logo</label><input type="color" id="adv-brandColor"><button class="color-reset-btn" data-reset-adv="brandColor" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label title="Hamburger menu"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></label><input type="color" id="adv-hamburgerColor"><button class="color-reset-btn" data-reset-adv="hamburgerColor" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Odysseus Logo</label><input type="color" id="adv-brandColor"><button class="color-reset-btn" data-reset-adv="brandColor" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label title="Hamburger menu"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></label><input type="color" id="adv-hamburgerColor"><button class="color-reset-btn" data-reset-adv="hamburgerColor" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-adv-group">
|
||||
<div class="theme-adv-group-label">Chat Input / Prompt Area</div>
|
||||
<div class="theme-custom">
|
||||
<div class="color-row"><label>Input Bg</label><input type="color" id="adv-inputBg"><button class="color-reset-btn" data-reset-adv="inputBg" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Input Border</label><input type="color" id="adv-inputBorder"><button class="color-reset-btn" data-reset-adv="inputBorder" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Send Btn</label><input type="color" id="adv-sendBtnBg"><button class="color-reset-btn" data-reset-adv="sendBtnBg" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Send Hover</label><input type="color" id="adv-sendBtnHover"><button class="color-reset-btn" data-reset-adv="sendBtnHover" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Input Bg</label><input type="color" id="adv-inputBg"><button class="color-reset-btn" data-reset-adv="inputBg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Input Border</label><input type="color" id="adv-inputBorder"><button class="color-reset-btn" data-reset-adv="inputBorder" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Send Btn</label><input type="color" id="adv-sendBtnBg"><button class="color-reset-btn" data-reset-adv="sendBtnBg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Send Hover</label><input type="color" id="adv-sendBtnHover"><button class="color-reset-btn" data-reset-adv="sendBtnHover" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-adv-group">
|
||||
<div class="theme-adv-group-label">Code Blocks</div>
|
||||
<div class="theme-custom">
|
||||
<div class="color-row"><label>Code Bg</label><input type="color" id="adv-codeBg"><button class="color-reset-btn" data-reset-adv="codeBg" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Code Text</label><input type="color" id="adv-codeFg"><button class="color-reset-btn" data-reset-adv="codeFg" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Code Bg</label><input type="color" id="adv-codeBg"><button class="color-reset-btn" data-reset-adv="codeBg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Code Text</label><input type="color" id="adv-codeFg"><button class="color-reset-btn" data-reset-adv="codeFg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-adv-group">
|
||||
<div class="theme-adv-group-label">Controls</div>
|
||||
<div class="theme-custom">
|
||||
<div class="color-row"><label>Toggle On</label><input type="color" id="adv-toggleActive"><button class="color-reset-btn" data-reset-adv="toggleActive" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Toggle On</label><input type="color" id="adv-toggleActive"><button class="color-reset-btn" data-reset-adv="toggleActive" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-adv-group">
|
||||
@@ -559,7 +559,7 @@
|
||||
<div class="theme-fd-row">
|
||||
<div class="theme-fd-group">
|
||||
<label class="theme-fd-label">Font</label>
|
||||
<select id="theme-font-select" class="theme-fd-select">
|
||||
<select id="theme-font-select" class="theme-fd-select" aria-label="Font">
|
||||
<option value="mono">Monospace</option>
|
||||
<option value="sans">Sans-serif</option>
|
||||
<option value="serif">Serif</option>
|
||||
@@ -567,7 +567,7 @@
|
||||
</div>
|
||||
<div class="theme-fd-group">
|
||||
<label class="theme-fd-label">Density</label>
|
||||
<select id="theme-density-select" class="theme-fd-select">
|
||||
<select id="theme-density-select" class="theme-fd-select" aria-label="Density">
|
||||
<option value="compact">Compact</option>
|
||||
<option value="comfortable">Comfortable</option>
|
||||
<option value="spacious">Spacious</option>
|
||||
@@ -981,7 +981,7 @@
|
||||
<input type="checkbox" id="research-toggle" style="display:none;">
|
||||
<input type="checkbox" id="rag-toggle" style="display:none;">
|
||||
<input type="checkbox" id="incognito-toggle" style="display:none;">
|
||||
<input type="file" id="file-input" class="hidden" multiple accept="image/*,application/pdf,video/*,.txt,.py,.html,.htm,.md,.json,.csv,.log,audio/*" />
|
||||
<input type="file" id="file-input" class="hidden" multiple />
|
||||
|
||||
<!-- Unified chat input bar -->
|
||||
<div class="chat-input-bar">
|
||||
@@ -993,7 +993,7 @@
|
||||
<button type="button" class="model-picker-btn" id="model-picker-btn" title="Switch model"><span id="model-picker-label">Select model</span> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 15 12 9 18 15"/></svg></button>
|
||||
<div class="model-picker-menu hidden" id="model-picker-menu">
|
||||
<div class="model-picker-search-row">
|
||||
<input type="text" id="model-picker-search" placeholder="Search models..." autocomplete="off">
|
||||
<input type="text" id="model-picker-search" placeholder="Search models..." autocomplete="off" aria-label="Search models">
|
||||
<button type="button" class="model-picker-action-btn primary" id="model-picker-add-models-btn" title="Add model endpoints" aria-label="Add model endpoints">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
||||
</button>
|
||||
@@ -1007,7 +1007,7 @@
|
||||
<div class="chat-input-left">
|
||||
<!-- Overflow menu (+) — always first/left -->
|
||||
<div class="overflow-wrapper">
|
||||
<button type="button" class="input-icon-btn overflow-plus-btn" id="overflow-plus-btn" title="More tools">
|
||||
<button type="button" class="input-icon-btn overflow-plus-btn" id="overflow-plus-btn" title="More tools" aria-label="More tools" aria-haspopup="true">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 15 12 9 18 15"/>
|
||||
</svg>
|
||||
@@ -1051,13 +1051,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Web search (magnifying glass) -->
|
||||
<button type="button" class="input-icon-btn" title="Web search" id="web-toggle-btn" data-mode-tool="true">
|
||||
<button type="button" class="input-icon-btn" title="Web search" id="web-toggle-btn" data-mode-tool="true" aria-label="Web search" aria-pressed="false">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Shell commands (terminal) -->
|
||||
<button type="button" class="input-icon-btn" title="Shell Access" id="bash-toggle-btn" data-mode-tool="true">
|
||||
<button type="button" class="input-icon-btn" title="Shell Access" id="bash-toggle-btn" data-mode-tool="true" aria-label="Shell access" aria-pressed="false">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
@@ -1084,7 +1084,7 @@
|
||||
</button>
|
||||
<input type="checkbox" id="group-toggle" style="display:none;">
|
||||
<!-- Character indicator (hidden until active) -->
|
||||
<button type="button" class="input-icon-btn tool-indicator" title="Character active — click to deactivate" id="character-indicator-btn" style="display:none;">
|
||||
<button type="button" class="input-icon-btn tool-indicator" title="Persona active — click to deactivate" id="character-indicator-btn" style="display:none;">
|
||||
<svg id="char-indicator-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
<span id="character-indicator-name" style="font-size:11px;margin-left:2px;max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
|
||||
<svg class="tool-indicator-x" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
||||
@@ -1099,8 +1099,8 @@
|
||||
<div class="chat-input-right">
|
||||
<!-- Agent / Chat mode toggle -->
|
||||
<div class="mode-toggle">
|
||||
<button type="button" class="mode-toggle-btn active" id="mode-agent-btn">Agent</button>
|
||||
<button type="button" class="mode-toggle-btn" id="mode-chat-btn">Chat</button>
|
||||
<button type="button" class="mode-toggle-btn active" id="mode-agent-btn" aria-pressed="true">Agent</button>
|
||||
<button type="button" class="mode-toggle-btn" id="mode-chat-btn" aria-pressed="false">Chat</button>
|
||||
</div>
|
||||
<button type="submit" form="chat-form" class="send-btn newchat-mode" data-mode="newchat" aria-label="New chat">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg><span class="send-btn-label">+ New</span>
|
||||
@@ -1115,16 +1115,16 @@
|
||||
|
||||
<!-- Character (custom preset) modal -->
|
||||
<div id="custom-preset-modal" class="modal hidden">
|
||||
<div class="modal-content preset-modal-content" style="background:var(--bg)">
|
||||
<div class="modal-content preset-modal-content" role="dialog" aria-label="Prompt" style="background:var(--bg)">
|
||||
<div class="modal-header">
|
||||
<h4><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="m18 2 4 4"/><path d="m17 7 3-3"/><path d="M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5"/><path d="m9 11 4 4"/><path d="m5 19-3 3"/><path d="m14 4 6 6"/></svg>Prompt</h4>
|
||||
<button class="close-btn" id="close-custom-preset">✖</button>
|
||||
<button class="close-btn" id="close-custom-preset" aria-label="Close prompt">✖</button>
|
||||
</div>
|
||||
<div class="modal-body preset-modal-body">
|
||||
<div id="char-fields-wrap">
|
||||
<div class="preset-tabs">
|
||||
<button class="preset-tab active" data-chartab="inject"><svg class="preset-tab-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 2 4 4"/><path d="m17 7 3-3"/><path d="M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5"/><path d="m9 11 4 4"/><path d="m5 19-3 3"/><path d="m14 4 6 6"/></svg><span>Inject</span></button>
|
||||
<button class="preset-tab" data-chartab="character"><svg class="preset-tab-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span>Character</span></button>
|
||||
<button class="preset-tab" data-chartab="character"><svg class="preset-tab-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span>Persona</span></button>
|
||||
<button class="preset-tab" data-chartab="group"><svg class="preset-tab-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg><span>Group</span></button>
|
||||
</div>
|
||||
<!-- Inject tab (also holds model tuning: temperature + max tokens) -->
|
||||
@@ -1151,25 +1151,25 @@
|
||||
</div>
|
||||
<!-- Prompt (character/persona) tab -->
|
||||
<div class="preset-chartab" data-chartab-panel="character" style="display:none">
|
||||
<label>Character</label>
|
||||
<label>Persona</label>
|
||||
<div class="char-name-combo">
|
||||
<select id="char-template-select" class="char-template-select">
|
||||
<option value="">Select character...</option>
|
||||
<option value="">Select persona...</option>
|
||||
</select>
|
||||
<button type="button" id="char-new-btn" class="char-action-btn" title="Create a new character">+ New</button>
|
||||
<button type="button" id="char-new-btn" class="char-action-btn" title="Create a new persona">+ New</button>
|
||||
</div>
|
||||
<div id="char-name-row">
|
||||
<label for="custom-character-name">Name</label>
|
||||
<div class="char-name-combo">
|
||||
<input type="text" id="custom-character-name" maxlength="50" placeholder="Give your character a name..." autocomplete="off" style="flex:1">
|
||||
<button type="button" id="char-delete-template-btn" class="char-action-btn" title="Delete this character and its memories" style="display:none;margin-top:-6px !important"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>Delete</button>
|
||||
<input type="text" id="custom-character-name" maxlength="50" placeholder="Give your persona a name..." autocomplete="off" style="flex:1">
|
||||
<button type="button" id="char-delete-template-btn" class="char-action-btn" title="Delete this persona and its memories" style="display:none;margin-top:-6px !important"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>Delete</button>
|
||||
<button type="button" id="reset-character-btn" class="char-action-btn" title="Reset to default" style="margin-top:-6px !important">↺ Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<label for="custom-system-prompt">Style of response</label>
|
||||
<label for="custom-system-prompt">System prompt</label>
|
||||
<div class="char-prompt-wrap">
|
||||
<textarea id="custom-system-prompt" rows="4" placeholder="Write rough notes and click Expand, or leave empty"></textarea>
|
||||
<button type="button" id="char-expand-btn" class="char-expand-btn" title="AI expand — turn your notes into a full character prompt">
|
||||
<button type="button" id="char-expand-btn" class="char-expand-btn" title="AI expand — turn your notes into a full system prompt">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:2px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
|
||||
Expand
|
||||
</button>
|
||||
@@ -1262,7 +1262,7 @@
|
||||
|
||||
<!-- Rename Session Modal -->
|
||||
<div id="rename-session-modal" class="modal hidden">
|
||||
<div class="modal-content" style="width: 400px;">
|
||||
<div class="modal-content" role="dialog" aria-label="Rename session" style="width: 400px;">
|
||||
<div class="modal-header">
|
||||
<h4>Rename Session</h4>
|
||||
<button class="close-btn" id="close-rename-session" aria-label="Close rename session modal">✖</button>
|
||||
@@ -1288,10 +1288,10 @@
|
||||
|
||||
<!-- Cookbook Modal -->
|
||||
<div id="cookbook-modal" class="modal hidden">
|
||||
<div class="modal-content" style="width: min(780px, 92vw); height: 94vh; max-height: 94vh; background: var(--bg);">
|
||||
<div class="modal-content" role="dialog" aria-label="Cookbook" style="width: min(780px, 92vw); height: 94vh; max-height: 94vh; background: var(--bg);">
|
||||
<div class="modal-header">
|
||||
<h4 style="margin:0;margin-right:auto"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>Cookbook</h4>
|
||||
<button class="close-btn" id="close-cookbook-modal">✖</button>
|
||||
<button class="close-btn" id="close-cookbook-modal" aria-label="Close cookbook">✖</button>
|
||||
</div>
|
||||
<div class="modal-body cookbook-body"></div>
|
||||
</div>
|
||||
@@ -1299,14 +1299,14 @@
|
||||
|
||||
<!-- Settings Modal (all users) -->
|
||||
<div id="settings-modal" class="modal hidden">
|
||||
<div class="modal-content settings-modal-content">
|
||||
<div class="modal-content settings-modal-content" role="dialog" aria-label="Settings">
|
||||
<div class="modal-header">
|
||||
<h4><span style="vertical-align:-1px;margin-right:6px;font-size:15px">⚙</span>Settings</h4>
|
||||
<button type="button" class="theme-opacity-wrap theme-opacity-toggle hidden" id="settings-opacity-wrap" title="Fade this window to preview the page behind it" aria-pressed="false">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
|
||||
<span class="theme-opacity-label">Peek</span>
|
||||
</button>
|
||||
<button class="close-btn">✖</button>
|
||||
<button class="close-btn" aria-label="Close settings">✖</button>
|
||||
</div>
|
||||
<div class="admin-toggle-sub" style="padding:0 12px 8px;opacity:0.6;font-size:11px;">Toggle on/off visibility of tools and modules across the interface.</div>
|
||||
<div class="settings-layout">
|
||||
@@ -1463,6 +1463,10 @@
|
||||
<label class="settings-label">Extract Parallel</label>
|
||||
<input id="set-researchExtractConcurrency" type="text" inputmode="numeric" placeholder="3" class="settings-select" style="width:120px;">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Max Time</label>
|
||||
<input id="set-researchRunTimeout" type="text" inputmode="numeric" placeholder="1800 sec (0 = no limit)" class="settings-select" style="width:120px;">
|
||||
</div>
|
||||
<div id="set-researchMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1602,12 +1606,16 @@
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Results</label>
|
||||
<select id="set-searchResultCount" class="settings-select">
|
||||
<option value="3">3</option>
|
||||
<option value="5" selected>5</option>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
</select>
|
||||
<div style="display:flex;gap:8px;flex:1;">
|
||||
<select id="set-searchResultCount" class="settings-select" style="flex:1;">
|
||||
<option value="3">3</option>
|
||||
<option value="5" selected>5</option>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
<input id="set-searchResultCountCustom" type="number" class="settings-select" placeholder="Enter custom value" style="flex:1;display:none;min-width:120px;" min="1" max="100">
|
||||
</div>
|
||||
</div>
|
||||
<div id="set-searchUrlRow" class="settings-row">
|
||||
<label class="settings-label">URL</label>
|
||||
@@ -1808,7 +1816,7 @@
|
||||
</label>
|
||||
<label class="vis-row">
|
||||
<span class="vis-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></span>
|
||||
<span class="vis-label">Characters <span class="vis-hint">Persona picker & system prompt</span></span>
|
||||
<span class="vis-label">Personas <span class="vis-hint">Persona picker & system prompt</span></span>
|
||||
<input type="checkbox" checked data-ui-key="preset-mini-btn"><span class="vis-switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -2010,6 +2018,9 @@
|
||||
<option value="image">Image</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="admin-model-form-row">
|
||||
<input id="adm-epLocalApiKey" type="password" placeholder="API key (optional — for protected local endpoints)" autocomplete="off" style="flex:1">
|
||||
</div>
|
||||
<div class="admin-model-form-row">
|
||||
<span style="flex:1"></span>
|
||||
<button class="admin-btn-sm" id="adm-epLocalTestBtn" style="width:55px;text-align:center;">Test</button>
|
||||
@@ -2064,6 +2075,7 @@
|
||||
<option value="https://generativelanguage.googleapis.com/v1beta/openai" data-logo="gemini">Google Gemini</option>
|
||||
<option value="https://api.x.ai/v1" data-logo="grok">xAI Grok</option>
|
||||
<option value="https://api.z.ai/api/paas/v4" data-logo="zhipu">Z.AI (Zhipu)</option>
|
||||
<option value="https://api.z.ai/api/coding/paas/v4" data-logo="zhipu">Z.AI Coding Plan</option>
|
||||
</select>
|
||||
<div class="admin-model-form-row">
|
||||
<input id="adm-epApiKey" type="password" placeholder="API key">
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
## Purpose
|
||||
This document describes what each JavaScript module is responsible for.
|
||||
|
||||
> **Note:** This file is a partial, historical overview — not a complete authoritative
|
||||
> inventory. The authoritative module set is the current `static/js/` tree plus the
|
||||
> scripts loaded by `static/index.html`. As of this writing that tree holds **65 `.js`
|
||||
> files** across **8 subdirectories** (`calendar/`, `color/`, `compare/`, `editor/`,
|
||||
> `emailLibrary/`, `markdown/`, `research/`, `util/`), and `static/index.html` loads
|
||||
> **35** `/static…` script tags. The catalog below covers only the original core
|
||||
> modules and is not kept in sync with every module.
|
||||
|
||||
---
|
||||
|
||||
## Core Modules (in static/js/)
|
||||
@@ -23,7 +31,7 @@ This document describes what each JavaScript module is responsible for.
|
||||
- Content rendering for message arrays
|
||||
- Text cleanup (`squashOutsideCode`)
|
||||
|
||||
### 3. **session.js**
|
||||
### 3. **sessions.js**
|
||||
- Session/chat management
|
||||
- Create, load, delete, switch sessions
|
||||
- Session history loading
|
||||
@@ -54,7 +62,7 @@ This document describes what each JavaScript module is responsible for.
|
||||
|
||||
### 7. **models.js**
|
||||
- Model scanning and display
|
||||
- Local model discovery (ports 8000-8010)
|
||||
- Local model discovery (ports 8000-8020)
|
||||
- Provider management (OpenAI)
|
||||
- Model selection UI
|
||||
|
||||
|
||||
+8
-1
@@ -871,11 +871,14 @@ function initEndpointForm() {
|
||||
const raw = (el('adm-epLocalUrl').value || '').trim();
|
||||
if (!raw) { msg.textContent = 'Enter a base URL to test'; msg.className = 'admin-error'; return; }
|
||||
const url = _normalizeBaseUrl(raw);
|
||||
const keyEl = el('adm-epLocalApiKey');
|
||||
const apiKey = keyEl ? keyEl.value.trim() : '';
|
||||
localTestBtn.disabled = true;
|
||||
localTestBtn.textContent = 'Testing...';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('base_url', url);
|
||||
if (apiKey) fd.append('api_key', apiKey);
|
||||
const res = await fetch('/api/model-endpoints/test', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||||
const d = await res.json();
|
||||
_renderEndpointTestResult(msg, res, d);
|
||||
@@ -894,10 +897,13 @@ function initEndpointForm() {
|
||||
const raw = (el('adm-epLocalUrl').value || '').trim();
|
||||
if (!raw) { msg.textContent = 'Enter a base URL (e.g. http://localhost:8002/v1)'; msg.className = 'admin-error'; return; }
|
||||
const url = _normalizeBaseUrl(raw);
|
||||
const keyEl = el('adm-epLocalApiKey');
|
||||
const apiKey = keyEl ? keyEl.value.trim() : '';
|
||||
localAddBtn.disabled = true; localAddBtn.textContent = 'Adding...';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('base_url', url);
|
||||
if (apiKey) fd.append('api_key', apiKey);
|
||||
const lt = el('adm-epLocalType');
|
||||
if (lt) fd.append('model_type', lt.value);
|
||||
fd.append('skip_probe', 'false');
|
||||
@@ -905,6 +911,7 @@ function initEndpointForm() {
|
||||
const d = await res.json();
|
||||
if (res.ok) {
|
||||
el('adm-epLocalUrl').value = '';
|
||||
if (keyEl) keyEl.value = '';
|
||||
if (lt) lt.value = 'llm';
|
||||
if (d.id) _recentlyAddedEpId = String(d.id);
|
||||
await loadEndpoints();
|
||||
@@ -968,7 +975,7 @@ function initEndpointForm() {
|
||||
const data = await res.json();
|
||||
const items = data.items || [];
|
||||
if (!items.length) {
|
||||
msg.textContent = 'No model servers found. Make sure vLLM, llama.cpp, SGLang, or Ollama is running. Docker users may need OLLAMA_HOST=0.0.0.0:11434.';
|
||||
msg.textContent = 'No model servers found. Make sure vLLM, llama.cpp, SGLang, or Ollama is running. Docker users may need Ollama bound to a trusted reachable interface.';
|
||||
msg.className = 'admin-error';
|
||||
} else {
|
||||
// Auto-add each discovered endpoint. Server dedupes on base_url
|
||||
|
||||
@@ -180,7 +180,7 @@ function _renderSettingsBody(body, data, tzList) {
|
||||
<div class="assistant-field">
|
||||
<span style="display:flex;align-items:center;gap:8px;">Personality
|
||||
<select id="assistant-character-pick" style="font-size:11px;padding:1px 6px;border:1px solid var(--border);border-radius:3px;background:var(--bg);color:var(--fg);max-width:180px;">
|
||||
<option value="">-- pick from character --</option>
|
||||
<option value="">-- pick from persona --</option>
|
||||
</select>
|
||||
</span>
|
||||
<textarea id="assistant-personality" rows="6" placeholder="Describe the assistant's personality, tone, and behavior...">${_esc(crew.personality || '')}</textarea>
|
||||
@@ -293,7 +293,7 @@ function _renderSettingsBody(body, data, tzList) {
|
||||
allPresets.push(...presetsRaw);
|
||||
}
|
||||
const allTemplates = Array.isArray(templates) ? templates : [];
|
||||
let opts = '<option value="">-- pick from character --</option>';
|
||||
let opts = '<option value="">-- pick from persona --</option>';
|
||||
if (allPresets.length) {
|
||||
opts += '<optgroup label="Presets">';
|
||||
for (const p of allPresets) {
|
||||
@@ -304,7 +304,7 @@ function _renderSettingsBody(body, data, tzList) {
|
||||
opts += '</optgroup>';
|
||||
}
|
||||
if (allTemplates.length) {
|
||||
opts += '<optgroup label="Characters">';
|
||||
opts += '<optgroup label="Personas">';
|
||||
for (const t of allTemplates) {
|
||||
if (!t.system_prompt && !t.personality) continue;
|
||||
const name = t.character_name || t.name || 'Unnamed';
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
|
||||
_trashIcon, _moreIcon, _bellIcon,
|
||||
_isCalBgImage, _calBgImageUrl, _calBgCss,
|
||||
_calReadableTextColor,
|
||||
_ds, _addDays, _shiftDT, _tzOffset, _localDateOf,
|
||||
} from './calendar/utils.js';
|
||||
|
||||
@@ -371,6 +372,10 @@ function _calColor(ev) {
|
||||
return c?.color || 'var(--accent)';
|
||||
}
|
||||
|
||||
function _calEventFg(ev) {
|
||||
return _calReadableTextColor(_calColor(ev));
|
||||
}
|
||||
|
||||
// Extra inline style for an event row when the event has a custom BG image.
|
||||
// Returns '' for normal solid-color events.
|
||||
function _calItemBgStyle(ev) {
|
||||
@@ -975,7 +980,7 @@ async function _renderMonth() {
|
||||
const startColInt = Math.round(startCol);
|
||||
const endColInt = Math.round(endCol);
|
||||
const span = endColInt - startColInt + 1;
|
||||
h += `<div class="cal-multiday" style="--col:${startColInt};--span:${span};--slot:${barSlot};background:${_calColor(md)}" draggable="true" data-uid="${_e(md.uid)}" title="${_e(md.summary)}">${_e(md.summary)}</div>`;
|
||||
h += `<div class="cal-multiday" style="--col:${startColInt};--span:${span};--slot:${barSlot};background:${_calColor(md)};--cal-event-fg:${_calEventFg(md)}" draggable="true" data-uid="${_e(md.uid)}" title="${_e(md.summary)}">${_e(md.summary)}</div>`;
|
||||
barSlot++;
|
||||
}
|
||||
h += '</div>';
|
||||
@@ -1141,7 +1146,7 @@ async function _renderWeek() {
|
||||
// All-day strip
|
||||
colsHtml += `<div class="cal-wk-allday">`;
|
||||
for (const ev of allDayEvents) {
|
||||
colsHtml += `<div class="cal-wk-allday-event" data-uid="${_e(ev.uid)}" style="background:${_calColor(ev)};" title="${_e(ev.summary)}">${_e(ev.summary)}</div>`;
|
||||
colsHtml += `<div class="cal-wk-allday-event" data-uid="${_e(ev.uid)}" style="background:${_calColor(ev)};--cal-event-fg:${_calEventFg(ev)};" title="${_e(ev.summary)}">${_e(ev.summary)}</div>`;
|
||||
}
|
||||
colsHtml += `</div>`;
|
||||
// Hour-grid body
|
||||
|
||||
@@ -74,6 +74,42 @@ export function _calBgCss(c, fallback) {
|
||||
return c || fallback || 'var(--accent)';
|
||||
}
|
||||
|
||||
function _hexToRgb(c) {
|
||||
if (typeof c !== 'string') return null;
|
||||
const m = c.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
||||
if (!m) return null;
|
||||
const hex = m[1].length === 3
|
||||
? m[1].split('').map(ch => ch + ch).join('')
|
||||
: m[1];
|
||||
return {
|
||||
r: parseInt(hex.slice(0, 2), 16),
|
||||
g: parseInt(hex.slice(2, 4), 16),
|
||||
b: parseInt(hex.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function _relativeLuminance({ r, g, b }) {
|
||||
return [r, g, b].map(v => {
|
||||
const c = v / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
}).reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i], 0);
|
||||
}
|
||||
|
||||
function _contrastRatio(a, b) {
|
||||
const light = Math.max(a, b);
|
||||
const dark = Math.min(a, b);
|
||||
return (light + 0.05) / (dark + 0.05);
|
||||
}
|
||||
|
||||
export function _calReadableTextColor(bg) {
|
||||
const rgb = _hexToRgb(bg);
|
||||
if (!rgb) return 'var(--fg)';
|
||||
const lum = _relativeLuminance(rgb);
|
||||
const white = _contrastRatio(lum, 1);
|
||||
const ink = _contrastRatio(lum, 0.006);
|
||||
return ink >= white ? '#111820' : '#ffffff';
|
||||
}
|
||||
|
||||
// ── date helpers ──
|
||||
|
||||
// `YYYY-MM-DD` string from a Date.
|
||||
@@ -82,13 +118,17 @@ export function _ds(d) {
|
||||
}
|
||||
|
||||
export function _addDays(dateStr, n) {
|
||||
if (typeof dateStr !== 'string' || !dateStr) return '';
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
if (isNaN(d)) return '';
|
||||
d.setDate(d.getDate() + n);
|
||||
return _ds(d);
|
||||
}
|
||||
|
||||
export function _shiftDT(iso, days) {
|
||||
if (typeof iso !== 'string' || !iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return '';
|
||||
d.setDate(d.getDate() + days);
|
||||
return _ds(d) + (iso.length > 10 ? 'T' + iso.slice(11) : '');
|
||||
}
|
||||
@@ -111,7 +151,7 @@ export function _tzOffset() {
|
||||
// bucket by the USER's local date. Without this an event at
|
||||
// "2026-05-13T22:00:00Z" (07:00 May 14 JST) would render on May 13.
|
||||
export function _localDateOf(isoStr) {
|
||||
if (!isoStr) return '';
|
||||
if (typeof isoStr !== 'string' || !isoStr) return '';
|
||||
if (isoStr.length === 10) return isoStr;
|
||||
if (/[Zz]$|[+\-]\d{2}:?\d{2}$/.test(isoStr)) {
|
||||
const d = new Date(isoStr);
|
||||
|
||||
+7
-1
@@ -8,7 +8,13 @@
|
||||
let _enabled = true;
|
||||
let _observer = null;
|
||||
const PREF_KEY = 'odysseus-sensitive-blur';
|
||||
const _prefEnabled = () => localStorage.getItem(PREF_KEY) === 'on';
|
||||
export const _prefEnabled = () => {
|
||||
try {
|
||||
return localStorage.getItem(PREF_KEY) === 'on';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Patterns that indicate sensitive data
|
||||
const PATTERNS = [
|
||||
|
||||
+52
-8
@@ -457,6 +457,8 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
const ok = await sessionModule.materializePendingSession();
|
||||
if (!ok || !sessionModule.getCurrentSessionId()) { _releaseSendFlag(); return; }
|
||||
} else {
|
||||
el('message').value = '';
|
||||
if (uiModule.autoResize) uiModule.autoResize(el('message'));
|
||||
addMessage('assistant',
|
||||
'No chat session active. You can:\n\n' +
|
||||
'- Open the model picker in the chat box and pick a model\n' +
|
||||
@@ -466,6 +468,8 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
el('message').value = '';
|
||||
if (uiModule.autoResize) uiModule.autoResize(el('message'));
|
||||
addMessage('assistant',
|
||||
'No chat session active. You can:\n\n' +
|
||||
'- Open the model picker in the chat box and pick a model\n' +
|
||||
@@ -512,6 +516,10 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
|
||||
// Declare accumulated outside try block so it's accessible in catch
|
||||
let accumulated = '';
|
||||
// Are we currently inside an unclosed <think> block? Toggled per think/answer
|
||||
// cycle so a multi-round agent response (one reasoning phase PER round) wraps each
|
||||
// round's reasoning in its own <think>…</think> instead of leaking rounds 2+ as text.
|
||||
let _thinkOpen = false;
|
||||
let holder = null;
|
||||
let finalMeta = null;
|
||||
let finalModelName = null;
|
||||
@@ -960,6 +968,11 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark the chat log busy while streaming so screen readers wait for the
|
||||
// settled response instead of announcing every token. Cleared in finally.
|
||||
const _chatLog = document.getElementById('chat-history');
|
||||
if (_chatLog) _chatLog.setAttribute('aria-busy', 'true');
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
@@ -1357,12 +1370,15 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
if (_threadAbove && _threadAbove.classList.contains('agent-thread') && !_threadAbove.classList.contains('has-bottom')) {
|
||||
_threadAbove.classList.add('has-bottom');
|
||||
}
|
||||
// VLLM reasoning tokens: wrap in <think> tags for the thinking UI
|
||||
// VLLM reasoning tokens: wrap in <think> tags for the thinking UI.
|
||||
// Stateful open/close (not a whole-message substring check) so each round
|
||||
// of a multi-round agent response gets its own <think>…</think> — otherwise
|
||||
// only round 1 is wrapped and rounds 2+ reasoning leaks into the answer.
|
||||
let _delta = json.delta;
|
||||
if (json.thinking) {
|
||||
if (!accumulated.includes('<think>')) _delta = '<think>' + _delta;
|
||||
} else if (accumulated.includes('<think>') && !accumulated.includes('</think>')) {
|
||||
_delta = '</think>' + _delta;
|
||||
if (!_thinkOpen) { _delta = '<think>' + _delta; _thinkOpen = true; }
|
||||
} else if (_thinkOpen) {
|
||||
_delta = '</think>' + _delta; _thinkOpen = false;
|
||||
}
|
||||
const wasEmpty = !accumulated;
|
||||
accumulated += _delta;
|
||||
@@ -1771,6 +1787,26 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
if (tsSpan) roleEl.appendChild(tsSpan);
|
||||
}
|
||||
}
|
||||
} else if (json.type === 'fallback') {
|
||||
// The selected model failed and another provider answered. Make
|
||||
// it visible so a misconfigured provider is never silently
|
||||
// masked under the selected model's name.
|
||||
if (!_isBg) {
|
||||
var _selM = _shortModel(json.selected_model || '');
|
||||
var _ansM = _shortModel(json.answered_by || '');
|
||||
uiModule.showToast('⚠ ' + _selM + ' failed — answered by ' + _ansM, 6000);
|
||||
if (holder) {
|
||||
var _rEl = holder.querySelector('.role');
|
||||
if (_rEl) {
|
||||
var _tsS = _rEl.querySelector('.role-timestamp');
|
||||
_rEl.textContent = _ansM + ' (fallback) ';
|
||||
_rEl.title = (json.selected_model || '') + ' failed' +
|
||||
(json.reason ? ': ' + json.reason : '') + ' — answered by ' + (json.answered_by || '');
|
||||
_applyModelColor(_rEl, json.answered_by);
|
||||
if (_tsS) _rEl.appendChild(_tsS);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (json.type === 'attachments') {
|
||||
if (_isBg) continue;
|
||||
// Update user bubble — replace file chips with image previews
|
||||
@@ -2675,6 +2711,9 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
}
|
||||
} finally {
|
||||
clearProcessingProbe();
|
||||
// Streaming done — let screen readers announce the settled response.
|
||||
const _chatLogDone = document.getElementById('chat-history');
|
||||
if (_chatLogDone) _chatLogDone.setAttribute('aria-busy', 'false');
|
||||
// Always clean up research tracking regardless of background state
|
||||
_researchingStreamIds.delete(streamSessionId);
|
||||
if (_researchingStreamIds.size === 0) {
|
||||
@@ -3389,7 +3428,7 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
|
||||
// Also submit on Enter (without shift)
|
||||
editor.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
saveBtn.click();
|
||||
}
|
||||
@@ -4002,8 +4041,11 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
const clickedIndex = allMsgs.indexOf(msgElement);
|
||||
if (clickedIndex < 0) return;
|
||||
|
||||
// No early-out on a missing session: an output shown before any model was
|
||||
// selected (issue #1428) has no session/persisted rows, but its "x" must
|
||||
// still remove it. We only need the session id for the server-side delete
|
||||
// below; without one we fall back to removing the DOM.
|
||||
const sessionId = sessionModule.getCurrentSessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
const clickedIsUser = msgElement.classList.contains('msg-user');
|
||||
|
||||
@@ -4079,8 +4121,10 @@ import createResearchSynapse from './researchSynapse.js';
|
||||
}
|
||||
}
|
||||
|
||||
if (!msgIds.length) {
|
||||
// Fallback: just remove DOM elements if no DB IDs available
|
||||
if (!msgIds.length || !sessionId) {
|
||||
// No persisted rows to delete (no DB IDs, or no session at all — e.g. an
|
||||
// error output shown before a model was selected, #1428). Just remove the
|
||||
// DOM so the "x" works regardless.
|
||||
domToRemove.forEach(el => el.remove());
|
||||
if (uiModule) uiModule.showToast('Message deleted');
|
||||
return;
|
||||
|
||||
@@ -659,6 +659,12 @@ export function isLocalEndpoint(url) {
|
||||
if (!host) return true;
|
||||
if (host === 'localhost' || host === '0.0.0.0' || host === 'host.docker.internal' || host.endsWith('.local')) return true;
|
||||
if (typeof window !== 'undefined' && window.location && host === window.location.hostname) return true;
|
||||
// A single-label hostname (no dot) is an internal/Docker service name
|
||||
// (e.g. "nim-nano", "llamaswap", "nemotron-super-49b") or a LAN shortname —
|
||||
// never a public API, which always needs an FQDN. Treat as local → free.
|
||||
// (Without this, container-name endpoints get billed at cloud rates because
|
||||
// the pricing table matches on a name substring, e.g. "nemotron".)
|
||||
if (!host.includes('.')) return true;
|
||||
if (/^127\./.test(host)) return true;
|
||||
if (/^10\./.test(host)) return true;
|
||||
if (/^192\.168\./.test(host)) return true;
|
||||
@@ -1211,6 +1217,17 @@ export function showWelcomeScreen() {
|
||||
const cc = document.getElementById('chat-container');
|
||||
if (ws) ws.classList.remove('hidden');
|
||||
if (cc) cc.classList.add('welcome-active');
|
||||
// Entering the New Chat / welcome state: discard any stale draft left in the
|
||||
// composer from the previous session so the input starts empty (issue #1343).
|
||||
// Switching between existing sessions loads them directly and does NOT call
|
||||
// this, so genuine drafts are not erased. Reset the autosized height and fire
|
||||
// an `input` event so the send button + autosize listeners update.
|
||||
const _msg = document.getElementById('message');
|
||||
if (_msg) {
|
||||
_msg.value = '';
|
||||
_msg.style.height = '';
|
||||
_msg.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
// Re-trigger the L→R clip-wipe reveal on the welcome name each time the
|
||||
// welcome screen is shown (new session, deleted last session, etc.) — without
|
||||
// this, the CSS animation only fires on initial DOM insertion.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// static/js/color/hex.js
|
||||
//
|
||||
// Parse a CSS hex color into {r, g, b}. Pure — no DOM — so it can be reused
|
||||
// across modules and unit-tested under node.
|
||||
|
||||
// Accepts "#rgb", "#rrggbb" (with or without the leading '#'). Returns null
|
||||
// for anything that isn't a valid 3- or 6-digit hex color.
|
||||
export function hexToRgb(hex) {
|
||||
let h = String(hex || '').trim().replace(/^#/, '');
|
||||
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(h)) return null;
|
||||
const n = parseInt(h, 16);
|
||||
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
|
||||
}
|
||||
+104
-15
@@ -213,6 +213,8 @@ export function _renderGpuToggles(system) {
|
||||
if (quantSel && quantSel.value !== '') {
|
||||
if (count <= 1) {
|
||||
quantSel.value = 'Q4_K_M'; // RAM or 1 GPU -> Q4 sweet spot
|
||||
} else if (String(system?.backend || '').toLowerCase() === 'rocm') {
|
||||
quantSel.value = 'Q4_K_M'; // ROCm default stays GGUF/local-safe; AWQ is explicit only
|
||||
} else {
|
||||
quantSel.value = 'AWQ-4bit'; // Multi-GPU -> AWQ for vLLM
|
||||
}
|
||||
@@ -244,11 +246,13 @@ function _ctxLabel(value) {
|
||||
if (!n) return 'Max';
|
||||
return n >= 1000 ? Math.round(n / 1000) + 'k' : String(n);
|
||||
}
|
||||
|
||||
function _ctxValue() {
|
||||
const slider = document.getElementById('hwfit-context');
|
||||
const idx = Math.max(0, Math.min(_CTX_PRESETS.length - 1, Number(slider?.value ?? 3) || 0));
|
||||
return _CTX_PRESETS[idx] || 0;
|
||||
}
|
||||
|
||||
function _syncCtxControl() {
|
||||
const slider = document.getElementById('hwfit-context');
|
||||
const label = document.getElementById('hwfit-context-label');
|
||||
@@ -359,6 +363,7 @@ function _scanSig() {
|
||||
o: sortEl?.value || 'score',
|
||||
r: sortEl?.dataset.reverse === '1' ? 1 : 0,
|
||||
q: document.getElementById('hwfit-quant')?.value || '',
|
||||
c: _ctxValue(),
|
||||
g: (tc && typeof tc._activeCount === 'number') ? String(tc._activeCount) : '',
|
||||
gg: (tc && tc._activeGroup) ? String(tc._activeGroup) : '',
|
||||
m: _manualHwParams(),
|
||||
@@ -408,6 +413,17 @@ function _hwfitShowError(list, host, detail) {
|
||||
if (rb) rb.addEventListener('click', () => { _resetGpuToggleState(); _hwfitFetch(true); });
|
||||
}
|
||||
|
||||
// Client-side "Engine" filter (llama.cpp / vLLM / SGLang). Empty = show all.
|
||||
// Uses the same _detectBackend() the serve commands use, so what you filter to
|
||||
// is exactly what would be launched. Pure view filter — no refetch needed.
|
||||
function _applyEngineFilter(models) {
|
||||
const want = document.getElementById('hwfit-engine')?.value || '';
|
||||
if (!want || !Array.isArray(models)) return models || [];
|
||||
return models.filter(m => {
|
||||
try { return _detectBackend(m).backend === want; } catch { return true; }
|
||||
});
|
||||
}
|
||||
|
||||
export async function _hwfitFetch(fresh = false) {
|
||||
const _tk = ++_hwfitFetchToken;
|
||||
const useCase = document.getElementById('hwfit-usecase')?.value || '';
|
||||
@@ -427,7 +443,7 @@ export async function _hwfitFetch(fresh = false) {
|
||||
if (_cached) {
|
||||
_hwfitCache = _cached;
|
||||
_hwfitRenderHw(hw, _cached.system);
|
||||
_hwfitRenderList(list, _cached.models);
|
||||
_hwfitRenderList(list, _applyEngineFilter(_cached.models));
|
||||
} else {
|
||||
// Show spinner while scanning — stack the spinner above a text label
|
||||
// (the .hwfit-loading class is a centered flex ROW, so force column here).
|
||||
@@ -456,7 +472,9 @@ export async function _hwfitFetch(fresh = false) {
|
||||
fetch(`/api/model/cached?${_cacheParams}`, { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
_cachedModelIds = new Set((d.models || []).map(m => m.repo_id));
|
||||
// Exclude stalled (download-shell) entries — a 12 KB README-only
|
||||
// folder shouldn't count as "downloaded" in the Scan/Download list.
|
||||
_cachedModelIds = new Set((d.models || []).filter(m => m.status !== 'stalled').map(m => m.repo_id));
|
||||
// Re-mark rows if already rendered
|
||||
list.querySelectorAll('.hwfit-row[data-model]').forEach(row => {
|
||||
const name = row.dataset.model;
|
||||
@@ -472,6 +490,7 @@ export async function _hwfitFetch(fresh = false) {
|
||||
try {
|
||||
const sortBy = document.getElementById('hwfit-sort')?.value || 'score';
|
||||
const quantPref = document.getElementById('hwfit-quant')?.value || '';
|
||||
const targetCtx = _ctxValue();
|
||||
// Get active GPU count from toggles
|
||||
const toggleContainer = document.getElementById('hwfit-gpu-toggles');
|
||||
let gpuCountOverride = '';
|
||||
@@ -507,6 +526,7 @@ export async function _hwfitFetch(fresh = false) {
|
||||
if (!isImageMode) {
|
||||
if (useCase) params.set('use_case', useCase);
|
||||
if (quantPref) params.set('quant', quantPref);
|
||||
if (targetCtx) params.set('ctx', String(targetCtx));
|
||||
}
|
||||
const endpoint = isImageMode ? `/api/hwfit/image-models?${params}` : `/api/hwfit/models?${params}`;
|
||||
const res = await fetch(endpoint);
|
||||
@@ -562,13 +582,26 @@ export async function _hwfitFetch(fresh = false) {
|
||||
const sortSel = document.getElementById('hwfit-sort');
|
||||
const sortKey = sortSel?.value || 'score';
|
||||
const asc = sortSel?.dataset.reverse === '1'; // reversed → ascending (lowest first)
|
||||
const field = { score: 'score', vram: 'required_gb', speed: 'speed_tps', params: 'params_b', context: 'context' }[sortKey] || 'score';
|
||||
data.models.sort((a, b) => {
|
||||
const av = Number(a[field]) || 0, bv = Number(b[field]) || 0;
|
||||
return asc ? av - bv : bv - av;
|
||||
});
|
||||
if (sortKey === 'fit') {
|
||||
// fit_level is categorical (perfect→good→marginal→too_tight), not numeric,
|
||||
// so rank it explicitly instead of falling through to the score column.
|
||||
// Tie-break by score so rows within one fit tier stay meaningfully ordered.
|
||||
const fitRank = { perfect: 4, good: 3, marginal: 2, too_tight: 1, no_fit: 0 };
|
||||
data.models.sort((a, b) => {
|
||||
const ar = fitRank[a.fit_level] ?? -1, br = fitRank[b.fit_level] ?? -1;
|
||||
if (ar !== br) return asc ? ar - br : br - ar;
|
||||
const as = Number(a.score) || 0, bs = Number(b.score) || 0;
|
||||
return asc ? as - bs : bs - as;
|
||||
});
|
||||
} else {
|
||||
const field = { score: 'score', vram: 'required_gb', speed: 'speed_tps', params: 'params_b', context: 'context' }[sortKey] || 'score';
|
||||
data.models.sort((a, b) => {
|
||||
const av = Number(a[field]) || 0, bv = Number(b[field]) || 0;
|
||||
return asc ? av - bv : bv - av;
|
||||
});
|
||||
}
|
||||
}
|
||||
_hwfitRenderList(list, data.models);
|
||||
_hwfitRenderList(list, _applyEngineFilter(data.models));
|
||||
// Persist this result so the next page load can paint it instantly.
|
||||
_writeScanCache(_sig, data);
|
||||
// Render GPU toggles — only on first scan (no override active)
|
||||
@@ -614,8 +647,36 @@ export function _hwfitRenderHw(el, sys) {
|
||||
};
|
||||
let gpuChip;
|
||||
if (sys.gpu_name) {
|
||||
const label = gpuCount > 1 ? `${gpuCount}x ${esc(sys.gpu_name)}` : esc(sys.gpu_name);
|
||||
gpuChip = chip('gpu', label);
|
||||
// Mixed-GPU boxes (#711): `${gpuCount}x ${gpu_name}` uses gpus[0].name for
|
||||
// every card, so a 4090+3060 reads as "2x RTX 4090". Use gpu_groups (the
|
||||
// backend already groups identical cards) to render each pool separately
|
||||
// and put the per-card index+VRAM into the tooltip so it's actually
|
||||
// useful for picking CUDA_VISIBLE_DEVICES.
|
||||
const groups = Array.isArray(sys.gpu_groups) ? sys.gpu_groups : [];
|
||||
// Shorten vendor prefixes so a mixed-GPU label fits in the chip row
|
||||
// without overflowing. Single-GPU label still shows the full name
|
||||
// (that's what users are used to seeing). Tooltip carries the full
|
||||
// unmodified names regardless, so no information is lost.
|
||||
const _shortGpuName = (n) => String(n || '')
|
||||
.replace(/^NVIDIA\s+GeForce\s+/i, '')
|
||||
.replace(/^NVIDIA\s+/i, '')
|
||||
.replace(/^AMD\s+Radeon\s+/i, '')
|
||||
.replace(/^AMD\s+/i, '')
|
||||
.replace(/^Intel\s+/i, '');
|
||||
let label;
|
||||
if (groups.length > 1) {
|
||||
// Heterogeneous: "1× RTX 4090 + 1× RTX 3060"
|
||||
label = groups.map(g => `${g.count}× ${esc(_shortGpuName(g.name))}`).join(' + ');
|
||||
} else if (gpuCount > 1) {
|
||||
label = `${gpuCount}× ${esc(sys.gpu_name)}`;
|
||||
} else {
|
||||
label = esc(sys.gpu_name);
|
||||
}
|
||||
const gpus = Array.isArray(sys.gpus) ? sys.gpus : [];
|
||||
const tip = gpus.length
|
||||
? gpus.map(g => `GPU ${g.index}: ${g.name} · ${(+g.vram_gb).toFixed(1)} GB`).join('\n')
|
||||
: 'Click to toggle off (X to hide)';
|
||||
gpuChip = chip('gpu', label, tip);
|
||||
} else if (sys.gpu_error) {
|
||||
gpuChip = _removedHwChips.has('gpu')
|
||||
? ''
|
||||
@@ -761,8 +822,22 @@ function _wireManualHardwareControls(el) {
|
||||
|
||||
export const _fitColors = { perfect: 'var(--green, #50fa7b)', good: 'var(--yellow, #f1fa8c)', marginal: 'var(--orange, #ffb86c)', too_tight: 'var(--red, #ff5555)' };
|
||||
|
||||
function _requiresAcceleratorBackend(model) {
|
||||
const q = String(model?.quant || model?.quantization || '').toUpperCase();
|
||||
const text = `${model?.name || ''} ${model?.repo_id || ''} ${model?.path || ''}`.toLowerCase();
|
||||
return /^AWQ|^GPTQ|^NVFP4/.test(q) || q === 'FP8' || /\b(awq|gptq|fp8|nvfp4)\b/i.test(text);
|
||||
}
|
||||
|
||||
function _modeLabel(model) {
|
||||
if (model?.is_image_gen) return 'image';
|
||||
if (_requiresAcceleratorBackend(model)) return 'vLLM/SGLang';
|
||||
const detected = _detectBackend(model);
|
||||
if (detected?.label) return detected.label;
|
||||
return String(model?.run_mode || '').replace('_', '+');
|
||||
}
|
||||
|
||||
export const _hwfitColumns = [
|
||||
{ key: 'score', label: 'Fit', cls: 'hwfit-fit' },
|
||||
{ key: 'fit', label: 'Fit', cls: 'hwfit-fit' },
|
||||
{ key: null, label: 'Model', cls: 'hwfit-name' },
|
||||
{ key: 'params',label: 'Param', cls: 'hwfit-c-params' },
|
||||
{ key: null, label: 'Quant', cls: 'hwfit-c-quant' },
|
||||
@@ -783,9 +858,10 @@ export function _hwfitRenderList(el, models) {
|
||||
const hasHw = sys && ((sys.gpu_vram_gb || 0) > 0 || (sys.total_ram_gb || 0) > 8);
|
||||
const hasFilters = !!(document.getElementById('hwfit-search')?.value?.trim()
|
||||
|| document.getElementById('hwfit-usecase')?.value
|
||||
|| document.getElementById('hwfit-quant')?.value);
|
||||
|| document.getElementById('hwfit-quant')?.value
|
||||
|| document.getElementById('hwfit-engine')?.value);
|
||||
let msg;
|
||||
if (hasFilters) msg = 'No models match these filters — try clearing the search, use-case, or quant.';
|
||||
if (hasFilters) msg = 'No models match these filters — try clearing the search, use-case, quant, or engine.';
|
||||
else if (hasHw) msg = 'No models fit — the hardware probe may have under-reported. Try Rescan.';
|
||||
else msg = 'No models fit your hardware';
|
||||
el.innerHTML = `<div class="hwfit-loading">${msg}</div>`;
|
||||
@@ -827,7 +903,7 @@ export function _hwfitRenderList(el, models) {
|
||||
const pcount = m.parameter_count || '?';
|
||||
const ctx = m.context ? (m.context >= 1024 ? (m.context / 1024).toFixed(0) + 'k' : m.context) : '?';
|
||||
const fitLabel = (m.fit_level || '').replace('_', ' ');
|
||||
const modeLabel = (m.run_mode || '').replace('_', '+');
|
||||
const modeLabel = _modeLabel(m);
|
||||
const vramLabel = m.required_gb ? m.required_gb.toFixed(1) + 'G' : '?';
|
||||
const moeBadge = m.is_moe ? '<span class="hwfit-badge hwfit-moe">MoE</span>' : '';
|
||||
const imgBadge = m.is_image_gen ? '<span class="hwfit-badge" style="background:color-mix(in srgb, var(--red) 20%, transparent);color:var(--red);font-size:8px;padding:1px 4px;border-radius:3px;margin-left:4px;">IMG</span>' : '';
|
||||
@@ -841,7 +917,7 @@ export function _hwfitRenderList(el, models) {
|
||||
html += `<span class="hwfit-col hwfit-c-ctx">${m.is_image_gen ? '\u2014' : ctx}</span>`;
|
||||
html += `<span class="hwfit-col hwfit-c-speed">${m.is_image_gen ? '\u2014' : tps + ' t/s'}</span>`;
|
||||
html += `<span class="hwfit-col hwfit-c-score">${score}</span>`;
|
||||
html += `<span class="hwfit-col hwfit-c-mode">${m.is_image_gen ? 'image' : esc(modeLabel)}</span>`;
|
||||
html += `<span class="hwfit-col hwfit-c-mode" title="${_requiresAcceleratorBackend(m) ? 'Requires vLLM or SGLang with a visible CUDA/ROCm accelerator. llama.cpp and Ollama need GGUF files.' : ''}">${esc(modeLabel)}</span>`;
|
||||
html += `</div>`;
|
||||
}
|
||||
el.innerHTML = html;
|
||||
@@ -941,6 +1017,8 @@ export function _expandModelRow(row, modelData) {
|
||||
html += `</div>`;
|
||||
if (modelData.is_image_gen) {
|
||||
html += `<div style="font-size:10px;opacity:0.5;margin-top:4px;">${esc((modelData.capabilities || []).join(' \u00B7 ') || '')}${modelData.description ? ' \u2014 ' + esc(modelData.description) : ''}</div>`;
|
||||
} else if (_requiresAcceleratorBackend(modelData)) {
|
||||
html += `<div class="hwfit-panel-note">This is a safetensors GPU-serving format. Use vLLM/SGLang with a visible CUDA/ROCm accelerator, or pick a GGUF download for llama.cpp/Ollama.</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
|
||||
@@ -1145,6 +1223,17 @@ export function _hwfitInit() {
|
||||
if (uc) uc.addEventListener('change', () => _hwfitFetch());
|
||||
if (sort) sort.addEventListener('change', () => _hwfitFetch());
|
||||
if (qpref) qpref.addEventListener('change', () => _hwfitFetch());
|
||||
// Engine filter is a pure client-side view filter over the already-fetched
|
||||
// list, so just re-render from cache instead of re-probing hardware.
|
||||
const engine = document.getElementById('hwfit-engine');
|
||||
if (engine) engine.addEventListener('change', () => {
|
||||
const list = document.getElementById('hwfit-list');
|
||||
if (list && _hwfitCache && Array.isArray(_hwfitCache.models)) {
|
||||
_hwfitRenderList(list, _applyEngineFilter(_hwfitCache.models));
|
||||
} else {
|
||||
_hwfitFetch();
|
||||
}
|
||||
});
|
||||
if (ctx && !ctx.dataset.bound) {
|
||||
ctx.dataset.bound = '1';
|
||||
ctx.addEventListener('input', () => {
|
||||
|
||||
+229
-44
@@ -223,11 +223,20 @@ function _detectModelOptimizations(modelName) {
|
||||
return opts;
|
||||
}
|
||||
|
||||
/** Detect the right vLLM tool-call-parser based on model name */
|
||||
/** Detect the right vLLM tool-call-parser based on model name.
|
||||
* Qwen tool-call formats split by generation:
|
||||
* - Qwen3-Coder → qwen3_coder (XML <tool_call> with named params)
|
||||
* - Qwen3 (non-coder) → qwen3_xml (reasoning/instruct, XML wrapper)
|
||||
* - Qwen2.5 / Qwen2 / 1.5 → hermes (Qwen2.5 was trained on Hermes format)
|
||||
* Catching "qwen" first and labelling everything qwen3_xml breaks tool
|
||||
* calls on the Qwen2.5 line (the model emits hermes-style which the
|
||||
* qwen3_xml parser doesn't recognise, so the call leaks through as text).
|
||||
*/
|
||||
export function _detectToolParser(modelName) {
|
||||
const n = (modelName || '').toLowerCase();
|
||||
if (n.includes('qwen3') && n.includes('coder')) return 'qwen3_coder';
|
||||
if (n.includes('qwen')) return 'qwen3_xml';
|
||||
if (n.includes('qwen3')) return 'qwen3_xml';
|
||||
if (n.includes('qwen')) return 'hermes'; // Qwen2.5 / Qwen2 / Qwen1.5
|
||||
if (n.includes('llama-4') || n.includes('llama4')) return 'llama4_json';
|
||||
if (n.includes('llama') || n.includes('nemotron')) return 'llama3_json';
|
||||
if (n.includes('mistral') || n.includes('mixtral')) return 'mistral';
|
||||
@@ -251,37 +260,43 @@ export function _detectBackend(model) {
|
||||
const q = (model.quant || '').toUpperCase();
|
||||
const sysBackend = String(_hwfitCache?.system?.backend || '').toLowerCase();
|
||||
const isRocm = sysBackend === 'rocm';
|
||||
const isAppleSilicon = ['metal', 'mps', 'apple'].includes(sysBackend);
|
||||
const _nm = `${model.repo_id || ''} ${model.path || ''} ${model.name || ''}`.toLowerCase();
|
||||
if (/\bmlx\b|mlx-|_mlx/i.test(_nm) || q.startsWith('MLX')) {
|
||||
return { backend: 'unsupported', label: 'Unsupported' };
|
||||
}
|
||||
const isAwqLike = /^AWQ|^GPTQ|^NVFP4/.test(q) || ['FP8', 'FP4', 'MXFP4', 'NF4', 'INT4', 'INT8', 'W4A16', 'W8A8', 'W8A16'].includes(q) || /\b(awq|gptq|fp8|fp4|nvfp4|mxfp4|nf4|int4|int8|w4a16|w8a8|w8a16)\b/i.test(_nm);
|
||||
const isGgufLike = model.is_gguf || /^Q[2-8]/.test(q) || /^IQ/.test(q) || q === 'GGUF' || _nm.includes('gguf');
|
||||
|
||||
// Image gen models → diffusers
|
||||
if (model.is_image_gen || model.is_diffusion || model._tag === 'image') {
|
||||
return { backend: 'diffusers', label: 'Diffusers' };
|
||||
}
|
||||
|
||||
// AWQ / GPTQ / FP8 are safetensors GPU-serving formats. Never route them
|
||||
// through llama.cpp/Ollama just because the host is Mac/Windows; those engines
|
||||
// need GGUF. The UI will warn/block on Metal where vLLM/SGLang aren't viable.
|
||||
if (isAwqLike) {
|
||||
return { backend: 'vllm', label: 'vLLM' };
|
||||
}
|
||||
|
||||
// GGUF → llama.cpp/Ollama-compatible.
|
||||
if (isGgufLike) {
|
||||
return { backend: 'llamacpp', label: 'llama.cpp' };
|
||||
}
|
||||
|
||||
// Windows → default to llama.cpp (no vLLM support on Windows)
|
||||
if (_isWindows()) {
|
||||
return { backend: 'llamacpp', label: 'llama.cpp' };
|
||||
}
|
||||
|
||||
// Apple Silicon (Metal) → llama.cpp (GGUF). vLLM/SGLang are CUDA/ROCm-only and
|
||||
// don't run on macOS; AWQ/GPTQ/FP8 (vLLM-only) models are already filtered out
|
||||
// don't run on macOS; vLLM-native quantized models are already filtered out
|
||||
// of metal Cookbook results, so llama.cpp is always the right engine here.
|
||||
if (['metal', 'mps', 'apple'].includes(sysBackend)) {
|
||||
return { backend: 'llamacpp', label: 'llama.cpp' };
|
||||
}
|
||||
|
||||
// AWQ / GPTQ / FP8 → vLLM
|
||||
if (/^AWQ|^GPTQ/.test(q) || q === 'FP8') {
|
||||
return { backend: 'vllm', label: 'vLLM' };
|
||||
}
|
||||
|
||||
// GGUF → llama.cpp. Match the quant tag OR a gguf hint in the repo/path/name:
|
||||
// a raw .gguf file often has no quant field, which made it fall through to the
|
||||
// vLLM default below.
|
||||
const _nm = `${model.repo_id || ''} ${model.path || ''} ${model.name || ''}`.toLowerCase();
|
||||
if (model.is_gguf || /^Q[2-8]/.test(q) || /^IQ/.test(q) || q === 'GGUF' || _nm.includes('gguf')) {
|
||||
return { backend: 'llamacpp', label: 'llama.cpp' };
|
||||
}
|
||||
|
||||
// ROCm/AMD machines should not blindly default HF safetensors models to
|
||||
// vLLM. SGLang is the safer OpenAI-compatible default for plain HF text
|
||||
// repos there; llama.cpp still wins above whenever the model is GGUF.
|
||||
@@ -351,6 +366,8 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
cmd += ` --gpu-memory-utilization ${f.gpu_mem || '0.90'}`;
|
||||
if (f.swap && f.swap !== '0') cmd += ` --swap-space ${f.swap}`;
|
||||
cmd += ` --dtype ${f.dtype || 'auto'}`;
|
||||
const _kv = (f.vllm_kv_cache_dtype ?? '').toString().trim();
|
||||
if (_kv === 'fp8') cmd += ' --kv-cache-dtype fp8';
|
||||
if (f.max_seqs && f.max_seqs.toString().trim()) cmd += ` --max-num-seqs ${f.max_seqs.toString().trim()}`;
|
||||
if (f.enforce_eager) cmd += ' --enforce-eager';
|
||||
if (f.trust_remote) cmd += ' --trust-remote-code';
|
||||
@@ -384,13 +401,17 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
const ggufPath = f._gguf_path || 'model.gguf';
|
||||
const gpuId = f.gpu_id?.trim() || '';
|
||||
const py = _isWindows() ? 'python' : 'python3';
|
||||
// CPU-only serve (-ngl 0): drop the GPU-only flags, otherwise the command
|
||||
// mixes "zero GPU layers" with CUDA unified-memory + flash-attn and fails to
|
||||
// start (issue #1291). Only affects the ngl=0 path; GPU serving is unchanged.
|
||||
const _cpuOnly = String(f.ngl).trim() === '0';
|
||||
const lcPrefix = (() => {
|
||||
let p = '';
|
||||
if (f.unified_mem && !_isWindows()) p += `GGML_CUDA_ENABLE_UNIFIED_MEMORY=1 `;
|
||||
if (f.unified_mem && !_cpuOnly && !_isWindows()) p += `GGML_CUDA_ENABLE_UNIFIED_MEMORY=1 `;
|
||||
if (gpuId && !_isWindows()) p += `CUDA_VISIBLE_DEVICES=${gpuId} `;
|
||||
return p;
|
||||
})();
|
||||
if (f.unified_mem && _isWindows()) cmd += `$env:GGML_CUDA_ENABLE_UNIFIED_MEMORY="1"; `;
|
||||
if (f.unified_mem && !_cpuOnly && _isWindows()) cmd += `$env:GGML_CUDA_ENABLE_UNIFIED_MEMORY="1"; `;
|
||||
if (gpuId && _isWindows()) cmd += `$env:CUDA_VISIBLE_DEVICES="${gpuId}"; `;
|
||||
if (!_isWindows()) {
|
||||
// Resolve GGUF path once, fail loudly if nothing matched (prevents
|
||||
@@ -402,16 +423,75 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
// renders modern GGUF chat templates that the Python bindings' Jinja2
|
||||
// rejects (do_tojson ensure_ascii). Fall back to llama_cpp.server.
|
||||
// Don't suppress stderr — surface real errors (missing file, lib, OOM).
|
||||
const _lcpServer = `${lcPrefix}${py} -m llama_cpp.server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} --n_gpu_layers ${f.ngl || '99'} --n_ctx ${f.ctx || '8192'}`;
|
||||
// Optional perf/fit flags from a hardware profile (see services/hwfit/
|
||||
// profiles.py). n_cpu_moe offloads MoE expert layers to CPU when the model
|
||||
// is bigger than VRAM; flash-attn + a quantized KV cache cut KV memory and
|
||||
// speed things up. Only emitted when set, so manual/older flows are unchanged.
|
||||
const _ncm = (f.n_cpu_moe ?? '').toString().trim();
|
||||
const _kv = (f.cache_type ?? '').toString().trim();
|
||||
const _llamaNum = (v) => {
|
||||
const s = String(v || '').trim();
|
||||
return /^\d+$/.test(s) ? s : '';
|
||||
};
|
||||
const _llamaCsv = (v) => {
|
||||
const s = String(v || '').replace(/\s+/g, '');
|
||||
return /^\d+(?:\.\d+)?(?:,\d+(?:\.\d+)?)*$/.test(s) ? s : '';
|
||||
};
|
||||
let _lcExtra = '';
|
||||
let _lcpExtra = '';
|
||||
if (_ncm !== '' && Number(_ncm) > 0) {
|
||||
_lcExtra += ` --n-cpu-moe ${_ncm}`;
|
||||
_lcpExtra += ` --n_cpu_moe ${_ncm}`; // llama-cpp-python uses underscores
|
||||
}
|
||||
if (f.flash_attn && !_cpuOnly) {
|
||||
_lcExtra += ' --flash-attn on';
|
||||
_lcpExtra += ' --flash_attn true';
|
||||
}
|
||||
if (_kv) {
|
||||
_lcExtra += ` --cache-type-k ${_kv} --cache-type-v ${_kv}`;
|
||||
// llama-cpp-python exposes these as type_k/type_v; pass through best-effort.
|
||||
_lcpExtra += ` --type_k ${_kv} --type_v ${_kv}`;
|
||||
}
|
||||
const _llamaFit = String(f.llama_fit || '').trim();
|
||||
if (['on', 'off'].includes(_llamaFit)) _lcExtra += ` --fit ${_llamaFit}`;
|
||||
if (f.llama_no_mmap) _lcExtra += ' --no-mmap';
|
||||
if (f.llama_no_warmup) _lcExtra += ' --no-warmup';
|
||||
const _llamaSplitMode = String(f.llama_split_mode || '').trim();
|
||||
if (['none', 'layer', 'row', 'tensor'].includes(_llamaSplitMode)) _lcExtra += ` --split-mode ${_llamaSplitMode}`;
|
||||
const _llamaTensorSplit = _llamaCsv(f.llama_tensor_split);
|
||||
if (_llamaTensorSplit) _lcExtra += ` --tensor-split ${_llamaTensorSplit}`;
|
||||
const _llamaMainGpu = _llamaNum(f.llama_main_gpu);
|
||||
if (_llamaMainGpu) _lcExtra += ` --main-gpu ${_llamaMainGpu}`;
|
||||
const _llamaParallel = _llamaNum(f.llama_parallel);
|
||||
if (_llamaParallel) _lcExtra += ` --parallel ${_llamaParallel}`;
|
||||
const _llamaBatch = _llamaNum(f.llama_batch_size);
|
||||
if (_llamaBatch) _lcExtra += ` --batch-size ${_llamaBatch}`;
|
||||
const _llamaUBatch = _llamaNum(f.llama_ubatch_size);
|
||||
if (_llamaUBatch) _lcExtra += ` --ubatch-size ${_llamaUBatch}`;
|
||||
if (f.llama_speculative_mtp) {
|
||||
const specTokens = parseInt(f.llama_spec_tokens, 10);
|
||||
const specN = Number.isFinite(specTokens) && specTokens > 0 ? specTokens : 3;
|
||||
_lcExtra += ` --spec-type draft-mtp --spec-draft-n-max ${specN}`;
|
||||
}
|
||||
// Vision: serve the multimodal projector so the model can read images. The
|
||||
// mmproj path is resolved at runtime (find mmproj-*.gguf next to the model);
|
||||
// only emitted when the Vision toggle is on AND a projector was found.
|
||||
if (f.vision && f._mmproj_path) {
|
||||
_lcExtra += ` --mmproj "${f._mmproj_path}" --image-max-tokens 1024`;
|
||||
// llama-cpp-python takes the projector via --clip_model_path.
|
||||
_lcpExtra += ` --clip_model_path "${f._mmproj_path}"`;
|
||||
}
|
||||
const _lcpServer = `${lcPrefix}${py} -m llama_cpp.server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} --n_gpu_layers ${f.ngl || '99'} --n_ctx ${f.ctx || '8192'}${_lcpExtra}`;
|
||||
if (_isWindows()) {
|
||||
cmd += _lcpServer;
|
||||
} else {
|
||||
cmd += `${lcPrefix}llama-server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} -ngl ${f.ngl || '99'} -c ${f.ctx || '8192'}`;
|
||||
cmd += `${lcPrefix}llama-server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} -ngl ${f.ngl || '99'} -c ${f.ctx || '8192'}${_lcExtra}`;
|
||||
cmd += ` || ${_lcpServer}`;
|
||||
}
|
||||
} else if (backend === 'ollama') {
|
||||
const ollamaPort = f.port || '11434';
|
||||
const hostEnv = ollamaPort !== '11434' ? `OLLAMA_HOST=0.0.0.0:${ollamaPort} ` : '';
|
||||
const bindHost = _envState.remoteHost ? '0.0.0.0' : '127.0.0.1';
|
||||
const hostEnv = ollamaPort !== '11434' ? `OLLAMA_HOST=${bindHost}:${ollamaPort} ` : '';
|
||||
cmd = `${hostEnv}ollama serve`;
|
||||
} else if (backend === 'diffusers') {
|
||||
const gpuStr = f.gpus?.trim();
|
||||
@@ -542,6 +622,10 @@ async function _fetchDependencies() {
|
||||
const _statusTag = (pkg, isLocal, isSystemDep, winBlocked) => {
|
||||
if (winBlocked) return `<span class="cookbook-dep-tag cookbook-dep-na">N/A</span>`;
|
||||
if (pkg.installed && isSystemDep) return `<span class="cookbook-dep-tag cookbook-dep-installed" title="Found on selected server">Installed</span>`;
|
||||
if (pkg.installed && pkg.pip_update_available === false) {
|
||||
const tip = esc(pkg.update_note || pkg.status_note || 'Found externally; update outside Odysseus.');
|
||||
return `<span class="cookbook-dep-tag cookbook-dep-installed" title="${tip}">Installed</span>`;
|
||||
}
|
||||
if (pkg.installed) return `<button class="cookbook-dep-tag cookbook-dep-installed cookbook-dep-installed-btn" title="Installed — click for actions"><span class="cookbook-dep-installed-label">Installed</span><span class="cookbook-dep-caret">▾</span></button>`;
|
||||
if (isSystemDep) {
|
||||
const depTip = esc(pkg.install_hint || 'Install this OS package on the selected server.');
|
||||
@@ -556,11 +640,13 @@ async function _fetchDependencies() {
|
||||
const isSystemDep = pkg.kind === 'system';
|
||||
const winBlocked = !isLocal && _isWindows() && _winUnsupported.has(pkg.name);
|
||||
const note = pkg.status_note ? `<div class="memory-item-meta" style="font-size:10px;opacity:0.65;margin-top:3px;">${esc(pkg.status_note)}</div>` : '';
|
||||
const updateNote = pkg.installed && pkg.pip_update_available === false && pkg.update_note ? `<div class="memory-item-meta" style="font-size:10px;opacity:0.55;margin-top:3px;">${esc(pkg.update_note)}</div>` : '';
|
||||
return `<div class="cookbook-dep-row${winBlocked ? ' cookbook-dep-blocked' : ''}" data-pkg-name="${esc(pkg.name)}" data-dep-pip="${esc(pkg.pip || '')}" data-dep-target="${isLocal ? 'local' : 'remote'}" data-dep-kind="${esc(pkg.kind || 'python')}">`
|
||||
+ `<div class="cookbook-dep-info">`
|
||||
+ `<div class="memory-item-title">${esc(pkg.name)}</div>`
|
||||
+ `<div class="memory-item-meta" style="font-size:10px;opacity:0.5;margin-top:2px;">${esc(pkg.desc)}</div>`
|
||||
+ note
|
||||
+ updateNote
|
||||
+ `</div>`
|
||||
+ `<span class="cookbook-dep-tag cookbook-dep-cat">${esc(pkg.category)}</span>`
|
||||
+ _statusTag(pkg, isLocal, isSystemDep, winBlocked)
|
||||
@@ -642,7 +728,7 @@ async function _fetchDependencies() {
|
||||
}
|
||||
// _dep flags this as a pip dependency/driver install (not a servable
|
||||
// model) so the running-task card doesn't offer a "Serve →" button.
|
||||
const payload = { repo_id: pipName, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true };
|
||||
const payload = { repo_id: pipName, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true, env_path: _envState.envPath || '' };
|
||||
_addTask(data.session_id, 'pip ' + pkgName, 'download', payload);
|
||||
if (statusEl) { statusEl.textContent = upgrade ? 'Updating...' : 'Installing...'; statusEl.disabled = true; }
|
||||
uiModule.showToast(`${upgrade ? 'Updating' : 'Installing'} ${pkgName} on ${targetHost}...`);
|
||||
@@ -932,6 +1018,51 @@ function _wireTabEvents(body) {
|
||||
});
|
||||
}
|
||||
|
||||
// "Rebuild llama.cpp" clears the cached build so the next serve recompiles.
|
||||
// The serve bootstrap only builds llama-server when it is missing from PATH,
|
||||
// so a host that first built CPU-only (no nvcc at build time) keeps reusing
|
||||
// that binary forever; this is the lever to force a fresh GPU build after a
|
||||
// CUDA/ROCm toolkit is installed.
|
||||
const rebuildBtn = document.getElementById('cookbook-rebuild-engine');
|
||||
if (rebuildBtn && !rebuildBtn._wired) {
|
||||
rebuildBtn._wired = true;
|
||||
rebuildBtn.addEventListener('click', async () => {
|
||||
// Match _installDep: honor the Dependencies server selector so the clear
|
||||
// runs on the same host the build runs on.
|
||||
const sel = document.getElementById('hwfit-deps-server');
|
||||
if (sel) _applyServerSelection(sel.value);
|
||||
const host = _envState.remoteHost || '';
|
||||
const where = host || 'this server';
|
||||
if (!confirm(`Rebuild the llama.cpp engine on ${where}?\n\nThis clears the cached llama-server build so the next serve recompiles from source (with CUDA/HIP if a toolchain is present). It does not download or install anything.`)) return;
|
||||
const _label = rebuildBtn.textContent;
|
||||
rebuildBtn.disabled = true;
|
||||
rebuildBtn.textContent = 'Clearing...';
|
||||
try {
|
||||
const res = await fetch('/api/cookbook/rebuild-engine', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
engine: 'llamacpp',
|
||||
remote_host: host || undefined,
|
||||
ssh_port: _getPort(host) || undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.ok) {
|
||||
const reason = data.detail || data.error || `HTTP ${res.status}`;
|
||||
uiModule.showToast('Rebuild failed: ' + String(reason).slice(0, 200));
|
||||
} else {
|
||||
uiModule.showToast(`Cleared llama.cpp build on ${where}. Re-launch the serve task to rebuild with GPU support.`);
|
||||
}
|
||||
} catch (err) {
|
||||
uiModule.showToast('Rebuild failed: ' + err.message);
|
||||
} finally {
|
||||
rebuildBtn.disabled = false;
|
||||
rebuildBtn.textContent = _label;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Serve sort
|
||||
const serveSort = document.getElementById('serve-sort');
|
||||
if (serveSort) {
|
||||
@@ -985,6 +1116,7 @@ function _wireTabEvents(body) {
|
||||
|
||||
document.getElementById('serve-bulk-cancel')?.addEventListener('click', () => {
|
||||
selectBtn.classList.remove('active');
|
||||
selectBtn.textContent = 'Select'; // reset label so the button doesn't stay reading "Cancel" after exit
|
||||
bulkBar.classList.add('hidden');
|
||||
document.querySelectorAll('.serve-select-cb').forEach(dot => { dot.style.display = 'none'; dot.classList.remove('selected'); });
|
||||
});
|
||||
@@ -1003,6 +1135,7 @@ function _wireTabEvents(body) {
|
||||
if (item) await _deleteCachedModel(repo, item, true);
|
||||
}
|
||||
selectBtn.classList.remove('active');
|
||||
selectBtn.textContent = 'Select'; // same reset as bulk-cancel
|
||||
bulkBar.classList.add('hidden');
|
||||
document.querySelectorAll('.serve-select-cb').forEach(dot => { dot.style.display = 'none'; dot.classList.remove('selected'); });
|
||||
});
|
||||
@@ -1011,6 +1144,16 @@ function _wireTabEvents(body) {
|
||||
// Download input
|
||||
const dlBtn = document.getElementById('cookbook-dl-btn');
|
||||
const dlInput = document.getElementById('cookbook-dl-repo');
|
||||
const dlCardToggle = document.getElementById('cookbook-download-card-toggle');
|
||||
const dlCardBody = document.getElementById('cookbook-download-card-body');
|
||||
const dlCardArrow = document.getElementById('cookbook-download-card-arrow');
|
||||
if (dlCardToggle && dlCardBody) {
|
||||
dlCardToggle.addEventListener('click', () => {
|
||||
const isOpen = dlCardBody.style.display !== 'none';
|
||||
dlCardBody.style.display = isOpen ? 'none' : 'block';
|
||||
if (dlCardArrow) dlCardArrow.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)';
|
||||
});
|
||||
}
|
||||
if (dlBtn && dlInput) {
|
||||
function _stripHfUrl(input) {
|
||||
let repo = input.trim();
|
||||
@@ -1104,8 +1247,12 @@ function _wireTabEvents(body) {
|
||||
if (hfToggle && hfList) {
|
||||
let _loaded = false;
|
||||
// Per-server VRAM cache so we don't re-probe on every expand
|
||||
const _vramCache = {};
|
||||
async function _getSelectedServerVram() {
|
||||
const _hwCache = {};
|
||||
function _hfModelLooksAwqLike(m) {
|
||||
const text = `${m?.repo_id || ''} ${(m?.tags || []).join(' ')}`.toLowerCase();
|
||||
return /\b(awq|gptq|fp8|4bit|int4)\b/.test(text);
|
||||
}
|
||||
async function _getSelectedServerHw() {
|
||||
// Prefer the "What Fits" dropdown (the main control that shows hardware);
|
||||
// fall back to the download dropdown. This is the server the list ranks for.
|
||||
const dlSrv = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server');
|
||||
@@ -1122,7 +1269,7 @@ function _wireTabEvents(body) {
|
||||
}
|
||||
}
|
||||
const cacheKey = host || 'local';
|
||||
if (_vramCache[cacheKey] !== undefined) return _vramCache[cacheKey];
|
||||
if (_hwCache[cacheKey]) return _hwCache[cacheKey];
|
||||
// Fetch system info for this server from hwfit
|
||||
try {
|
||||
const qp = new URLSearchParams();
|
||||
@@ -1132,13 +1279,13 @@ function _wireTabEvents(body) {
|
||||
const r = await fetch(`/api/hwfit/system?${qp}`);
|
||||
if (r.ok) {
|
||||
const sys = await r.json();
|
||||
const v = sys?.gpu_vram_gb || 0;
|
||||
_vramCache[cacheKey] = v;
|
||||
return v;
|
||||
const hw = { vram: sys?.gpu_vram_gb || 0, backend: String(sys?.backend || '').toLowerCase() };
|
||||
_hwCache[cacheKey] = hw;
|
||||
return hw;
|
||||
}
|
||||
} catch {}
|
||||
_vramCache[cacheKey] = 0;
|
||||
return 0;
|
||||
_hwCache[cacheKey] = { vram: 0, backend: '' };
|
||||
return _hwCache[cacheKey];
|
||||
}
|
||||
async function _loadLatest() {
|
||||
// Match the Dependencies loader: whirlpool spinner + text label so the
|
||||
@@ -1157,7 +1304,8 @@ function _wireTabEvents(body) {
|
||||
} catch {
|
||||
hfList.innerHTML = '<div class="hwfit-loading">Scanning models…</div>';
|
||||
}
|
||||
const vram = await _getSelectedServerVram();
|
||||
const hwInfo = await _getSelectedServerHw();
|
||||
const vram = hwInfo.vram || 0;
|
||||
try {
|
||||
let lastErr = '';
|
||||
const _fetchLatest = async (v) => {
|
||||
@@ -1173,6 +1321,9 @@ function _wireTabEvents(body) {
|
||||
if (!models.length && vram > 0) {
|
||||
models = await _fetchLatest(0);
|
||||
}
|
||||
if (['rocm', 'metal', 'mps', 'apple', 'generic', 'cpu'].includes(hwInfo.backend)) {
|
||||
models = models.filter(m => !_hfModelLooksAwqLike(m));
|
||||
}
|
||||
if (!models.length) {
|
||||
// Distinguish "the HF API failed" from "nothing matched" so an outage
|
||||
// doesn't masquerade as no-fitting-models.
|
||||
@@ -1254,9 +1405,32 @@ function _wireTabEvents(body) {
|
||||
// HF token — save on change
|
||||
const hfInput = document.getElementById('hwfit-hftoken');
|
||||
if (hfInput) {
|
||||
hfInput.addEventListener('change', () => {
|
||||
_envState.hfToken = hfInput.value.trim();
|
||||
_persistEnvState();
|
||||
hfInput.addEventListener('change', async () => {
|
||||
const val = hfInput.value.trim();
|
||||
_envState.hfToken = val;
|
||||
try { await _persistEnvState(); } catch {}
|
||||
if (val) {
|
||||
_envState.hfTokenConfigured = true;
|
||||
const masked = val.length > 6 ? val.slice(0, 3) + '…' + val.slice(-3) : '••••';
|
||||
_envState.hfTokenMasked = masked;
|
||||
hfInput.placeholder = `Stored (${masked}) - enter a new token to replace`;
|
||||
hfInput.value = '';
|
||||
let check = hfInput.parentNode.querySelector('.hwfit-hf-check');
|
||||
if (!check) {
|
||||
check = document.createElement('span');
|
||||
check.className = 'hwfit-hf-check';
|
||||
check.title = 'Token stored';
|
||||
check.textContent = '✓';
|
||||
check.style.cssText = 'font-weight:800;color:var(--green,#50fa7b);font-size:15px;line-height:1;flex-shrink:0;position:relative;top:2px;';
|
||||
hfInput.parentNode.insertBefore(check, hfInput);
|
||||
}
|
||||
const flash = document.createElement('span');
|
||||
flash.textContent = 'Saved';
|
||||
flash.style.cssText = 'margin-left:8px;font-size:11px;color:var(--green,#50fa7b);opacity:0;transition:opacity 0.18s;flex-shrink:0;position:relative;top:1px;';
|
||||
hfInput.parentNode.appendChild(flash);
|
||||
requestAnimationFrame(() => { flash.style.opacity = '1'; });
|
||||
setTimeout(() => { flash.style.opacity = '0'; setTimeout(() => flash.remove(), 220); }, 1400);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1393,7 +1567,7 @@ function _renderRecipes() {
|
||||
// silently sending downloads to the wrong server. An empty selection means Local; the user
|
||||
// chooses a remote server explicitly via the dropdown.
|
||||
|
||||
// Download input
|
||||
// Manual download input
|
||||
html += `<div style="margin-top:7px;margin-bottom:2px;display:flex;gap:4px;align-items:center;">`;
|
||||
if (_es.servers.length > 1) {
|
||||
html += `<select class="cookbook-field-input hwfit-dl-server" id="hwfit-dl-server" style="height:28px;position:relative;top:0px;">`;
|
||||
@@ -1409,7 +1583,7 @@ function _renderRecipes() {
|
||||
html += `<button class="cookbook-btn cookbook-dl-btn" id="cookbook-dl-btn">Download</button>`;
|
||||
html += `</div>`;
|
||||
// Latest HF models that fit — collapsible card list
|
||||
html += `<div style="margin-top:2px;position:relative;top:-8px;">`;
|
||||
html += `<div style="margin-top:5px;position:relative;top:-3px;">`;
|
||||
html += `<div style="display:flex;gap:4px;align-items:center;">`;
|
||||
html += `<button type="button" class="memory-toolbar-btn" id="cookbook-hf-latest-toggle" style="flex:1;text-align:left;height:26px;display:flex;align-items:center;gap:6px;border-radius:4px;">`;
|
||||
html += `<span id="cookbook-hf-latest-arrow" style="display:inline-block;transition:transform 0.15s;pointer-events:none;">\u25B8</span>`;
|
||||
@@ -1422,7 +1596,7 @@ function _renderRecipes() {
|
||||
html += `</div>`; // /#cookbook-dl-tab-fold-body (whole Download card body)
|
||||
|
||||
// Search section
|
||||
html += '</div></div></div>';
|
||||
html += '</div></div></div></div>';
|
||||
html += '<div class="cookbook-group" data-backend-group="Search">';
|
||||
html += '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">';
|
||||
html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">';
|
||||
@@ -1445,13 +1619,21 @@ function _renderRecipes() {
|
||||
html += '<option value="Q4_K_M">Q4</option><option value="Q8_0">Q8</option>';
|
||||
html += '<option value="Q6_K">Q6</option><option value="Q5_K_M">Q5</option>';
|
||||
html += '<option value="Q3_K_M">Q3</option><option value="Q2_K">Q2</option>';
|
||||
html += '<option value="AWQ-4bit">AWQ</option><option value="FP8">FP8</option></select>';
|
||||
// Ctx slider — ported from origin/main. Lets you target a context length
|
||||
// for fit estimates; the hwfit ranking uses _ctxValue() to factor that into
|
||||
// VRAM math, so dragging this re-sorts the list toward models that fit
|
||||
// your chosen ctx.
|
||||
html += '<option value="AWQ-4bit">AWQ</option><option value="FP8">FP8</option><option value="FP4">FP4</option><option value="NVFP4">NVFP4</option></select>';
|
||||
// Engine filter — show only models whose serve engine matches. Composes
|
||||
// with quant / type / search filters.
|
||||
html += '<select class="cookbook-field-input hwfit-engine" id="hwfit-engine" style="height:28px;" title="Filter by serving engine">';
|
||||
html += '<option value="">Engine</option>';
|
||||
html += '<option value="llamacpp">llama.cpp</option>';
|
||||
html += '<option value="vllm">vLLM</option>';
|
||||
html += '<option value="sglang">SGLang</option>';
|
||||
html += '</select>';
|
||||
html += '<span class="hwfit-help-chip" title="Higher numbers usually mean better quality, but they need more memory. Lower numbers fit on more hardware.">?</span>';
|
||||
// Ctx slider — lets you target a context length for fit estimates; the
|
||||
// hwfit ranking uses _ctxValue() to factor that into VRAM math, so
|
||||
// dragging this re-sorts the list toward models that fit your chosen ctx.
|
||||
html += '<label class="hwfit-ctx-control" title="Context length for fit estimates. Lower it to find more models that could fit your hardware.">';
|
||||
html += '<span>Ctx</span><input type="range" id="hwfit-context" min="0" max="5" step="1" value="3" />';
|
||||
html += '<span>Ctx</span><span class="hwfit-help-chip hwfit-help-chip-inline" title="Context length. Lower it to find more models that could fit your hardware; raise it when you need longer chats or documents.">?</span><input type="range" id="hwfit-context" min="0" max="5" step="1" value="3" />';
|
||||
html += '<output id="hwfit-context-label">50k</output></label>';
|
||||
html += '</div>';
|
||||
html += '<div class="hwfit-toolbar" style="margin-top:7px;">';
|
||||
@@ -1462,8 +1644,10 @@ function _renderRecipes() {
|
||||
// Scan/refresh button (icon-only) where the quant dropdown used to sit.
|
||||
html += '<button type="button" class="hwfit-gpu-btn" id="hwfit-rescan" title="Re-scan hardware" style="flex-shrink:0;position:relative;top:-3px;left:-1px;">↻ RESCAN</button>';
|
||||
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-manual-btn" id="hwfit-hw-manual-btn" title="Set hardware manually" style="flex-shrink:0;position:relative;top:-3px;left:-1px;">EDIT</button>';
|
||||
// Sort state — the clickable column headers read/write this (pewds' original
|
||||
// sort paradigm). Newest is reachable by clicking the Model column header.
|
||||
html += '<select class="cookbook-field-input hwfit-sort" id="hwfit-sort" style="display:none">';
|
||||
html += '<option value="score">Score</option><option value="vram">VRAM</option>';
|
||||
html += '<option value="fit">Fit</option><option value="score">Score</option><option value="vram">VRAM</option>';
|
||||
html += '<option value="speed">Speed</option><option value="params">Params</option>';
|
||||
html += '<option value="context">Context</option></select>';
|
||||
html += '</div>';
|
||||
@@ -1523,6 +1707,7 @@ function _renderRecipes() {
|
||||
html += '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">';
|
||||
html += '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">';
|
||||
html += '<h2 style="margin:0;padding:0;line-height:1;">Dependencies</h2>';
|
||||
html += '<button class="cookbook-field-input" id="cookbook-rebuild-engine" title="Clear the cached llama.cpp build so the next serve recompiles from source (use after installing a CUDA/ROCm toolkit to turn a CPU-only build into a GPU build)." style="height:24px;font-size:10px;padding:0 8px;cursor:pointer;width:auto;">Rebuild llama.cpp</button>';
|
||||
html += '<span style="font-size:10px;opacity:0.5;margin-left:auto;">Server</span>';
|
||||
html += '<select class="cookbook-field-input" id="hwfit-deps-server" style="height:28px;min-width:70px;">';
|
||||
html += _buildServerOpts(false);
|
||||
|
||||
@@ -86,6 +86,9 @@ function _ggufIncludePattern(model, source) {
|
||||
|
||||
function _missingGgufMessage(model) {
|
||||
const name = model?.name || 'this model';
|
||||
if (/\bnvfp4\b/i.test(name)) {
|
||||
return `${name} is an NVIDIA NVFP4 checkpoint, not a GGUF download. Pick the base model row with an Unsloth GGUF source, or paste the GGUF repo directly.`;
|
||||
}
|
||||
return `No GGUF source is configured for ${name}. Pick a model with a GGUF source, or paste the GGUF repo in Download.`;
|
||||
}
|
||||
|
||||
@@ -492,6 +495,10 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
|
||||
const payload = { repo_id: repo };
|
||||
if (include) payload.include = include;
|
||||
// Large downloads are where hf_transfer most often dies near the end. Use the
|
||||
// plain HuggingFace downloader up front for big model files; it is slower, but
|
||||
// resumes cached partials more reliably.
|
||||
if ((model.required_gb || 0) >= 10 || backend === 'llamacpp') payload.disable_hf_transfer = true;
|
||||
if (_envState.hfToken) payload.hf_token = _envState.hfToken;
|
||||
if (host) { payload.remote_host = host; const _sp = _getPort(host); if (_sp) payload.ssh_port = _sp; }
|
||||
if (platform) payload.platform = platform;
|
||||
@@ -516,6 +523,18 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
const targetHost = host || 'local';
|
||||
|
||||
const tasks = _loadTasks();
|
||||
const sameDownload = (t) => {
|
||||
if (!t || t.type !== 'download') return false;
|
||||
const tRepo = t?.payload?.repo_id || t?.repo_id || t?.repo || t?.name || '';
|
||||
const tHost = t?.remoteHost || t?.payload?.remote_host || 'local';
|
||||
return String(tRepo) === String(payload.repo_id) && String(tHost || 'local') === String(targetHost);
|
||||
};
|
||||
const duplicate = tasks.find(t => sameDownload(t) && (t.status === 'running' || t.status === 'queued'));
|
||||
if (duplicate) {
|
||||
_renderRunningTab();
|
||||
uiModule.showToast(`${shortName} is already ${duplicate.status === 'queued' ? 'queued' : 'downloading'}`);
|
||||
return;
|
||||
}
|
||||
const activeOnHost = tasks.find(t => t.type === 'download' && (t.status === 'running' || t.status === 'queued') && (t.remoteHost || 'local') === targetHost);
|
||||
|
||||
if (activeOnHost) {
|
||||
@@ -536,18 +555,20 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
uiModule.showToast('Download failed: HTTP ' + res.status);
|
||||
// Errors carry actionable text (e.g. "tmux is required …"); keep them up
|
||||
// long enough to read, matching the serve path's duration (issue #1355).
|
||||
uiModule.showToast('Download failed: HTTP ' + res.status, 9000);
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
uiModule.showToast('Download failed: ' + (data.error || ''));
|
||||
uiModule.showToast('Download failed: ' + (data.error || ''), 9000);
|
||||
return;
|
||||
}
|
||||
_addTask(data.session_id, shortName, 'download', payload);
|
||||
uiModule.showToast(`Downloading ${shortName}...`);
|
||||
} catch (e) {
|
||||
uiModule.showToast('Download failed: ' + e.message);
|
||||
uiModule.showToast('Download failed: ' + e.message, 9000);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// static/js/cookbookProgressSignal.js
|
||||
/**
|
||||
* Liveness signal for a running cookbook download/install. The watchdog treats a
|
||||
* task as stalled when this signal stays unchanged for too long, so it must move
|
||||
* whenever the task is genuinely making progress.
|
||||
*
|
||||
* During a model DOWNLOAD the honest signal is the downloaded-byte counter
|
||||
* ("1.81G" from "1.81G/2.49G"): it climbs while transferring and freezes when
|
||||
* stuck — and unlike a % bar or speed/ETA it doesn't keep animating on a frozen
|
||||
* frame. That path is kept exactly as-is.
|
||||
*
|
||||
* But a dependency install (e.g. vllm) spends long stretches with NO byte
|
||||
* counter — pip dependency resolution and the native CUDA build/compile. A
|
||||
* byte-only signal freezes there, so the watchdog falsely declares the install
|
||||
* stale and restarts it mid-build, looping forever (#1568). When there's no byte
|
||||
* counter, fall back to a fingerprint of the output tail: resolver/compile lines
|
||||
* keep changing while the process is alive, and only a truly hung process leaves
|
||||
* the tail frozen.
|
||||
*
|
||||
* Pure (string in, string out) so it's unit-testable; cookbookRunning.js pulls
|
||||
* in browser-only modules and can't load under node.
|
||||
*/
|
||||
export function computeProgressSignal(bytes, dlAgg, lastPct, snapshot) {
|
||||
if (bytes) return bytes;
|
||||
const base = dlAgg != null ? String(dlAgg) : (lastPct || '0');
|
||||
// No byte counter → use the output tail so a build/resolve phase that emits new
|
||||
// lines counts as progress instead of a false stall (#1568).
|
||||
return base + '|' + String(snapshot || '').slice(-300);
|
||||
}
|
||||
+552
-102
@@ -7,6 +7,7 @@
|
||||
import uiModule from './ui.js';
|
||||
import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.js';
|
||||
import { registerMenuDismiss } from './escMenuStack.js';
|
||||
import { computeProgressSignal } from './cookbookProgressSignal.js';
|
||||
|
||||
// Human-friendly badge label for a task's internal status. Avoids surfacing
|
||||
// the word "error" in the sidebar — a server the user stopped or one that
|
||||
@@ -34,12 +35,105 @@ function _taskBadge(task) {
|
||||
return { text: _statusLabel(task.status, task.type), cls: 'cookbook-task-' + task.status };
|
||||
}
|
||||
|
||||
function _canClearTask(task) {
|
||||
if (!task || task.status === 'running') return false;
|
||||
if (task.type === 'serve' && (task.status === 'ready' || task._serveReady)) return false;
|
||||
return ['done', 'stopped', 'error', 'crashed', 'failed'].includes(task.status);
|
||||
}
|
||||
|
||||
function _clearPillLabel(task) {
|
||||
return 'clear';
|
||||
}
|
||||
|
||||
function _shouldOfferCrashReport(task) {
|
||||
if (!task) return false;
|
||||
if (task._unreachable && task.type === 'serve') return true;
|
||||
return ['error', 'crashed', 'failed'].includes(task.status);
|
||||
}
|
||||
|
||||
function _serveTaskLooksAwqOnLocalBackend(task, outputText = '') {
|
||||
const repo = `${task?.payload?.repo_id || ''} ${task?.name || ''}`.toLowerCase();
|
||||
const cmd = `${task?.payload?._cmd || ''} ${outputText || ''}`.toLowerCase();
|
||||
return /\b(awq|gptq|fp8)\b/.test(repo) && /(llama-server|llama_cpp\.server|ollama|ggml_cuda_enable_unified_memory)/.test(cmd);
|
||||
}
|
||||
|
||||
function _serveTaskLooksAwqWithoutUsableAccelerator(task, outputText = '') {
|
||||
const repo = `${task?.payload?.repo_id || ''} ${task?.name || ''}`.toLowerCase();
|
||||
const out = String(outputText || '').toLowerCase();
|
||||
return /\b(awq|gptq|fp8)\b/.test(repo)
|
||||
&& /(no accelerator|no cuda runtime|failed to infer device type|triton is not supported|0 active driver)/i.test(out);
|
||||
}
|
||||
|
||||
async function _openDownloadForGgufTask(task) {
|
||||
const raw = task?.payload?.repo_id || task?.name || '';
|
||||
const modelName = String(raw)
|
||||
.split('/').pop()
|
||||
.replace(/[-_](?:AWQ|GPTQ|FP8|4bit|8bit|Int4|Int8).*$/i, '')
|
||||
.replace(/[-_]+$/g, '')
|
||||
|| String(raw).split('/').pop()
|
||||
|| raw;
|
||||
const cookbook = window.cookbookModule;
|
||||
if (cookbook && typeof cookbook.open === 'function') {
|
||||
cookbook.open({ tab: 'Search' });
|
||||
} else {
|
||||
document.getElementById('tool-cookbook-btn')?.click();
|
||||
}
|
||||
setTimeout(async () => {
|
||||
const modal = document.getElementById('cookbook-modal');
|
||||
const tab = modal?.querySelector('.cookbook-tab[data-backend="Search"]');
|
||||
if (tab && !tab.classList.contains('active')) tab.click();
|
||||
const search = document.getElementById('hwfit-search');
|
||||
if (search) {
|
||||
search.value = modelName;
|
||||
search.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
search.focus();
|
||||
}
|
||||
const quant = document.getElementById('hwfit-quant');
|
||||
if (quant) {
|
||||
quant.value = 'Q4_K_M';
|
||||
quant.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
try {
|
||||
const hwfit = await import('./cookbook-hwfit.js');
|
||||
if (typeof hwfit._hwfitFetch === 'function') hwfit._hwfitFetch(true);
|
||||
} catch {}
|
||||
}, 80);
|
||||
}
|
||||
|
||||
function _terminalServeDiagnosis(task, outputText) {
|
||||
const out = String(outputText || task?.output || '');
|
||||
if (!task || task.type !== 'serve' || !['stopped', 'error', 'crashed', 'failed'].includes(task.status) || !out.trim()) return null;
|
||||
if (_serveTaskLooksAwqOnLocalBackend(task, out)) {
|
||||
return {
|
||||
message: 'AWQ/GPTQ/FP8 cannot be served through llama.cpp/Ollama unified-memory mode.',
|
||||
suggestion: 'Suggested action: use vLLM/SGLang on a compatible CUDA/ROCm GPU server, or download a GGUF version for llama.cpp/Ollama/unified-memory serving.',
|
||||
fixes: [
|
||||
{ label: 'Find GGUF download', action: () => _openDownloadForGgufTask(task) },
|
||||
{ label: 'Edit serve', action: (panel) => _openServeEditForTask(task) },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (_serveTaskLooksAwqWithoutUsableAccelerator(task, out)) {
|
||||
return {
|
||||
message: 'AWQ/GPTQ/FP8 needs a working vLLM/SGLang accelerator path; this server did not expose one.',
|
||||
suggestion: 'Suggested action: choose a CUDA/ROCm server where vLLM/SGLang can see the GPU, or download a GGUF version and serve it with llama.cpp/Ollama.',
|
||||
fixes: [
|
||||
{ label: 'Find GGUF download', action: () => _openDownloadForGgufTask(task) },
|
||||
{ label: 'Edit serve', action: (panel) => _openServeEditForTask(task) },
|
||||
],
|
||||
};
|
||||
}
|
||||
return _diagnose(out) || {
|
||||
message: /Native llama-server not found|building llama-server|llama\.cpp/i.test(out)
|
||||
? 'llama.cpp build stopped before the server became reachable.'
|
||||
: 'Serve stopped before the model became reachable.',
|
||||
suggestion: /Native llama-server not found|building llama-server|llama\.cpp/i.test(out)
|
||||
? 'Suggested action: copy the troubleshooting bundle, then edit serve settings. For the quickest local/CPU path, use Ollama or a prebuilt llama-server; source builds can take several minutes and fail if build dependencies are incomplete.'
|
||||
: 'Suggested action: copy the troubleshooting bundle, then edit serve settings or relaunch with a CPU/backend fallback.',
|
||||
fixes: [{ label: 'Edit serve', action: (panel) => _openServeEditForTask(task) }],
|
||||
};
|
||||
}
|
||||
|
||||
function _redactCrashReportText(text) {
|
||||
if (!text) return '';
|
||||
return String(text)
|
||||
@@ -136,6 +230,7 @@ const SERVE_STATE_KEY = 'cookbook-serve-state';
|
||||
const TASK_POLL_INTERVAL_MS = 3000; // delay between reconnect-loop iterations
|
||||
const BG_MONITOR_INTERVAL_MS = 10000; // background task status poll
|
||||
const STALE_PROGRESS_MS = 5 * 60 * 1000; // download with no progress this long = stale
|
||||
const STARTUP_STALE_PROGRESS_MS = 45 * 1000; // 0%-forever startup stall: retry much sooner
|
||||
|
||||
// ── Phase detection (mirrors Python _parse_serve_phase in cookbook_routes.py) ──
|
||||
// Single source of truth for serve task status. KEEP IN SYNC with the Python version.
|
||||
@@ -172,6 +267,23 @@ export function _parseServePhase(snapshot) {
|
||||
if (/Ollama API ready on port\s+\d+/i.test(flat)) {
|
||||
return { phase: 'ready', status: 'ready' };
|
||||
}
|
||||
const llamaBuildMatches = [...flat.matchAll(/\[\s*(\d{1,3})%\]\s*(?:Building|Linking)/gi)];
|
||||
if (llamaBuildMatches.length) {
|
||||
const pct = Math.min(100, parseInt(llamaBuildMatches[llamaBuildMatches.length - 1][1], 10));
|
||||
return { phase: `building llama.cpp ${pct}%`, status: 'running', pct };
|
||||
}
|
||||
if (/Native llama-server not found|building from source/i.test(flat)) {
|
||||
if (/Cloning into ['"]?llama\.cpp/i.test(flat) && !/Receiving objects:\s*100%/i.test(flat)) {
|
||||
return { phase: 'cloning llama.cpp', status: 'running' };
|
||||
}
|
||||
if (/Configuring incomplete|CMake Error/i.test(flat)) {
|
||||
return {};
|
||||
}
|
||||
if (/CMAKE_BUILD_TYPE|Detecting CXX|Found Threads|Including CPU backend|CUDA nvcc found|building llama-server/i.test(flat)) {
|
||||
return { phase: 'configuring llama.cpp', status: 'running' };
|
||||
}
|
||||
return { phase: 'building llama.cpp', status: 'running' };
|
||||
}
|
||||
// HTTP access logs (e.g. GET /v1/models 200 OK) mean the server is up
|
||||
if (/(?:GET|POST)\s+\/[^\s]*\s+HTTP\/[\d.]+"\s*\d{3}/.test(flat)) {
|
||||
return { phase: 'idle', status: 'ready' };
|
||||
@@ -264,10 +376,40 @@ function _refreshModelsAfterEndpointChange() {
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function _appendCookbookEndpointScope(fd, remoteHost) {
|
||||
const host = String(remoteHost || '').trim();
|
||||
if (!host || host === 'local' || host === 'localhost' || host === '127.0.0.1') {
|
||||
fd.append('container_local', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
function _connectHostFromRemote(remoteHost, fallback = 'localhost') {
|
||||
const host = String(remoteHost || '').trim();
|
||||
if (!host || host === 'local') return fallback;
|
||||
return host.includes('@') ? host.split('@').pop() : host;
|
||||
}
|
||||
|
||||
function _isAnyBindHost(host) {
|
||||
const h = String(host || '').trim().toLowerCase();
|
||||
return h === '0.0.0.0' || h === '::' || h === '[::]';
|
||||
}
|
||||
|
||||
function _endpointFromAdvertisedUrl(rawUrl, currentHost, fallbackPort = '11434') {
|
||||
try {
|
||||
const u = new URL(rawUrl);
|
||||
const host = _isAnyBindHost(u.hostname) ? currentHost : (u.hostname || currentHost);
|
||||
const port = u.port || fallbackPort;
|
||||
const bracketedHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
||||
return { host, port, baseUrl: `${u.protocol}//${bracketedHost}${port ? `:${port}` : ''}/v1` };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Download queue — runs one at a time per server ──
|
||||
|
||||
function _processQueue() {
|
||||
const tasks = _loadTasks();
|
||||
const tasks = _loadPrunedTasks();
|
||||
const running = tasks.filter(t => t.type === 'download' && t.status === 'running');
|
||||
const queued = tasks.filter(t => t.type === 'download' && t.status === 'queued');
|
||||
if (!queued.length) return;
|
||||
@@ -321,14 +463,24 @@ async function _startQueuedDownload(task) {
|
||||
return;
|
||||
}
|
||||
const oldId = task.sessionId;
|
||||
const tasks = _loadTasks();
|
||||
const t = tasks.find(t => t.sessionId === oldId);
|
||||
if (t) {
|
||||
t.sessionId = data.session_id;
|
||||
t.id = data.session_id;
|
||||
t.status = 'running';
|
||||
_saveTasks(tasks);
|
||||
}
|
||||
const launchedTask = { ...task, sessionId: data.session_id, id: data.session_id, status: 'running' };
|
||||
const key = _downloadDedupeKey(launchedTask);
|
||||
let found = false;
|
||||
const tasks = _loadTasks().filter(t => {
|
||||
if (t.sessionId === oldId) {
|
||||
found = true;
|
||||
t.sessionId = data.session_id;
|
||||
t.id = data.session_id;
|
||||
t.status = 'running';
|
||||
t._startLaunched = true;
|
||||
return true;
|
||||
}
|
||||
if (t.sessionId === data.session_id) return false;
|
||||
return !(key && t.type === 'download' && t.status === 'queued' && _downloadDedupeKey(t) === key);
|
||||
});
|
||||
if (!found) tasks.push(_stripTaskSecrets(launchedTask));
|
||||
_saveTasks(tasks);
|
||||
_renderRunningTab();
|
||||
_startBackgroundMonitor();
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
_renderRunningTab();
|
||||
@@ -340,11 +492,74 @@ async function _startQueuedDownload(task) {
|
||||
|
||||
// ── Task CRUD ──
|
||||
|
||||
function _serveOutputLooksReady(task) {
|
||||
const out = String(task?.output || '');
|
||||
return !!task?._serveReady
|
||||
|| /Application startup complete/i.test(out)
|
||||
|| /Ollama API ready on port\s+\d+/i.test(out)
|
||||
|| /(?:GET|POST)\s+\/[^\s]*\s+HTTP\/[\d.]+"\s*2\d\d/i.test(out);
|
||||
}
|
||||
|
||||
function _normalizeTaskForDisplay(task) {
|
||||
if (!task || typeof task !== 'object') return task;
|
||||
if (task.type === 'serve' && task.status === 'done' && !_serveOutputLooksReady(task)) {
|
||||
return { ...task, status: 'error' };
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
export function _loadTasks() {
|
||||
try { return JSON.parse(localStorage.getItem(TASKS_KEY)) || []; }
|
||||
try { return (JSON.parse(localStorage.getItem(TASKS_KEY)) || []).map(_normalizeTaskForDisplay); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
function _downloadRepoKey(task) {
|
||||
return String(task?.payload?.repo_id || task?.repo_id || task?.repo || task?.name || '').trim();
|
||||
}
|
||||
|
||||
function _downloadHostKey(task) {
|
||||
return String(task?.remoteHost || task?.payload?.remote_host || 'local').trim() || 'local';
|
||||
}
|
||||
|
||||
function _downloadDedupeKey(task) {
|
||||
if (!task || task.type !== 'download') return '';
|
||||
const repo = _downloadRepoKey(task);
|
||||
if (!repo) return '';
|
||||
return `${_downloadHostKey(task)}\n${repo}`;
|
||||
}
|
||||
|
||||
function _pruneQueuedDownloadDuplicates(tasks) {
|
||||
if (!Array.isArray(tasks) || !tasks.length) return tasks || [];
|
||||
const launched = new Set();
|
||||
for (const task of tasks) {
|
||||
if (task?.type !== 'download' || task.status === 'queued') continue;
|
||||
const key = _downloadDedupeKey(task);
|
||||
if (key) launched.add(key);
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const seenQueued = new Set();
|
||||
const next = tasks.filter(task => {
|
||||
if (task?.type !== 'download' || task.status !== 'queued') return true;
|
||||
const key = _downloadDedupeKey(task);
|
||||
if (!key) return true;
|
||||
if (launched.has(key) || seenQueued.has(key)) {
|
||||
changed = true;
|
||||
return false;
|
||||
}
|
||||
seenQueued.add(key);
|
||||
return true;
|
||||
});
|
||||
return changed ? next : tasks;
|
||||
}
|
||||
|
||||
function _loadPrunedTasks() {
|
||||
const tasks = _loadTasks();
|
||||
const pruned = _pruneQueuedDownloadDuplicates(tasks);
|
||||
if (pruned !== tasks) _saveTasks(pruned);
|
||||
return pruned;
|
||||
}
|
||||
|
||||
// Tombstones for removed tasks. Without these, removing a task only deletes it
|
||||
// locally — but the server still has it (its own POST guard even re-preserves
|
||||
// recently-added ones), so the next sync/poll merges it right back ("I removed
|
||||
@@ -407,6 +622,13 @@ export function _addTask(sessionId, name, type, payload) {
|
||||
const _repoId = payload.repo_id;
|
||||
tasks = tasks.filter(t => !(t.type === 'download' && t.status === 'done' && t.payload && t.payload.repo_id === _repoId));
|
||||
}
|
||||
if (type === 'download' && payload && payload.repo_id) {
|
||||
const key = _downloadDedupeKey({ type: 'download', payload, remoteHost });
|
||||
tasks = tasks.filter(t => {
|
||||
if (t.sessionId === sessionId) return false;
|
||||
return !(key && t.type === 'download' && t.status === 'queued' && _downloadDedupeKey(t) === key);
|
||||
});
|
||||
}
|
||||
const task = _stripTaskSecrets({ id: sessionId, sessionId, name, type, status: 'running', output: '', ts: Date.now(), payload: payload || null, remoteHost, sshPort, platform });
|
||||
tasks.push(task);
|
||||
_saveTasks(tasks);
|
||||
@@ -523,6 +745,52 @@ function _tmuxGracefulKill(task) {
|
||||
return `tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null`;
|
||||
}
|
||||
|
||||
function _shQuote(value) {
|
||||
return "'" + String(value ?? '').replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
function _taskLooksOllama(task, outputText = '') {
|
||||
const haystack = `${task?.payload?.backend || ''} ${task?.payload?._cmd || ''} ${task?.payload?._fields?.backend || ''} ${outputText || ''}`;
|
||||
return /\bollama\b/i.test(haystack) || /Ollama API ready on port\s+\d+/i.test(haystack);
|
||||
}
|
||||
|
||||
function _ollamaBaseUrlForTask(task, outputText = '') {
|
||||
const out = String(outputText || '');
|
||||
const ready = out.match(/Ollama API ready on port\s+\d+:\s*(http:\/\/[^\s]+)/i);
|
||||
if (ready) return ready[1].replace(/\/+$/, '');
|
||||
const cmd = String(task?.payload?._cmd || '');
|
||||
const host = cmd.match(/OLLAMA_HOST=([^\s]+)/)?.[1] || '';
|
||||
const port = host.match(/:(\d+)$/)?.[1] || '11434';
|
||||
return `http://127.0.0.1:${port}`;
|
||||
}
|
||||
|
||||
function _ollamaModelForTask(task) {
|
||||
return String(task?.payload?.model || task?.payload?.repo_id || task?.name || '').trim();
|
||||
}
|
||||
|
||||
function _ollamaUnloadCommand(task, outputText = '') {
|
||||
if (!_taskLooksOllama(task, outputText)) return '';
|
||||
const model = _ollamaModelForTask(task);
|
||||
if (!model) return '';
|
||||
const base = _ollamaBaseUrlForTask(task, outputText);
|
||||
const body = JSON.stringify({ model, prompt: '', keep_alive: 0, stream: false });
|
||||
const inner = `curl -sf -X POST ${_shQuote(base + '/api/generate')} -H 'Content-Type: application/json' -d ${_shQuote(body)} >/dev/null 2>&1 || true`;
|
||||
if (task.remoteHost) {
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(inner)}`;
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
|
||||
function _endpointUrlForTask(task, outputText = '') {
|
||||
if (_taskLooksOllama(task, outputText)) {
|
||||
return _ollamaBaseUrlForTask(task, outputText) + '/v1';
|
||||
}
|
||||
const host = _connectHostFromRemote(task.remoteHost);
|
||||
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
|
||||
const port = portMatch ? portMatch[1] : '8000';
|
||||
return `http://${host}:${port}/v1`;
|
||||
}
|
||||
|
||||
// ── Wave animation ──
|
||||
|
||||
const _waveFrames = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▅', '▆▅▄', '▅▄▃', '▄▃▂', '▃▂▁'];
|
||||
@@ -781,17 +1049,23 @@ async function _retryTask(el, task) {
|
||||
body: JSON.stringify({ command: _tmuxGracefulKill(task) }),
|
||||
});
|
||||
} catch {}
|
||||
_removeTask(task.sessionId);
|
||||
if (task.payload) {
|
||||
if (task.type === 'serve' && task.payload._cmd) {
|
||||
_removeTask(task.sessionId);
|
||||
_launchServeTask(task.name, task.payload.repo_id, task.payload._cmd, task.payload._fields, task.remoteHost || '');
|
||||
} else {
|
||||
_retryDownload(task.name, task.payload);
|
||||
uiModule.showToast('Retrying download — progress may look reset while HuggingFace checks cached files, then it should resume.', 7000);
|
||||
_updateTask(task.sessionId, {
|
||||
status: 'running',
|
||||
output: `${task.output || ''}\n\n[odysseus] Retrying download. Progress may briefly look like a fresh download while HuggingFace checks cached/incomplete files; cached partial files will be reused when available.`.trim(),
|
||||
_retrying: true,
|
||||
});
|
||||
_retryDownload(task.name, task.payload, task.sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function _retryDownload(name, payload) {
|
||||
async function _retryDownload(name, payload, replaceSessionId = '') {
|
||||
try {
|
||||
// A retry means the fast hf_transfer path already failed once — fall back to
|
||||
// the plain, reliable downloader for this and any further attempt (it resumes
|
||||
@@ -804,17 +1078,40 @@ async function _retryDownload(name, payload) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
uiModule.showToast('Download failed: HTTP ' + res.status);
|
||||
if (replaceSessionId) _updateTask(replaceSessionId, { status: 'crashed', _retrying: false });
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
uiModule.showToast('Download failed: ' + (data.error || ''));
|
||||
if (replaceSessionId) _updateTask(replaceSessionId, { status: 'crashed', _retrying: false });
|
||||
return;
|
||||
}
|
||||
_addTask(data.session_id, name, 'download', _payload);
|
||||
if (replaceSessionId) {
|
||||
const tasks = _loadTasks();
|
||||
const task = tasks.find(t => t.sessionId === replaceSessionId);
|
||||
if (task) {
|
||||
task.id = data.session_id;
|
||||
task.sessionId = data.session_id;
|
||||
task.status = 'running';
|
||||
task.output = '';
|
||||
task.ts = Date.now();
|
||||
task.payload = _payload;
|
||||
task._retrying = false;
|
||||
_saveTasks(tasks);
|
||||
_soloExpandTaskId = data.session_id;
|
||||
_renderRunningTab();
|
||||
_startBackgroundMonitor();
|
||||
} else {
|
||||
_addTask(data.session_id, name, 'download', _payload);
|
||||
}
|
||||
} else {
|
||||
_addTask(data.session_id, name, 'download', _payload);
|
||||
}
|
||||
uiModule.showToast(`Downloading ${name}...`);
|
||||
} catch (e) {
|
||||
uiModule.showToast('Download failed: ' + e.message);
|
||||
if (replaceSessionId) _updateTask(replaceSessionId, { status: 'crashed', _retrying: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -875,7 +1172,7 @@ export async function _serveAutoFix(panel, envVar) {
|
||||
// Edit button, but optionally with a modified command (used by the diagnosis
|
||||
// "Retry with X" buttons so a retry lands in the editable Serve panel with the
|
||||
// adjusted setting, instead of blindly relaunching).
|
||||
async function _openServeEditForTask(task, cmdOverride) {
|
||||
async function _openServeEditForTask(task, cmdOverride, fieldOverrides = null) {
|
||||
const repo = task.payload?.repo_id;
|
||||
if (!repo) { uiModule.showToast('No model info on this task'); return; }
|
||||
const cmd = cmdOverride || task.payload?._cmd;
|
||||
@@ -883,6 +1180,9 @@ async function _openServeEditForTask(task, cmdOverride) {
|
||||
let fields = cmdOverride
|
||||
? _parseServeCmdToFields(cmd)
|
||||
: (task.payload?._fields || (cmd ? _parseServeCmdToFields(cmd) : null));
|
||||
if (fieldOverrides && typeof fieldOverrides === 'object') {
|
||||
fields = { ...(fields || {}), ...fieldOverrides };
|
||||
}
|
||||
// Switch the active server to the one this serve ran on (mirrors _openEdit).
|
||||
const _tHost = task.remoteHost || '';
|
||||
_envState.remoteHost = _tHost;
|
||||
@@ -1062,12 +1362,27 @@ function _parseServeCmdToFields(cmd) {
|
||||
gpu_mem: ex(/--gpu-memory-utilization\s+([\d.]+)/) || '0.90',
|
||||
swap: ex(/--swap-space\s+(\d+)/) || '',
|
||||
dtype: ex(/--dtype\s+(\w+)/) || 'auto',
|
||||
vllm_kv_cache_dtype: ex(/--kv-cache-dtype\s+([\w.-]+)/) || 'auto',
|
||||
max_seqs: ex(/--max-num-seqs\s+(\d+)/) || '',
|
||||
gpus: ex(/CUDA_VISIBLE_DEVICES=(\S+)/) || '',
|
||||
cache_type: ex(/(?:--cache-type-k|-ctk)\s+(\S+)/) || '',
|
||||
llama_fit: ex(/(?:--fit|-fit)\s+(on|off)/) || '',
|
||||
llama_split_mode: ex(/(?:--split-mode|-sm)\s+(none|layer|row|tensor)/) || '',
|
||||
llama_tensor_split: ex(/(?:--tensor-split|-ts)\s+([0-9.,]+)/) || '',
|
||||
llama_main_gpu: ex(/(?:--main-gpu|-mg)\s+(\d+)/) || '',
|
||||
llama_parallel: ex(/(?:--parallel|-np)\s+(\d+)/) || '',
|
||||
llama_batch_size: ex(/(?:--batch-size|-b)\s+(\d+)/) || '',
|
||||
llama_ubatch_size: ex(/(?:--ubatch-size|-ub)\s+(\d+)/) || '',
|
||||
llama_spec_tokens: ex(/--spec-draft-n-max\s+(\d+)/) || '3',
|
||||
enforce_eager: cmd.includes('--enforce-eager'),
|
||||
trust_remote: cmd.includes('--trust-remote-code'),
|
||||
prefix_cache: cmd.includes('--enable-prefix-caching'),
|
||||
auto_tool: cmd.includes('--enable-auto-tool-choice'),
|
||||
flash_attn: /--flash-attn\s+on\b/.test(cmd),
|
||||
unified_mem: /GGML_CUDA_ENABLE_UNIFIED_MEMORY=1/.test(cmd),
|
||||
llama_no_mmap: /--no-mmap\b/.test(cmd),
|
||||
llama_no_warmup: /--no-warmup\b/.test(cmd),
|
||||
llama_speculative_mtp: /--spec-type\s+\S*draft-mtp/.test(cmd),
|
||||
speculative: cmd.includes('--speculative-config'),
|
||||
};
|
||||
const spec = cmd.match(/--speculative-config\s+'?\{[^}]*"method"\s*:\s*"([^"]+)"[^}]*"num_speculative_tokens"\s*:\s*(\d+)/);
|
||||
@@ -1181,7 +1496,7 @@ export function _renderRunningTab() {
|
||||
// event but the matching clear only ran on modal-open, so the highlight
|
||||
// persisted indefinitely after tasks finished in the background.
|
||||
try {
|
||||
const _activeTasks = _loadTasks().filter(t => t.status === 'running' || t.status === 'queued' || t.status === 'error');
|
||||
const _activeTasks = _loadPrunedTasks().filter(t => t.status === 'running' || t.status === 'queued' || t.status === 'error');
|
||||
if (!_activeTasks.length) _clearCookbookNotif();
|
||||
} catch {}
|
||||
|
||||
@@ -1222,6 +1537,8 @@ export function _renderRunningTab() {
|
||||
|
||||
const tasks = _loadTasks();
|
||||
const hasContent = tasks.length > 0;
|
||||
const activeCount = tasks.filter(t => t.status === 'running' || t.status === 'queued').length;
|
||||
const activeCountHtml = activeCount ? ` <span class="cookbook-tab-count">${activeCount}</span>` : '';
|
||||
|
||||
let tabBar = body.querySelector('.cookbook-tabs');
|
||||
if (!tabBar) return;
|
||||
@@ -1231,7 +1548,7 @@ export function _renderRunningTab() {
|
||||
runTab.className = 'cookbook-tab';
|
||||
runTab.dataset.backend = 'Running';
|
||||
const _errCount = tasks.filter(t => t.status === 'error' || t.status === 'crashed').length;
|
||||
runTab.innerHTML = `Running <span class="cookbook-tab-count">${tasks.length}</span>${_errCount ? `<span class="cookbook-tab-error-dot"></span>` : ''}`;
|
||||
runTab.innerHTML = `Running${activeCountHtml}${_errCount ? `<span class="cookbook-tab-error-dot"></span>` : ''}`;
|
||||
tabBar.insertBefore(runTab, tabBar.firstChild);
|
||||
runTab.addEventListener('click', () => {
|
||||
tabBar.querySelectorAll('.cookbook-tab').forEach(t => t.classList.remove('active'));
|
||||
@@ -1242,7 +1559,7 @@ export function _renderRunningTab() {
|
||||
});
|
||||
} else if (runTab) {
|
||||
const _errCount2 = tasks.filter(t => t.status === 'error' || t.status === 'crashed').length;
|
||||
runTab.innerHTML = tasks.length ? `Running <span class="cookbook-tab-count">${tasks.length}</span>${_errCount2 ? '<span class="cookbook-tab-error-dot"></span>' : ''}` : 'Running';
|
||||
runTab.innerHTML = tasks.length ? `Running${activeCountHtml}${_errCount2 ? '<span class="cookbook-tab-error-dot"></span>' : ''}` : 'Running';
|
||||
if (!hasContent) {
|
||||
if (runTab.classList.contains('active')) {
|
||||
const wfTab = tabBar.querySelector('.cookbook-tab[data-backend="Search"]');
|
||||
@@ -1259,7 +1576,7 @@ export function _renderRunningTab() {
|
||||
group.dataset.backendGroup = 'Running';
|
||||
group.innerHTML = '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">' +
|
||||
'<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">' +
|
||||
'<h2 style="margin:0;padding:0;line-height:1;">Running <span id="running-count" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal">' + tasks.length + '</span></h2>' +
|
||||
'<h2 style="margin:0;padding:0;line-height:1;">Running <span id="running-count" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal">' + activeCount + '</span></h2>' +
|
||||
'</div>' +
|
||||
'<p class="memory-desc doclib-desc" style="margin-top:6px;">Active downloads and serving processes.</p>' +
|
||||
'</div>';
|
||||
@@ -1271,7 +1588,7 @@ export function _renderRunningTab() {
|
||||
if (!group) return;
|
||||
|
||||
const countEl = group.querySelector('#running-count');
|
||||
if (countEl) countEl.textContent = tasks.length;
|
||||
if (countEl) countEl.textContent = activeCount;
|
||||
|
||||
if (!hasContent) {
|
||||
group.remove();
|
||||
@@ -1351,8 +1668,8 @@ export function _renderRunningTab() {
|
||||
const host = btn.dataset.clearServer;
|
||||
if (!await window.styledConfirm(`Clear finished tasks on ${_serverName(host)}?`, { confirmText: 'Clear' })) return;
|
||||
const allTasks = _loadTasks();
|
||||
const toRemove = allTasks.filter(t => (t.remoteHost || '') === host && t.status !== 'running');
|
||||
const remaining = allTasks.filter(t => (t.remoteHost || '') !== host || t.status === 'running');
|
||||
const toRemove = allTasks.filter(t => (t.remoteHost || '') === host && _canClearTask(t));
|
||||
const remaining = allTasks.filter(t => (t.remoteHost || '') !== host || !_canClearTask(t));
|
||||
_saveTasks(remaining);
|
||||
// Fade/slide each finished card out (same exit as the per-card clear)
|
||||
// instead of yanking them instantly.
|
||||
@@ -1389,6 +1706,9 @@ export function _renderRunningTab() {
|
||||
const running = _loadTasks().filter(t => (t.remoteHost || '') === host && t.status === 'running');
|
||||
if (!running.length) { uiModule.showToast(`Nothing running on ${_serverName(host)}`); return; }
|
||||
if (!await window.styledConfirm(`Stop ${running.length} running task${running.length > 1 ? 's' : ''} on ${_serverName(host)}?`, { confirmText: 'Stop all' })) return;
|
||||
// Mark every task as user-stopped BEFORE firing the kills so that the
|
||||
// download auto-retry logic never restarts a task the user just stopped.
|
||||
running.forEach(t => _updateTask(t.sessionId, { _userStopped: true }));
|
||||
// Reuse each task's own Stop action so it does the full teardown
|
||||
// (send C-c, drop the endpoint, mark stopped) consistently.
|
||||
running.forEach(t => {
|
||||
@@ -1442,16 +1762,21 @@ export function _renderRunningTab() {
|
||||
const _bdg = _taskBadge(task);
|
||||
badge.textContent = _bdg.text;
|
||||
badge.className = 'cookbook-task-status' + (_bdg.cls ? ' ' + _bdg.cls : '');
|
||||
badge.style.display = isDone ? 'none' : ''; // hidden — type chip carries it
|
||||
badge.style.display = '';
|
||||
}
|
||||
// Indicator: spinning wave while running, green check when finished.
|
||||
const wave = el.querySelector('.cookbook-task-wave');
|
||||
if (wave) wave.style.display = task.status === 'running' ? '' : 'none';
|
||||
// Model downloads (which have a Serve → button) don't get a clear pill —
|
||||
// pressing Serve clears them. Dep installs / serve tasks keep it.
|
||||
const check = el.querySelector('.cookbook-task-check');
|
||||
const _showClear = isDone && !(task.type === 'download' && !task.payload?._dep);
|
||||
if (check) check.style.display = _showClear ? '' : 'none';
|
||||
if (check) {
|
||||
check.style.display = _canClearTask(task) ? '' : 'none';
|
||||
const label = check.querySelector('.cookbook-task-done-label');
|
||||
if (label) label.textContent = _clearPillLabel(task);
|
||||
}
|
||||
const startNow = el.querySelector('.cookbook-task-start-now');
|
||||
if (startNow) startNow.style.display = (task.type === 'download' && task.status === 'queued') ? '' : 'none';
|
||||
const terminalDiag = _terminalServeDiagnosis(task, el.querySelector('.cookbook-output-pre')?.textContent || task.output || '');
|
||||
if (terminalDiag) _showDiagnosis(el, terminalDiag, el.querySelector('.cookbook-output-pre')?.textContent || task.output || '');
|
||||
}
|
||||
if (!task) {
|
||||
if (el._uptimeInterval) { clearInterval(el._uptimeInterval); el._uptimeInterval = null; }
|
||||
@@ -1475,20 +1800,21 @@ export function _renderRunningTab() {
|
||||
<div class="cookbook-task-header">
|
||||
<span class="cookbook-task-type${(task.status === 'done' && task.type === 'download') ? ' cookbook-task-type-done' : ''}" data-type="${esc(task.type)}">${esc((task.status === 'done' && task.type === 'download') ? 'finished' : task.type)}</span>
|
||||
<span class="cookbook-task-name">${modelLogo(task.name)}${esc(task.name)}</span>
|
||||
<span class="cookbook-task-status ${_bdg.cls}" style="display:${task.status === 'done' ? 'none' : ''}"${_bdgTitle}>${esc(_bdg.text)}</span>
|
||||
${task.type === 'serve' && task.payload?._cmd ? '<button class="cookbook-task-edit-btn" title="Edit settings & relaunch"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>' : ''}
|
||||
${task.type === 'serve' && task.payload?._cmd ? '<button class="cookbook-task-save-btn" title="Save preset"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg></button>' : ''}
|
||||
<span class="cookbook-task-indicator"><span class="cookbook-task-wave" style="display:${task.status === 'running' ? '' : 'none'}"></span><span class="cookbook-task-check" title="Clear" style="display:${(task.status === 'done' && !(task.type === 'download' && !task.payload?._dep)) ? '' : 'none'}"><svg class="cookbook-task-check-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><svg class="cookbook-task-clear-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span class="cookbook-task-done-label">done</span><span class="cookbook-task-clear-label">clear</span></span></span>
|
||||
${task.type === 'download' && !task.payload?._dep && task.status === 'done' ? `<span class="cookbook-task-status cookbook-task-done">finished</span>` : ''}
|
||||
<span class="cookbook-task-indicator"><span class="cookbook-task-wave" style="display:${task.status === 'running' ? '' : 'none'}"></span><span class="cookbook-task-check" title="Clear" style="display:${_canClearTask(task) ? '' : 'none'}"><svg class="cookbook-task-check-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><svg class="cookbook-task-clear-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span class="cookbook-task-done-label">${esc(_clearPillLabel(task))}</span><span class="cookbook-task-clear-label">clear</span></span></span>
|
||||
<button type="button" class="cookbook-task-start-now" title="Start this queued download now" style="display:${(task.type === 'download' && task.status === 'queued') ? '' : 'none'}"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><polygon points="8 5 19 12 8 19 8 5"/></svg><span>start now</span></button>
|
||||
<span class="cookbook-task-status ${_bdg.cls}"${_bdgTitle}>${esc(_bdg.text)}</span>
|
||||
<button class="cookbook-task-menu-btn" title="Actions">⋮</button>
|
||||
</div>
|
||||
<div class="cookbook-task-sub"><span class="cookbook-task-session">${esc(task.sessionId)}</span><span class="cookbook-task-uptime" style="display:${((task.type === 'serve' || task.type === 'download') && task.status === 'running') ? '' : 'none'}"></span></div>
|
||||
<div class="cookbook-task-sub"><span class="cookbook-task-session">${esc(task.sessionId)}</span><span class="cookbook-task-uptime" style="display:${((task.type === 'serve' || task.type === 'download') && task.status === 'running') ? '' : 'none'}"></span>${(task.type === 'download') ? `<span class="cookbook-task-dldir" title="Download destination" style="font-size:9px;color:var(--fg-muted);font-family:'Fira Code',monospace;opacity:0.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:40ch;">Dir: ${esc(task.payload?.local_dir || '~/.cache/huggingface/hub')}</span>` : ''}</div>
|
||||
<div class="cookbook-output-wrap cookbook-task-collapsible${_mobileCollapseDefault ? ' cookbook-task-collapsed' : ''}"><pre class="cookbook-output-pre">${esc(task.output || '')}</pre><button type="button" class="copy-code cookbook-output-copy"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>
|
||||
`;
|
||||
|
||||
const _waveEl = el.querySelector('.cookbook-task-wave');
|
||||
if (_waveEl && task.status === 'running') _registerWaveEl(_waveEl);
|
||||
|
||||
const terminalDiag = _terminalServeDiagnosis(task, task.output || '');
|
||||
if (terminalDiag) _showDiagnosis(el, terminalDiag, task.output || '');
|
||||
|
||||
const _uptimeEl = el.querySelector('.cookbook-task-uptime');
|
||||
if (_uptimeEl && (task.type === 'serve' || task.type === 'download') && task.status === 'running') {
|
||||
const _startedAt = task.ts || Date.now();
|
||||
@@ -1505,35 +1831,12 @@ export function _renderRunningTab() {
|
||||
}
|
||||
|
||||
// Re-open the Serve panel for this model, pre-filled with the EXACT
|
||||
// settings this instance launched with, and on the SERVER it runs on —
|
||||
// shared by the edit icon button and the ⋮ "Edit settings" menu item.
|
||||
// settings this instance launched with, and on the SERVER it runs on.
|
||||
const _openEdit = () => _openServeEditForTask(task);
|
||||
const editBtn = el.querySelector('.cookbook-task-edit-btn');
|
||||
if (editBtn) {
|
||||
editBtn.addEventListener('click', (e) => { e.stopPropagation(); _openEdit(); });
|
||||
}
|
||||
|
||||
// Wire save icon button
|
||||
const saveBtn = el.querySelector('.cookbook-task-save-btn');
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
// Tell them it's already saved up front (often true now that working
|
||||
// configs auto-save) instead of after they've typed a name.
|
||||
if (_loadPresets().some(p => p.cmd === task.payload?._cmd)) {
|
||||
uiModule.showToast('Already saved');
|
||||
return;
|
||||
}
|
||||
const label = (await uiModule.styledPrompt('Name this config so you can recall it later.', {
|
||||
title: 'Save Config', defaultValue: task.name, placeholder: 'e.g. 8-bit, fast', confirmText: 'Save',
|
||||
}) || '').trim();
|
||||
if (!label) return;
|
||||
if (!_saveTaskAsPreset(task, label)) { uiModule.showToast('Already saved'); return; }
|
||||
saveBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="2.5" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
uiModule.showToast(`Saved "${label}"`);
|
||||
setTimeout(() => { saveBtn.style.display = 'none'; }, 1500);
|
||||
});
|
||||
}
|
||||
el.addEventListener('cookbook:edit-serve', (e) => {
|
||||
e.stopPropagation();
|
||||
_openServeEditForTask(task, null, e.detail?.fields || null);
|
||||
});
|
||||
|
||||
// Finished download → an explicit "Serve →" button jumps straight to the
|
||||
// Serve tab with this model pre-selected (on the server it downloaded to).
|
||||
@@ -1571,10 +1874,30 @@ export function _renderRunningTab() {
|
||||
if (_clearChk) {
|
||||
_clearChk.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// Belt-and-suspenders: kill the tmux session too. For a real-finished
|
||||
// task the session is already gone and kill-session errors silently,
|
||||
// but for a task that was falsely flagged done (the strict-finish
|
||||
// bug), this guarantees the still-running download actually stops
|
||||
// rather than continuing to write to disk after the row is removed.
|
||||
try {
|
||||
fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxCmd(task, `kill-session -t ${task.sessionId}`) }),
|
||||
}).catch(() => {});
|
||||
} catch {}
|
||||
_animateOutThenRemove(el, task.sessionId);
|
||||
});
|
||||
}
|
||||
|
||||
const _startNowBtn = el.querySelector('.cookbook-task-start-now');
|
||||
if (_startNowBtn) {
|
||||
_startNowBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_startQueuedDownload(task);
|
||||
});
|
||||
}
|
||||
|
||||
// Wire header click to collapse/expand output
|
||||
el.querySelector('.cookbook-task-header').addEventListener('click', (e) => {
|
||||
if (e.target.closest('button')) return;
|
||||
@@ -1675,8 +1998,7 @@ export function _renderRunningTab() {
|
||||
// serve to the model-endpoints list regardless of prior flag state.
|
||||
if (task.type === 'serve' && task.payload?._cmd) {
|
||||
items.push({ label: 'Register endpoint', action: 'register-endpoint', custom: async () => {
|
||||
const rawHost = task.remoteHost || 'localhost';
|
||||
const host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost;
|
||||
const host = _connectHostFromRemote(task.remoteHost);
|
||||
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
|
||||
const port = portMatch ? portMatch[1] : '8000';
|
||||
const baseUrl = `http://${host}:${port}/v1`;
|
||||
@@ -1699,6 +2021,7 @@ export function _renderRunningTab() {
|
||||
fd.append('base_url', baseUrl);
|
||||
fd.append('name', task.name);
|
||||
fd.append('skip_probe', 'true');
|
||||
_appendCookbookEndpointScope(fd, task.remoteHost || '');
|
||||
if (task.payload?._cmd?.includes('diffusion_server')) fd.append('model_type', 'image');
|
||||
const res = await fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
if (res.ok) {
|
||||
@@ -1859,13 +2182,21 @@ export function _renderRunningTab() {
|
||||
const badge = el.querySelector('.cookbook-task-status');
|
||||
if (badge) { badge.textContent = 'stopping...'; badge.className = 'cookbook-task-status cookbook-task-stopping'; }
|
||||
el.dataset.status = 'stopped';
|
||||
_updateTask(task.sessionId, { _userStopped: true });
|
||||
const outputText = el.querySelector('.cookbook-output-pre')?.textContent || task.output || '';
|
||||
// Drop the model endpoint so the picker stops listing it.
|
||||
if (task.type === 'serve' && task.payload) {
|
||||
const rawHost = task.remoteHost || 'localhost';
|
||||
const host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost;
|
||||
const portMatch = task.payload._cmd?.match(/--port\s+(\d+)/);
|
||||
const port = portMatch ? portMatch[1] : '8000';
|
||||
_removeEndpointByUrl(`http://${host}:${port}/v1`);
|
||||
_removeEndpointByUrl(_endpointUrlForTask(task, outputText));
|
||||
}
|
||||
const ollamaUnload = _ollamaUnloadCommand(task, outputText);
|
||||
if (ollamaUnload) {
|
||||
try {
|
||||
await fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: ollamaUnload }),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
// Gracefully stop (C-c, then kill the session) so it's fully down...
|
||||
try {
|
||||
@@ -1882,23 +2213,29 @@ export function _renderRunningTab() {
|
||||
|
||||
// Wire kill
|
||||
el.querySelector('.cookbook-task-action-kill').addEventListener('click', () => {
|
||||
const outputText = el.querySelector('.cookbook-output-pre')?.textContent || task.output || '';
|
||||
const ollamaUnload = _ollamaUnloadCommand(task, outputText);
|
||||
if (ollamaUnload) {
|
||||
fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: ollamaUnload }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxGracefulKill(task) }),
|
||||
}).catch(() => {});
|
||||
if (task.type === 'serve' && task.payload) {
|
||||
const rawHost = task.remoteHost || 'localhost';
|
||||
const host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost;
|
||||
const portMatch = task.payload._cmd?.match(/--port\s+(\d+)/);
|
||||
const port = portMatch ? portMatch[1] : '8000';
|
||||
_removeEndpointByUrl(`http://${host}:${port}/v1`);
|
||||
const endpointUrl = _endpointUrlForTask(task, outputText);
|
||||
_removeEndpointByUrl(endpointUrl);
|
||||
const modelName = task.payload.model || task.name || '';
|
||||
if (modelName) {
|
||||
fetch('/api/model-endpoints', { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(eps => {
|
||||
const ep = eps.find(e => e.name === modelName || (e.base_url && e.base_url.includes(':' + port)));
|
||||
const ep = eps.find(e => e.name === modelName || e.base_url === endpointUrl);
|
||||
if (ep) fetch(`/api/model-endpoints/${ep.id}`, { method: 'DELETE', credentials: 'same-origin' }).then(() => _refreshModelsAfterEndpointChange());
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -2017,19 +2354,65 @@ async function _reconnectTask(el, task) {
|
||||
if (badge) { badge.textContent = _statusLabel('error', task.type); badge.className = 'cookbook-task-status cookbook-task-error'; }
|
||||
_showCookbookNotif(true);
|
||||
} else {
|
||||
const looksSuccessful = !lastOutput.includes('DOWNLOAD_FAILED') && (lastOutput.includes('DONE') || lastOutput.includes('100%') || lastOutput.includes('Application startup complete') || lastOutput.includes('/snapshots/') || lastOutput.includes('Download complete') || lastOutput.includes('DOWNLOAD_OK'));
|
||||
if (!lastOutput.trim() || (task.type === 'download' && !looksSuccessful)) {
|
||||
const downloadLooksSuccessful = !lastOutput.includes('DOWNLOAD_FAILED')
|
||||
&& (lastOutput.includes('DONE') || lastOutput.includes('100%') || lastOutput.includes('/snapshots/') || lastOutput.includes('Download complete') || lastOutput.includes('DOWNLOAD_OK'));
|
||||
const serveLooksReady = task.type === 'serve' && _serveOutputLooksReady({ ...task, output: lastOutput });
|
||||
const looksSuccessful = task.type === 'download' ? downloadLooksSuccessful : serveLooksReady;
|
||||
if (!lastOutput.trim() || !looksSuccessful) {
|
||||
_updateTask(task.sessionId, { status: 'crashed' });
|
||||
el.dataset.status = 'crashed';
|
||||
const badge = el.querySelector('.cookbook-task-status');
|
||||
if (badge) { badge.textContent = _statusLabel('crashed', task.type); badge.className = 'cookbook-task-status cookbook-task-crashed'; }
|
||||
if (task.type === 'serve') {
|
||||
const diag = _diagnose(lastOutput) || {
|
||||
message: _serveTaskLooksAwqOnLocalBackend(task, lastOutput)
|
||||
? 'AWQ/GPTQ/FP8 cannot be served through llama.cpp/Ollama unified-memory mode.'
|
||||
: /Native llama-server not found|building llama-server|llama\.cpp/i.test(lastOutput)
|
||||
? 'llama.cpp build stopped before the server became reachable.'
|
||||
: 'Serve stopped before the model became reachable.',
|
||||
suggestion: _serveTaskLooksAwqOnLocalBackend(task, lastOutput)
|
||||
? 'Suggested action: use vLLM/SGLang on a compatible CUDA/ROCm GPU server, or download a GGUF version for llama.cpp/Ollama/unified-memory serving.'
|
||||
: /Native llama-server not found|building llama-server|llama\.cpp/i.test(lastOutput)
|
||||
? 'Suggested action: copy the troubleshooting bundle, then edit serve settings. For the quickest local/CPU path, use Ollama or a prebuilt llama-server; source builds can take several minutes and fail if build dependencies are incomplete.'
|
||||
: 'Suggested action: copy the troubleshooting bundle, then edit serve settings or relaunch with a CPU/backend fallback.',
|
||||
fixes: [{ label: 'Edit serve', action: (panel) => _openServeEditForTask(task) }],
|
||||
};
|
||||
_showDiagnosis(el, diag, lastOutput);
|
||||
} else if (task.type === 'download') {
|
||||
const isDisk = /no space left|disk quota|enospc/i.test(lastOutput);
|
||||
const isNetwork = /connection|timeout|timed out|incompleteread|chunkedencoding|reset by peer|protocolerror|all connection attempts failed/i.test(lastOutput);
|
||||
const progressMatch = String(lastOutput || '').match(/(\d+)%\|/);
|
||||
const nearDone = progressMatch && Number(progressMatch[1]) >= 80;
|
||||
const diag = {
|
||||
message: isDisk
|
||||
? 'Download stopped because this server ran out of disk space.'
|
||||
: isNetwork
|
||||
? 'Download stopped after the HuggingFace connection was interrupted.'
|
||||
: nearDone
|
||||
? 'Download stopped near the end before the final completion marker was captured.'
|
||||
: 'Download stopped before HuggingFace reported completion.',
|
||||
suggestion: isDisk
|
||||
? 'Suggested action: free disk space, then retry the download. HuggingFace resumes incomplete files when possible.'
|
||||
: nearDone
|
||||
? 'Suggested action: retry the download. It may briefly look like it restarted while cached files are checked, then it should reuse incomplete files.'
|
||||
: 'Suggested action: retry the download. HuggingFace resumes incomplete files when possible.',
|
||||
fixes: [
|
||||
{ label: 'Retry download', action: () => _retryTask(el, task) },
|
||||
{ label: 'Copy last 50 lines', action: () => {
|
||||
const last = String(lastOutput || '').split('\n').slice(-50).join('\n');
|
||||
_copyText(last || 'No download log available.');
|
||||
} },
|
||||
],
|
||||
};
|
||||
_showDiagnosis(el, diag, lastOutput);
|
||||
}
|
||||
_showCookbookNotif(true);
|
||||
} else {
|
||||
_updateTask(task.sessionId, { status: 'done' });
|
||||
el.dataset.status = 'done';
|
||||
const badge = el.querySelector('.cookbook-task-status');
|
||||
if (badge) { badge.textContent = _statusLabel('done', task.type); badge.className = 'cookbook-task-status cookbook-task-done'; }
|
||||
const _chk = el.querySelector('.cookbook-task-check'); if (_chk && task.type !== 'download') _chk.style.display = '';
|
||||
const _chk = el.querySelector('.cookbook-task-check'); if (_chk) _chk.style.display = '';
|
||||
const _sb = el.querySelector('.cookbook-task-serve-btn'); if (_sb) _sb.style.display = '';
|
||||
_showCookbookNotif();
|
||||
_refreshDepsAfterInstall(task);
|
||||
@@ -2071,10 +2454,17 @@ async function _reconnectTask(el, task) {
|
||||
// stale speed/ETA — so keying off speed masked real stalls (that's why a
|
||||
// 97%-stuck download went undetected). Bytes are the honest signal; fall
|
||||
// back to %/aggregate only when no byte counter is present.
|
||||
const _STALE_TIMEOUT = STALE_PROGRESS_MS;
|
||||
const _byteMatches = [...snapshot.matchAll(/([\d.]+\s?[KMGT])B?\s*\/\s*[\d.]+\s?[KMGT]B?/gi)];
|
||||
const _bytes = _byteMatches.length ? _byteMatches[_byteMatches.length - 1][1].replace(/\s/g, '') : null;
|
||||
const curProgress = _bytes || (_dlAgg != null ? String(_dlAgg) : (lastPct || '0'));
|
||||
// When there's no byte counter (pip resolve / native build phase of a
|
||||
// dependency install), key off the output tail so new build lines count
|
||||
// as progress — otherwise a long quiet build is falsely declared stale
|
||||
// and restarted mid-build, looping forever (#1568).
|
||||
const curProgress = computeProgressSignal(_bytes, _dlAgg, lastPct, snapshot);
|
||||
const _fetchPctMatches = [...snapshot.matchAll(/Fetching\s+\d+\s+files:\s*(\d+)%/g)];
|
||||
const _fetchPct = _fetchPctMatches.length ? parseInt(_fetchPctMatches[_fetchPctMatches.length - 1][1]) : null;
|
||||
const _startupStalled = !_bytes && ((_dlAgg === 0) || (_fetchPct === 0)) && curProgress === '0';
|
||||
const _STALE_TIMEOUT = _startupStalled ? STARTUP_STALE_PROGRESS_MS : STALE_PROGRESS_MS;
|
||||
if (!el._lastProgress) { el._lastProgress = curProgress; el._lastProgressTime = Date.now(); }
|
||||
if (curProgress !== el._lastProgress) {
|
||||
el._lastProgress = curProgress;
|
||||
@@ -2095,7 +2485,7 @@ async function _reconnectTask(el, task) {
|
||||
} else if (Date.now() - (el._lastProgressTime || 0) > _STALE_TIMEOUT && !task._autoRestarted) {
|
||||
task._autoRestarted = true;
|
||||
_updateTask(task.sessionId, { _autoRestarted: true });
|
||||
badge.textContent = 'stale — restarting';
|
||||
badge.textContent = _startupStalled ? '0% stall — retrying' : 'stale — restarting';
|
||||
badge.className = 'cookbook-task-status cookbook-task-error';
|
||||
_showCookbookNotif(true);
|
||||
try {
|
||||
@@ -2139,14 +2529,37 @@ async function _reconnectTask(el, task) {
|
||||
break;
|
||||
}
|
||||
|
||||
// When the snapshot includes a shard-of-N marker (e.g.
|
||||
// "model-00006-of-00082.safetensors"), TRUE overall progress is
|
||||
// ((shard-1) + currentShardFraction) / totalShards. Before, _dlAgg
|
||||
// (hf_transfer's per-current-shard aggregate, e.g. 53% of shard 6)
|
||||
// was treated as overall and the row read "53%" while only 5 of
|
||||
// 82 shards were actually done.
|
||||
const _shardPat = [...snapshot.matchAll(/model-(\d+)-of-(\d+)\.(?:safetensors|bin)/g)];
|
||||
const _lastShard = _shardPat.length ? _shardPat[_shardPat.length - 1] : null;
|
||||
const _curShardNum = _lastShard ? parseInt(_lastShard[1], 10) : null;
|
||||
const _totalShards = _lastShard ? parseInt(_lastShard[2], 10) : null;
|
||||
const _useShardAgg = _curShardNum && _totalShards && _totalShards > 1;
|
||||
|
||||
// HF's own "Fetching N files: X%" aggregate counts ALL files,
|
||||
// including ones already finished in a previous session (resume) —
|
||||
// so on a resumed download it reflects the true overall progress,
|
||||
// whereas completed/totalFiles only see this session's files (→ 0%).
|
||||
// Take the higher of the two so resume doesn't read as 0%.
|
||||
const _fetchPctMatches = [...snapshot.matchAll(/Fetching\s+\d+\s+files:\s*(\d+)%/g)];
|
||||
const _fetchPct = _fetchPctMatches.length ? parseInt(_fetchPctMatches[_fetchPctMatches.length - 1][1]) : null;
|
||||
if (_dlAgg != null) {
|
||||
if (_useShardAgg) {
|
||||
// Multi-shard download: compute TRUE overall as completed shards
|
||||
// plus the current shard's fraction. _dlAgg / lastPct represent
|
||||
// *this shard's* progress, not the whole download.
|
||||
const curShardFrac = (_dlAgg != null)
|
||||
? _dlAgg / 100
|
||||
: (lastPct ? parseInt(lastPct, 10) / 100 : 0);
|
||||
let overallPct = Math.round((((_curShardNum - 1) + curShardFrac) / _totalShards) * 100);
|
||||
if (_fetchPct != null) overallPct = Math.max(overallPct, _fetchPct);
|
||||
let text = `${overallPct}%`;
|
||||
if (lastSpeed) text += ` · ${lastSpeed}`;
|
||||
badge.textContent = text;
|
||||
badge.className = 'cookbook-task-status cookbook-task-running';
|
||||
} else if (_dlAgg != null) {
|
||||
// Real aggregate byte progress — most accurate; take the max of all signals.
|
||||
let pct = _dlAgg;
|
||||
if (_fetchPct != null) pct = Math.max(pct, _fetchPct);
|
||||
@@ -2182,7 +2595,7 @@ async function _reconnectTask(el, task) {
|
||||
const _accessDenied = /Access to model.*is restricted|gated repo|GatedRepoError|401 Unauthorized|403 Forbidden|not in the authorized list|awaiting a review|must (?:be authenticated|have access)/i.test(snapshot);
|
||||
const _dlKey = task.payload?.repo_id || task.name;
|
||||
const _dlN = _dlRetryCount.get(_dlKey) || 0;
|
||||
if (!_accessDenied && task.type === 'download' && task.payload && _dlN < _DL_MAX_AUTO_RETRY) {
|
||||
if (!_accessDenied && !task._userStopped && task.type === 'download' && task.payload && _dlN < _DL_MAX_AUTO_RETRY) {
|
||||
// Auto-retry: kill the dead session and re-launch (resumes from
|
||||
// the cached .incomplete files) after a short delay.
|
||||
_dlRetryCount.set(_dlKey, _dlN + 1);
|
||||
@@ -2297,8 +2710,7 @@ async function _reconnectTask(el, task) {
|
||||
// first one's dedup check can observe the newly-added row.
|
||||
if (task.type === 'serve' && !task._endpointAdded && !task._endpointAddInFlight && task._serveReady) {
|
||||
task._endpointAddInFlight = true;
|
||||
const rawHost = task.remoteHost || 'localhost';
|
||||
let host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost;
|
||||
let host = _connectHostFromRemote(task.remoteHost);
|
||||
const portMatch = task.payload?._cmd?.match(/--port[=\s]+(\d+)/)
|
||||
|| task.payload?._cmd?.match(/(?:^|\s)-p[=\s]+(\d+)/)
|
||||
|| snapshot.match(/Uvicorn running on\D*?:(\d+)/i)
|
||||
@@ -2309,12 +2721,8 @@ async function _reconnectTask(el, task) {
|
||||
let baseUrl = `http://${host}:${port}/v1`;
|
||||
const ollamaUrlMatch = snapshot.match(/Ollama API ready on port\s+\d+:\s*(http:\/\/[^\s]+)/i);
|
||||
if (ollamaUrlMatch) {
|
||||
try {
|
||||
const u = new URL(ollamaUrlMatch[1]);
|
||||
host = u.hostname || host;
|
||||
port = u.port || '11434';
|
||||
baseUrl = `${u.origin}/v1`;
|
||||
} catch {}
|
||||
const endpoint = _endpointFromAdvertisedUrl(ollamaUrlMatch[1], host, '11434');
|
||||
if (endpoint) ({ host, port, baseUrl } = endpoint);
|
||||
}
|
||||
fetch('/api/model-endpoints', { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
@@ -2342,6 +2750,7 @@ async function _reconnectTask(el, task) {
|
||||
fd.append('base_url', baseUrl);
|
||||
fd.append('name', task.name);
|
||||
fd.append('skip_probe', 'true');
|
||||
_appendCookbookEndpointScope(fd, task.remoteHost || '');
|
||||
if (_isDiffusion) fd.append('model_type', 'image');
|
||||
return fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
})
|
||||
@@ -2445,8 +2854,7 @@ async function _checkServeReachability() {
|
||||
]);
|
||||
} catch { return; }
|
||||
for (const task of serveTasks) {
|
||||
const rawHost = task.remoteHost || 'localhost';
|
||||
const host = rawHost.includes('@') ? rawHost.split('@').pop() : rawHost;
|
||||
const host = _connectHostFromRemote(task.remoteHost);
|
||||
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
|
||||
const port = portMatch ? portMatch[1] : '8000';
|
||||
const baseUrl = `http://${host}:${port}/v1`;
|
||||
@@ -2641,6 +3049,52 @@ async function _pollBackgroundStatus() {
|
||||
const data = await res.json();
|
||||
const tasks = data.tasks || [];
|
||||
|
||||
// Reconcile the authoritative tmux/process status back into the persisted
|
||||
// client task list. The Running-tab reconnect loop also does this, but it
|
||||
// only exists while cards are rendered; after a page refresh or closed modal
|
||||
// dependency installs could finish server-side while localStorage stayed
|
||||
// stuck at "running".
|
||||
try {
|
||||
const statusById = new Map(tasks.map(t => [t.session_id, t]));
|
||||
const localTasks = _loadTasks();
|
||||
let changed = false;
|
||||
const completedDeps = [];
|
||||
for (const task of localTasks) {
|
||||
const live = statusById.get(task.sessionId);
|
||||
if (!live) continue;
|
||||
const updates = {};
|
||||
const nextStatus = live.status === 'completed'
|
||||
? 'done'
|
||||
: (live.status === 'error'
|
||||
? 'error'
|
||||
: (live.status === 'stopped' ? (task.type === 'download' ? 'crashed' : 'stopped') : null));
|
||||
if (nextStatus && task.status !== nextStatus) {
|
||||
updates.status = nextStatus;
|
||||
if (nextStatus === 'done' && task.payload?._dep) completedDeps.push(task);
|
||||
}
|
||||
if ((live.status === 'running' || live.status === 'ready') && task.status !== live.status) {
|
||||
updates.status = live.status === 'ready' ? 'ready' : 'running';
|
||||
}
|
||||
if (live.progress && live.progress !== task.progress) updates.progress = live.progress;
|
||||
if (live.output_tail) {
|
||||
const previous = String(task.output || '');
|
||||
const tail = String(live.output_tail || '');
|
||||
if (tail && !previous.endsWith(tail)) {
|
||||
updates.output = `${previous ? `${previous}\n` : ''}${tail}`.slice(-5000);
|
||||
}
|
||||
}
|
||||
if (Object.keys(updates).length) {
|
||||
Object.assign(task, updates);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
_saveTasks(localTasks);
|
||||
_renderRunningTab();
|
||||
completedDeps.forEach(t => _refreshDepsAfterInstall(t));
|
||||
}
|
||||
} catch (_) { /* non-fatal: background status should never break polling */ }
|
||||
|
||||
const statusEl = document.getElementById('cookbook-bg-status');
|
||||
const activeTasks = tasks.filter(t => t.status === 'running' || t.status === 'ready');
|
||||
const errorTasks = tasks.filter(t => t.status === 'error');
|
||||
@@ -2653,8 +3107,7 @@ async function _pollBackgroundStatus() {
|
||||
const localTask = localTasks.find(lt => lt.sessionId === t.session_id);
|
||||
if (localTask && localTask._endpointAdded) continue;
|
||||
|
||||
const rawHost = localTask?.remoteHost || t.remote || 'localhost';
|
||||
let host = rawHost.includes('@') ? rawHost.split('@').pop() : (rawHost === 'local' ? 'localhost' : rawHost);
|
||||
let host = _connectHostFromRemote(localTask?.remoteHost || t.remote);
|
||||
const portMatch = localTask?.payload?._cmd?.match(/--port\s+(\d+)/)
|
||||
|| localTask?.payload?._cmd?.match(/OLLAMA_HOST=[^\s:]+:(\d+)/);
|
||||
let port = portMatch ? portMatch[1] : '8000';
|
||||
@@ -2662,12 +3115,8 @@ async function _pollBackgroundStatus() {
|
||||
const snapshot = t.output || localTask?.output || '';
|
||||
const ollamaUrlMatch = snapshot.match(/Ollama API ready on port\s+\d+:\s*(http:\/\/[^\s]+)/i);
|
||||
if (ollamaUrlMatch) {
|
||||
try {
|
||||
const u = new URL(ollamaUrlMatch[1]);
|
||||
host = u.hostname || host;
|
||||
port = u.port || '11434';
|
||||
baseUrl = `${u.origin}/v1`;
|
||||
} catch {}
|
||||
const endpoint = _endpointFromAdvertisedUrl(ollamaUrlMatch[1], host, '11434');
|
||||
if (endpoint) ({ host, port, baseUrl } = endpoint);
|
||||
}
|
||||
const _isDiffusion = localTask?.payload?._cmd?.includes('diffusion_server');
|
||||
|
||||
@@ -2698,6 +3147,7 @@ async function _pollBackgroundStatus() {
|
||||
fd.append('base_url', baseUrl);
|
||||
fd.append('name', t.model);
|
||||
fd.append('skip_probe', 'true');
|
||||
_appendCookbookEndpointScope(fd, localTask?.remoteHost || t.remote || '');
|
||||
if (_isDiffusion) fd.append('model_type', 'image');
|
||||
if (_supportsTools) fd.append('supports_tools', 'true');
|
||||
return fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
|
||||
+477
-20
@@ -41,6 +41,48 @@ const SERVE_STATE_KEY = 'cookbook-serve-state';
|
||||
|
||||
let _cachedAllModels = [];
|
||||
|
||||
function _repoLooksAwqLike(model, repo) {
|
||||
const q = String(model?.quant || '').toUpperCase();
|
||||
const n = `${repo || ''} ${model?.repo_id || ''} ${model?.name || ''} ${model?.path || ''}`.toLowerCase();
|
||||
return /^AWQ|^GPTQ/.test(q) || q === 'FP8' || /\b(awq|gptq|fp8)\b/i.test(n);
|
||||
}
|
||||
|
||||
function _repoLooksGgufLike(model, repo) {
|
||||
const q = String(model?.quant || '').toUpperCase();
|
||||
const n = `${repo || ''} ${model?.repo_id || ''} ${model?.name || ''} ${model?.path || ''}`.toLowerCase();
|
||||
return !!model?.is_gguf || /^Q[2-8]/.test(q) || /^IQ/.test(q) || q === 'GGUF' || n.includes('gguf');
|
||||
}
|
||||
|
||||
function _serveBackendWarning(model, repo, backend, fields = {}) {
|
||||
const awqLike = _repoLooksAwqLike(model, repo);
|
||||
const ggufLike = _repoLooksGgufLike(model, repo);
|
||||
if (awqLike && (backend === 'llamacpp' || backend === 'ollama')) {
|
||||
return {
|
||||
title: 'AWQ needs vLLM or SGLang',
|
||||
body: 'This model looks like AWQ/GPTQ/FP8 safetensors. llama.cpp and Ollama need GGUF files, so this backend cannot serve it. Choose vLLM/SGLang on a CUDA/ROCm GPU server, or download a GGUF version for llama.cpp/Ollama.',
|
||||
};
|
||||
}
|
||||
if (awqLike && _isMetal() && (backend === 'vllm' || backend === 'sglang')) {
|
||||
return {
|
||||
title: 'AWQ is not a unified-memory path',
|
||||
body: 'This model looks like AWQ/GPTQ/FP8 safetensors. AWQ is for vLLM/SGLang on CUDA/ROCm-style GPU servers, not local unified-memory llama.cpp/Ollama serving. For unified memory, download a GGUF model and use llama.cpp/Ollama.',
|
||||
};
|
||||
}
|
||||
if (awqLike && fields.unified_mem) {
|
||||
return {
|
||||
title: 'AWQ is not a unified-memory path',
|
||||
body: 'This model looks like AWQ/GPTQ/FP8 safetensors, but unified-memory local serving expects GGUF. Use vLLM/SGLang on a compatible GPU server, or download a GGUF version for llama.cpp/Ollama.',
|
||||
};
|
||||
}
|
||||
if (ggufLike && (backend === 'vllm' || backend === 'sglang')) {
|
||||
return {
|
||||
title: 'GGUF needs llama.cpp or Ollama',
|
||||
body: 'This model looks like GGUF. vLLM/SGLang expect HuggingFace safetensors-style repos. Choose llama.cpp/Ollama for GGUF, or download a safetensors model for vLLM/SGLang.',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _hasOwn(obj, key) {
|
||||
return Object.prototype.hasOwnProperty.call(obj || {}, key);
|
||||
}
|
||||
@@ -51,6 +93,67 @@ function _allGpuIds(count) {
|
||||
return Array.from({ length: Math.floor(n) }, (_, i) => String(i)).join(',');
|
||||
}
|
||||
|
||||
function _selectedServeTarget(panel) {
|
||||
const select = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server');
|
||||
const servers = Array.isArray(_envState.servers) ? _envState.servers : [];
|
||||
let host = _envState.remoteHost || '';
|
||||
let server = host ? servers.find(s => s.host === host) : null;
|
||||
if (select && select.value != null) {
|
||||
if (select.value === 'local') {
|
||||
host = '';
|
||||
server = servers.find(s => !s.host || s.host === 'local') || null;
|
||||
} else {
|
||||
const idx = /^\d+$/.test(String(select.value)) ? parseInt(select.value, 10) : -1;
|
||||
server = servers.find(s => s.host === select.value) || (idx >= 0 ? servers[idx] : null) || null;
|
||||
host = server?.host || '';
|
||||
}
|
||||
}
|
||||
const venv = panel?.querySelector('[data-field="venv"]')?.value?.trim() || server?.envPath || _envState.envPath || '';
|
||||
const label = host
|
||||
? (server?.name ? `${server.name} (${host})` : host)
|
||||
: (server?.name || 'local server');
|
||||
return {
|
||||
host,
|
||||
port: host ? (_getPort(host) || server?.port || '') : '',
|
||||
venv,
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
async function _fetchServeRuntimePackage(panel, backend) {
|
||||
const packageByBackend = {
|
||||
vllm: 'vllm',
|
||||
sglang: 'sglang',
|
||||
llamacpp: 'llama_cpp',
|
||||
diffusers: 'diffusers',
|
||||
};
|
||||
const packageName = packageByBackend[backend];
|
||||
if (!packageName) return null;
|
||||
const target = _selectedServeTarget(panel);
|
||||
const params = new URLSearchParams();
|
||||
if (target.host) {
|
||||
params.set('host', target.host);
|
||||
if (target.port) params.set('ssh_port', target.port);
|
||||
if (target.venv) params.set('venv', target.venv);
|
||||
}
|
||||
const res = await fetch('/api/cookbook/packages' + (params.toString() ? '?' + params.toString() : ''), { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const pkg = (data.packages || []).find(p => p.name === packageName);
|
||||
return { pkg, target };
|
||||
}
|
||||
|
||||
function _runtimeNoteText(backend, pkg, target) {
|
||||
const labels = { vllm: 'vLLM', sglang: 'SGLang', llamacpp: 'llama.cpp', diffusers: 'Diffusers' };
|
||||
const label = labels[backend] || backend;
|
||||
if (!pkg) return `${label} readiness unavailable for ${target.label}.`;
|
||||
const note = pkg.status_note || pkg.update_note || '';
|
||||
if (pkg.installed) {
|
||||
return note ? `${label} ready on ${target.label}: ${note}` : `${label} ready on ${target.label}.`;
|
||||
}
|
||||
return note ? `${label} missing on ${target.label}: ${note}` : `${label} missing on ${target.label}.`;
|
||||
}
|
||||
|
||||
// ── Filter/sort cached model list ──
|
||||
|
||||
function _filterCachedList() {
|
||||
@@ -99,6 +202,64 @@ function _isActivelyServing(repoId) {
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function _formatGgufSize(bytes) {
|
||||
const n = Number(bytes || 0);
|
||||
if (!Number.isFinite(n) || n <= 0) return '';
|
||||
if (n >= 1024 ** 3) return `${(n / (1024 ** 3)).toFixed(1)} GB`;
|
||||
if (n >= 1024 ** 2) return `${Math.round(n / (1024 ** 2))} MB`;
|
||||
return `${Math.max(1, Math.round(n / 1024))} KB`;
|
||||
}
|
||||
|
||||
function _ggufFilesForModel(model) {
|
||||
return Array.isArray(model?.gguf_files)
|
||||
? model.gguf_files.filter(f => f && typeof f.rel_path === 'string' && f.rel_path)
|
||||
: [];
|
||||
}
|
||||
|
||||
function _runnableGgufFiles(model) {
|
||||
const files = _ggufFilesForModel(model);
|
||||
const primary = files.filter(f => (f.role || 'model') === 'model');
|
||||
return primary.length ? primary : files;
|
||||
}
|
||||
|
||||
function _ggufFileLabel(file) {
|
||||
const base = (file.name || file.rel_path || '').split('/').pop();
|
||||
const size = _formatGgufSize(file.size_bytes);
|
||||
const quant = file.quant ? `${file.quant} ` : '';
|
||||
const parts = Number(file.parts || 0);
|
||||
const split = parts > 1 ? `, ${parts} parts` : '';
|
||||
const role = file.role && file.role !== 'model' ? ` ${file.role}` : '';
|
||||
return `${quant}${base}${size || split ? ` (${[size, split.replace(/^, /, '')].filter(Boolean).join(', ')})` : ''}${role}`;
|
||||
}
|
||||
|
||||
function _shellPathExpr(path) {
|
||||
const s = String(path || '');
|
||||
if (s === '~') return '${HOME}';
|
||||
if (s.startsWith('~/')) return '${HOME}' + _shellQuote(s.slice(1));
|
||||
return _shellQuote(s);
|
||||
}
|
||||
|
||||
function _selectedGgufExpr(model, repo, relPath) {
|
||||
const rel = String(relPath || '').replace(/^\/+/, '');
|
||||
if (!rel) return '';
|
||||
if (model.is_local_dir && model.path) {
|
||||
const base = String(model.path || '').replace(/\/+$/, '');
|
||||
return `$(printf %s ${_shellPathExpr(`${base}/${repo}/${rel}`)})`;
|
||||
}
|
||||
if (model.path) {
|
||||
const base = String(model.path || '').replace(/\/+$/, '');
|
||||
return `$(printf %s ${_shellPathExpr(`${base}/models--${repo.replace(/\//g, '--')}/snapshots/${rel}`)})`;
|
||||
}
|
||||
const cacheRepo = repo.replace(/\//g, '--');
|
||||
return `$(printf %s \${HOME}${_shellQuote(`/.cache/huggingface/hub/models--${cacheRepo}/snapshots/${rel}`)})`;
|
||||
}
|
||||
|
||||
function _ggufSearchDirExpr(model, repo) {
|
||||
if (model.is_local_dir && model.path) return _shellQuote(`${String(model.path || '').replace(/\/+$/, '')}/${repo}`);
|
||||
if (model.path) return _shellQuote(`${String(model.path || '').replace(/\/+$/, '')}/models--${repo.replace(/\//g, '--')}/snapshots`);
|
||||
return `"$HOME/.cache/huggingface/hub/models--${repo.replace(/\//g, '--')}/snapshots"`;
|
||||
}
|
||||
|
||||
function _rerenderCachedModels() {
|
||||
const list = document.getElementById('hwfit-cached-list');
|
||||
const tagContainer = document.getElementById('serve-tags');
|
||||
@@ -131,6 +292,8 @@ function _rerenderCachedModels() {
|
||||
if (m.path) {
|
||||
metaParts.push(`<span style="opacity:0.7;">${esc(m.path)}</span>`);
|
||||
}
|
||||
const ggufCount = _runnableGgufFiles(m).length;
|
||||
if (ggufCount > 1) metaParts.push(`${ggufCount} GGUFs`);
|
||||
if (m.status === 'downloading') {
|
||||
const _active = _isActivelyDownloading(m.repo_id);
|
||||
metaParts.push(`<span class="cookbook-dl-status" style="color:var(--accent,var(--red));">${_active ? 'downloading' : 'download stalled'}</span>`);
|
||||
@@ -307,7 +470,9 @@ function _rerenderCachedModels() {
|
||||
|
||||
// Toggle — close if already open
|
||||
if (item.classList.contains('doclib-card-expanded')) {
|
||||
item.querySelector('.hwfit-serve-panel')?.remove();
|
||||
const existingPanel = item.querySelector('.hwfit-serve-panel');
|
||||
existingPanel?._cleanupRuntimeReadiness?.();
|
||||
existingPanel?.remove();
|
||||
item.classList.remove('doclib-card-expanded');
|
||||
item.style.flexDirection = '';
|
||||
item.style.alignItems = '';
|
||||
@@ -318,18 +483,14 @@ function _rerenderCachedModels() {
|
||||
|
||||
// Collapse any other expanded
|
||||
list.querySelectorAll('.doclib-card-expanded').forEach(c => {
|
||||
c.querySelector('.hwfit-serve-panel')?.remove();
|
||||
const openPanel = c.querySelector('.hwfit-serve-panel');
|
||||
openPanel?._cleanupRuntimeReadiness?.();
|
||||
openPanel?.remove();
|
||||
c.classList.remove('doclib-card-expanded');
|
||||
c.style.flexDirection = '';
|
||||
c.style.alignItems = '';
|
||||
});
|
||||
|
||||
// Capture grid height
|
||||
const _tb = list.closest('.admin-card')?.querySelector('.memory-toolbar');
|
||||
const _tbH = _tb ? _tb.offsetHeight : 0;
|
||||
list.style.minHeight = (list.offsetHeight + _tbH) + 'px';
|
||||
list.style.maxHeight = (list.offsetHeight + _tbH) + 'px';
|
||||
|
||||
const shortName = repo.split('/').pop();
|
||||
const _es = _envState;
|
||||
// The venv set per-server in Settings (server.envPath). Used as the venv
|
||||
@@ -350,8 +511,13 @@ function _rerenderCachedModels() {
|
||||
? _byRepo[repo]
|
||||
: (_lastUsed || (_isLegacyFlat ? _allSs : {}));
|
||||
const detectedBackend = _detectBackend(m).backend;
|
||||
const defaultBackend = detectedBackend;
|
||||
const savedMatchesBackend = (ss.backend || 'vllm') === detectedBackend;
|
||||
const _allowedBackends = new Set(_isWindows()
|
||||
? ['llamacpp']
|
||||
: (_isMetal() ? ['llamacpp', 'ollama'] : ['vllm', 'sglang', 'llamacpp', 'ollama', 'diffusers']));
|
||||
const defaultBackend = (ss._forceBackend && ss.backend && _allowedBackends.has(ss.backend))
|
||||
? ss.backend
|
||||
: detectedBackend;
|
||||
const savedMatchesBackend = !!ss._forceBackend || (ss.backend || 'vllm') === detectedBackend;
|
||||
const sv = (k, def) => (ss[k] !== undefined && savedMatchesBackend) ? ss[k] : def;
|
||||
const defaultTp = defaultBackend === 'llamacpp' ? '1' : sv('tp', '1');
|
||||
const detectedGpuIds = _allGpuIds(_getGpuToggleTotal?.());
|
||||
@@ -362,7 +528,16 @@ function _rerenderCachedModels() {
|
||||
: (_es.gpus || detectedGpuIds));
|
||||
const tpOpts = [1,2,4,8].map(n => `<option${defaultTp==String(n)?' selected':''}>${n}</option>`).join('');
|
||||
const dtypeOpts = ['auto','float16','bfloat16'].map(d => `<option value="${d}"${sv('dtype','auto')===d?' selected':''}>${d}</option>`).join('');
|
||||
const vllmKvCacheOpts = ['auto','fp8'].map(d => `<option value="${d}"${sv('vllm_kv_cache_dtype','auto')===d?' selected':''}>${d}</option>`).join('');
|
||||
const _l = (name, tip) => `<span>${name}<span class="hwfit-hint" title="${tip}">?</span></span>`;
|
||||
const _ggufChoices = _runnableGgufFiles(m);
|
||||
const _savedGguf = String(sv('gguf_file', '') || '');
|
||||
const _defaultGguf = _ggufChoices.some(f => f.rel_path === _savedGguf)
|
||||
? _savedGguf
|
||||
: (_ggufChoices[0]?.rel_path || '');
|
||||
const _ggufOptions = _ggufChoices.map(f =>
|
||||
`<option value="${esc(f.rel_path)}"${f.rel_path === _defaultGguf ? ' selected' : ''}>${esc(_ggufFileLabel(f))}</option>`
|
||||
).join('');
|
||||
// Build save slots
|
||||
const _allPresets = _loadPresets();
|
||||
const _repoShort = repo.split('/').pop();
|
||||
@@ -372,10 +547,16 @@ function _rerenderCachedModels() {
|
||||
// load, × to delete) plus a "Save current config" row — see _showSavedConfigMenu.
|
||||
// Split button: "Save" saves the current config directly; the arrow opens
|
||||
// the dropdown of saved configs (load / delete). Arrow shows the count.
|
||||
// The arrow button shows just the saved-config count next to a "▾".
|
||||
// Spell out what the number means in the tooltip so users don't have
|
||||
// to click it to find out the badge isn't a notification dot.
|
||||
const _arrowLabel = _modelPresets.length > 0 ? `${_modelPresets.length} ▾` : '▾';
|
||||
const _arrowTitle = _modelPresets.length > 0
|
||||
? `${_modelPresets.length} saved launch config${_modelPresets.length === 1 ? '' : 's'} for ${_repoShort} — click ▾ to load or delete`
|
||||
: `No saved launch configs for ${_repoShort} yet — click Save to add one`;
|
||||
let _slotsHtml = `<div class="cookbook-serve-slots cookbook-saved-split">`
|
||||
+ `<button type="button" class="cookbook-slot-btn cookbook-saved-save" title="Save current config"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save</button>`
|
||||
+ `<button type="button" class="cookbook-slot-btn cookbook-saved-arrow" title="Saved launch configs">${_arrowLabel}</button>`
|
||||
+ `<button type="button" class="cookbook-slot-btn cookbook-saved-arrow" title="${esc(_arrowTitle)}">${_arrowLabel}</button>`
|
||||
+ `</div>`;
|
||||
|
||||
let panelHtml = `<div class="hwfit-serve-panel">${_slotsHtml}`;
|
||||
@@ -403,6 +584,14 @@ function _rerenderCachedModels() {
|
||||
}
|
||||
panelHtml += `<label>${_l('GPUs','Toggle which GPUs to use')}<div class="cookbook-gpu-group">${_gpuBtnsHtml}</div><input type="hidden" class="hwfit-sf" data-field="gpus" value="${esc(defaultGpus)}" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
panelHtml += `<div class="hwfit-serve-runtime-note" style="display:none;font-size:11px;line-height:1.35;color:var(--fg-muted);margin-top:-4px;"></div>`;
|
||||
if (_ggufChoices.length > 1) {
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-llamacpp">`;
|
||||
panelHtml += `<label class="hwfit-backend-llamacpp">${_l('GGUF File','Choose the exact GGUF artifact to serve from this cached model folder.')}<select class="hwfit-sf hwfit-sf-wide" data-field="gguf_file">${_ggufOptions}</select></label>`;
|
||||
panelHtml += `</div>`;
|
||||
} else if (_defaultGguf) {
|
||||
panelHtml += `<input type="hidden" class="hwfit-sf" data-field="gguf_file" value="${esc(_defaultGguf)}" />`;
|
||||
}
|
||||
// Row 2: Core settings
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-vllm hwfit-backend-sglang hwfit-backend-llamacpp">`;
|
||||
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('TP','Tensor Parallelism — split model across N GPUs')}<select class="hwfit-sf" data-field="tp">${tpOpts}</select></label>`;
|
||||
@@ -414,6 +603,7 @@ function _rerenderCachedModels() {
|
||||
panelHtml += `<label class="hwfit-backend-vllm">${_l('Swap','CPU swap space in GB. Leave empty to omit (removed in newer vLLM)')}<input type="text" class="hwfit-sf" data-field="swap" value="${esc(sv('swap', ''))}" placeholder="off" /></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang">${_l('Max Seqs','Maximum concurrent requests. Lower = less memory. Default 8 — prosumer GPUs often OOM on vLLM default 256 during CUDA graph capture.')}<input type="text" class="hwfit-sf" data-field="max_seqs" value="${esc(sv('max_seqs', '8'))}" placeholder="8" /></label>`;
|
||||
panelHtml += `<label>${_l('Dtype','Data type for weights. auto picks best for GPU')}<select class="hwfit-sf" data-field="dtype">${dtypeOpts}</select></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-vllm">${_l('KV Cache','vLLM --kv-cache-dtype. auto uses the model/runtime default; fp8 reduces KV memory for long context.')}<select class="hwfit-sf" data-field="vllm_kv_cache_dtype">${vllmKvCacheOpts}</select></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Row 2b: Diffusers settings
|
||||
const diffDtypeOpts = ['bfloat16','float16','float32'].map(d => `<option value="${d}"${sv('diff_dtype','bfloat16')===d?' selected':''}>${d}</option>`).join('');
|
||||
@@ -432,9 +622,47 @@ function _rerenderCachedModels() {
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="prefix_cache"${sv('prefix_cache',false)?' checked':''} /> Prefix Caching${_h('Cache shared prompt prefixes across requests')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb hwfit-backend-vllm"><input type="checkbox" class="hwfit-sf" data-field="auto_tool"${sv('auto_tool',false)?' checked':''} /> Auto Tool Choice${_h('Enable function/tool calling for agent mode')}</label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Row 2c: llama.cpp fit/perf flags (set by Auto profiles, editable by hand)
|
||||
const _kvOpts = ['', 'q4_0', 'q8_0', 'f16'].map(k => `<option value="${k}"${sv('cache_type','')===k?' selected':''}>${k||'default'}</option>`).join('');
|
||||
const llamaFitOpts = ['', 'off', 'on'].map(d => `<option value="${d}"${sv('llama_fit','')===d?' selected':''}>${d||'default'}</option>`).join('');
|
||||
const llamaSplitModeOpts = ['', 'layer', 'tensor', 'row', 'none'].map(d => `<option value="${d}"${sv('llama_split_mode','')===d?' selected':''}>${d||'default'}</option>`).join('');
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-llamacpp">`;
|
||||
panelHtml += `<label>${_l('CPU MoE','n-cpu-moe: number of MoE expert layers to run on CPU when the model is bigger than VRAM. 0 = all on GPU. Set automatically by the Auto profiles below.')}<input type="text" class="hwfit-sf" data-field="n_cpu_moe" value="${esc(sv('n_cpu_moe',''))}" placeholder="0" style="width:54px;" /></label>`;
|
||||
panelHtml += `<label>${_l('KV Cache','cache-type-k/v: quantize the KV cache. q4_0 = smallest (more context), q8_0 = sharp long-context, f16 = full. Blank = llama.cpp default.')}<select class="hwfit-sf" data-field="cache_type">${_kvOpts}</select></label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb" style="align-self:end;"><input type="checkbox" class="hwfit-sf" data-field="flash_attn"${sv('flash_attn',false)?' checked':''} /> Flash Attn${_h('--flash-attn on: faster attention + needed for quantized KV cache.')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb" style="align-self:end;"><input type="checkbox" class="hwfit-sf" data-field="vision"${sv('vision',false)?' checked':''} /> Vision${_h('Serve with the vision encoder so the model can read images. Auto-finds an mmproj-*.gguf next to the model (download one into the model folder). Adds ~1 GB VRAM + a small per-image cost.')}</label>`;
|
||||
panelHtml += `<label>${_l('Fit','llama.cpp --fit. Leave default unless you need explicit off/on behavior for a preset.')}<select class="hwfit-sf" data-field="llama_fit">${llamaFitOpts}</select></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Row 2d: native llama-server placement/runtime controls. These are
|
||||
// explicit overrides for known-good advanced presets; blank keeps
|
||||
// llama.cpp/profile defaults.
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-llamacpp">`;
|
||||
panelHtml += `<label>${_l('Split Mode','llama.cpp GPU placement. layer is the usual default; tensor splits weights and KV across GPUs.')}<select class="hwfit-sf" data-field="llama_split_mode">${llamaSplitModeOpts}</select></label>`;
|
||||
panelHtml += `<label>${_l('Tensor Split','GPU proportions for llama.cpp, e.g. 50,50 across two visible GPUs. Leave blank for auto.')}<input type="text" class="hwfit-sf" data-field="llama_tensor_split" value="${esc(sv('llama_tensor_split', ''))}" placeholder="50,50" /></label>`;
|
||||
panelHtml += `<label>${_l('Main GPU','llama.cpp --main-gpu index inside the visible GPU set. Mostly useful for split mode none/row.')}<input type="text" class="hwfit-sf" data-field="llama_main_gpu" value="${esc(sv('llama_main_gpu', ''))}" placeholder="auto" /></label>`;
|
||||
panelHtml += `<label>${_l('Parallel','llama.cpp parallel slots. Leave blank for llama.cpp default; 1 matches single-lane presets.')}<input type="text" class="hwfit-sf" data-field="llama_parallel" value="${esc(sv('llama_parallel', ''))}" placeholder="1" /></label>`;
|
||||
panelHtml += `<label>${_l('Batch','llama.cpp prompt batch size. Leave blank for llama.cpp default.')}<input type="text" class="hwfit-sf" data-field="llama_batch_size" value="${esc(sv('llama_batch_size', ''))}" placeholder="2048" /></label>`;
|
||||
panelHtml += `<label>${_l('UBatch','llama.cpp physical micro-batch size. Leave blank for llama.cpp default.')}<input type="text" class="hwfit-sf" data-field="llama_ubatch_size" value="${esc(sv('llama_ubatch_size', ''))}" placeholder="512" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Row 2d: Auto profiles — computed from detected hardware (see profiles.py).
|
||||
// Buttons are injected after the panel mounts (needs an async fetch).
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-llamacpp hwfit-serve-profiles" style="align-items:center;gap:8px;">`;
|
||||
panelHtml += `<span style="opacity:0.7;font-size:11px;">Auto profiles:</span>`;
|
||||
panelHtml += `<span class="hwfit-profile-btns" style="display:flex;gap:6px;flex-wrap:wrap;"><span style="opacity:0.5;font-size:11px;">computing…</span></span>`;
|
||||
panelHtml += `</div>`;
|
||||
// Live VRAM / RAM-spillover monitor for the serve target's GPU. Polls
|
||||
// /api/cookbook/gpus while the panel is open so you can SEE whether the
|
||||
// config fits VRAM (fast) or spills to system RAM (slow). Populated after mount.
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-llamacpp hwfit-vram-monitor" style="align-items:center;gap:8px;font-size:11px;">`;
|
||||
panelHtml += `<span style="opacity:0.7;">GPU memory:</span>`;
|
||||
panelHtml += `<span class="hwfit-vram-readout" style="opacity:0.5;">checking…</span>`;
|
||||
panelHtml += `</div>`;
|
||||
// Row 3a: Checkboxes (llama.cpp-only)
|
||||
panelHtml += `<div class="hwfit-serve-checks hwfit-backend-llamacpp">`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="unified_mem"${sv('unified_mem',false)?' checked':''} /> Unified Memory${_h('For AMD APUs / Strix Halo: exports GGML_CUDA_ENABLE_UNIFIED_MEMORY=1 so llama.cpp can address the full BIOS VRAM carveout instead of the default ~28 GB cap. No-op on discrete GPUs.')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="llama_no_mmap"${sv('llama_no_mmap',false)?' checked':''} /> No mmap${_h('Adds --no-mmap for native llama-server. Useful for some high-context/local-storage setups, but not a universal default.')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="llama_no_warmup"${sv('llama_no_warmup',false)?' checked':''} /> Skip warmup${_h('Adds --no-warmup. Can reduce startup memory spikes for tight launches, but llama.cpp defaults to warming up.')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb hwfit-spec-group"><input type="checkbox" class="hwfit-sf" data-field="llama_speculative_mtp"${sv('llama_speculative_mtp',false)?' checked':''} /> MTP Spec${_h('llama.cpp native MTP speculative decoding: --spec-type draft-mtp. Requires a GGUF with MTP heads and a recent llama-server build.')} <span class="hwfit-numstep"><button type="button" class="hwfit-numstep-btn" data-step="-1" tabindex="-1" aria-label="Decrease">‹</button><input type="number" class="hwfit-sf hwfit-spec-tokens" data-field="llama_spec_tokens" value="${esc(sv('llama_spec_tokens', '3'))}" min="1" max="10" title="--spec-draft-n-max" /><button type="button" class="hwfit-numstep-btn" data-step="1" tabindex="-1" aria-label="Increase">›</button></span></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Row 3b: Checkboxes (diffusers)
|
||||
panelHtml += `<div class="hwfit-serve-checks hwfit-backend-diffusers">`;
|
||||
@@ -500,9 +728,10 @@ function _rerenderCachedModels() {
|
||||
item.classList.add('doclib-card-expanded');
|
||||
item.style.flexDirection = 'column';
|
||||
item.style.alignItems = 'stretch';
|
||||
if (list) list.scrollTop = 0;
|
||||
item.insertAdjacentHTML('beforeend', panelHtml);
|
||||
const panel = item.querySelector('.hwfit-serve-panel');
|
||||
// Scroll the serve panel into view within its nearest scrollable ancestor
|
||||
requestAnimationFrame(() => panel.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
|
||||
|
||||
// Build command preview
|
||||
function updateCmd() {
|
||||
@@ -514,19 +743,27 @@ function _rerenderCachedModels() {
|
||||
const backend = f.backend || 'vllm';
|
||||
const serveModel = m.is_local_dir && m.path ? `${m.path}/${repo}` : repo;
|
||||
if (backend === 'llamacpp') {
|
||||
const ggufChoices = _runnableGgufFiles(m);
|
||||
const selectedGguf = ggufChoices.find(file => file.rel_path === f.gguf_file);
|
||||
// For multi-part GGUFs, llama.cpp requires the first split
|
||||
// (-00001-of-NNNNN.gguf). Prefer it (sorted, so UD-IQ4_XS/001 comes
|
||||
// before Q4_K_M/001 etc); fall back to any single GGUF sorted.
|
||||
// Use $HOME (not ~) so tilde survives variable interpolation inside $(...).
|
||||
const dir = `"$HOME/.cache/huggingface/hub/models--${repo.replace(/\//g, '--')}/snapshots"`;
|
||||
const dir = _ggufSearchDirExpr(m, repo);
|
||||
// GGUF needs the actual .gguf FILE, not the folder. For a custom-dir
|
||||
// model the file lives under "<path>/<repo>" — search there just like we
|
||||
// search the HF snapshots dir, so serving a GGUF from a custom dir works
|
||||
// instead of handing llama.cpp a directory (which fails).
|
||||
const _ldir = `"${m.path}/${repo}"`;
|
||||
f._gguf_path = m.is_local_dir && m.path
|
||||
const _ldir = m.path ? _shellQuote(`${m.path}/${repo}`) : '""';
|
||||
f._gguf_path = selectedGguf
|
||||
? _selectedGgufExpr(m, repo, selectedGguf.rel_path)
|
||||
: m.is_local_dir && m.path
|
||||
? `$({ find ${_ldir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${_ldir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`
|
||||
: `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`;
|
||||
// Vision: auto-find the mmproj (CLIP/projector) file in the same dir.
|
||||
// Resolved at runtime so the toggle just works if an mmproj-*.gguf is
|
||||
// present (downloaded alongside the model). Empty if none → cmd omits it.
|
||||
const _vsearchdir = (m.is_local_dir && m.path) ? _ldir : dir;
|
||||
f._mmproj_path = `$(find ${_vsearchdir} -iname 'mmproj*.gguf' 2>/dev/null | sort | head -1)`;
|
||||
}
|
||||
if (f.reasoning_parser) {
|
||||
const _rpEl2 = panel.querySelector('[data-field="reasoning_parser"]');
|
||||
@@ -541,6 +778,151 @@ function _rerenderCachedModels() {
|
||||
}
|
||||
updateCmd();
|
||||
|
||||
// Context clamp. Two ceilings:
|
||||
// - ABSOLUTE_CTX_MAX: a hard sanity cap (no LLM trains past ~1M tokens),
|
||||
// so an obvious typo like 16000000 can never reach llama.cpp even when
|
||||
// we don't know the model's real limit (not in catalog / profiles
|
||||
// fetch failed). This is what stops the radv ErrorDeviceLost crash.
|
||||
// - panel._modelCtxMax: the model's actual trained limit (set by the
|
||||
// profiles fetch below) — a tighter, model-specific cap when known.
|
||||
const ABSOLUTE_CTX_MAX = 1048576; // 1M tokens — above any real n_ctx_train
|
||||
const _ctxEl0 = panel.querySelector('[data-field="ctx"]');
|
||||
function _clampCtx(announce) {
|
||||
if (!_ctxEl0) return;
|
||||
const cap = panel._modelCtxMax > 0 ? panel._modelCtxMax : ABSOLUTE_CTX_MAX;
|
||||
const v = parseInt(_ctxEl0.value, 10);
|
||||
if (Number.isFinite(v) && v > cap) {
|
||||
_ctxEl0.value = String(cap);
|
||||
_ctxEl0.title = `Capped to ${panel._modelCtxMax > 0 ? "this model's trained limit" : "the maximum sane context"} (${cap}).`;
|
||||
if (announce) uiModule.showToast(`Context capped to ${cap}`);
|
||||
updateCmd();
|
||||
}
|
||||
}
|
||||
if (_ctxEl0) {
|
||||
_ctxEl0.addEventListener('change', () => _clampCtx(false));
|
||||
_ctxEl0.addEventListener('blur', () => _clampCtx(false));
|
||||
_clampCtx(false); // fix any stale/preset value already present
|
||||
}
|
||||
|
||||
// Auto profiles — fetch hardware-computed llama.cpp profiles and render
|
||||
// them as clickable chips. Clicking one fills the ctx/CPU-MoE/KV/flash
|
||||
// fields and rebuilds the command. Computed from detected VRAM (see
|
||||
// services/hwfit/profiles.py); rough on t/s, accurate on fit.
|
||||
async function _loadServeProfiles() {
|
||||
const wrap = panel.querySelector('.hwfit-profile-btns');
|
||||
if (!wrap) return;
|
||||
try {
|
||||
const host = (_es.remoteHost || '').trim();
|
||||
const params = new URLSearchParams({ model: repo });
|
||||
if (host) {
|
||||
params.set('host', host);
|
||||
const _sp = (_es.servers || []).find(s => s.host === host)?.port;
|
||||
if (_sp) params.set('ssh_port', _sp);
|
||||
}
|
||||
// SERVE mode: this is a specific GGUF file already on disk, so its quant
|
||||
// is fixed — tell the profiler the file's real size + quant so it varies
|
||||
// only the serving knobs (KV/ctx/offload), not the quant. Parse the size
|
||||
// from m.size (e.g. "20.6 GB") and the quant from the file/repo name.
|
||||
const _sizeMatch = String(m.size || '').match(/([\d.]+)\s*GB/i);
|
||||
if (_sizeMatch) params.set('serve_weights_gb', _sizeMatch[1]);
|
||||
const _qMatch = String(repo).match(/(Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP8)/i);
|
||||
if (_qMatch) params.set('serve_quant', _qMatch[1]);
|
||||
const res = await fetch(`/api/hwfit/profiles?${params}`);
|
||||
const data = await res.json();
|
||||
// Remember the model's trained context limit and clamp the ctx field
|
||||
// to it — asking llama.cpp for ctx > n_ctx_train overflows and, with a
|
||||
// quantized KV cache, can crash the GPU (radv ErrorDeviceLost).
|
||||
const ctxMax = Number(data && data.model_ctx_max) || 0;
|
||||
if (ctxMax > 0) {
|
||||
panel._modelCtxMax = ctxMax; // tighten the clamp to the real limit
|
||||
_clampCtx(false); // re-apply now that we know the model's max
|
||||
}
|
||||
const profs = (data && Array.isArray(data.profiles)) ? data.profiles : [];
|
||||
if (!profs.length) { wrap.innerHTML = `<span style="opacity:0.5;font-size:11px;">no auto profile for this model</span>`; return; }
|
||||
wrap.innerHTML = '';
|
||||
for (const p of profs) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'cookbook-btn hwfit-profile-chip';
|
||||
b.style.cssText = 'height:24px;padding:0 9px;font-size:11px;';
|
||||
const off = p.offloads ? `, ncm${p.n_cpu_moe}` : ', all-GPU';
|
||||
b.textContent = `${p.label} · ${p.quant} · ${Math.round(p.ctx/1024)}k${off}`;
|
||||
b.title = `${p.note}\nKV ${p.cache_type}, ~${p.est_vram_gb} GB VRAM`;
|
||||
b.addEventListener('click', () => {
|
||||
const set = (field, val) => {
|
||||
const el = panel.querySelector(`[data-field="${field}"]`);
|
||||
if (!el) return;
|
||||
if (el.type === 'checkbox') el.checked = !!val; else el.value = val;
|
||||
};
|
||||
set('ctx', p.ctx);
|
||||
set('n_cpu_moe', p.n_cpu_moe || '');
|
||||
set('cache_type', p.cache_type || '');
|
||||
set('flash_attn', true); // required for a quantized KV cache
|
||||
wrap.querySelectorAll('.hwfit-profile-chip').forEach(x => x.classList.remove('cookbook-btn-active'));
|
||||
b.classList.add('cookbook-btn-active');
|
||||
updateCmd();
|
||||
});
|
||||
wrap.appendChild(b);
|
||||
}
|
||||
} catch {
|
||||
wrap.innerHTML = `<span style="opacity:0.5;font-size:11px;">profile compute failed</span>`;
|
||||
}
|
||||
}
|
||||
_loadServeProfiles();
|
||||
|
||||
// Live GPU-memory monitor: poll /api/cookbook/gpus and show VRAM usage +
|
||||
// RAM-spillover, with a plain-language health/speed hint. Lets you tell at
|
||||
// a glance whether the chosen config fits VRAM (fast) or is paging into
|
||||
// system RAM over PCIe (slow). AMD sysfs reports gtt_used_mb for spillover.
|
||||
async function _refreshVramMonitor() {
|
||||
const el = panel.querySelector('.hwfit-vram-readout');
|
||||
if (!el || !document.body.contains(el)) return false; // panel closed → stop
|
||||
try {
|
||||
const host = (_es.remoteHost || '').trim();
|
||||
const params = new URLSearchParams();
|
||||
if (host) {
|
||||
params.set('host', host);
|
||||
const _sp = (_es.servers || []).find(s => s.host === host)?.port;
|
||||
if (_sp) params.set('ssh_port', _sp);
|
||||
}
|
||||
const res = await fetch('/api/cookbook/gpus' + (params.toString() ? '?' + params : ''));
|
||||
const data = await res.json();
|
||||
const gpus = Array.isArray(data) ? data : (data.gpus || []);
|
||||
if (!gpus.length) { el.textContent = 'no GPU detected'; el.style.color = ''; return true; }
|
||||
const g = gpus[0];
|
||||
const usedG = (g.used_mb / 1024), totG = (g.total_mb / 1024);
|
||||
const pct = totG ? Math.round((usedG / totG) * 100) : 0;
|
||||
const freeG = Math.max(0, totG - usedG);
|
||||
const spillG = (g.gtt_used_mb || 0) / 1024;
|
||||
// Color: green < 85%, amber 85-97%, red > 97% or spilling.
|
||||
const spilling = spillG > 0.5 && !g.unified_memory; // unified APUs always use GTT; not a spill
|
||||
let color = 'var(--green, #50fa7b)';
|
||||
if (pct >= 97 || spilling) color = 'var(--red, #ff5555)';
|
||||
else if (pct >= 85) color = 'var(--orange, #ffb86c)';
|
||||
let txt = `${usedG.toFixed(1)} / ${totG.toFixed(1)} GB (${pct}%) · ${freeG.toFixed(1)} GB free`;
|
||||
if (spilling) {
|
||||
txt += ` · ⚠ ${spillG.toFixed(1)} GB spilled to RAM — slow (raise CPU MoE or lower context)`;
|
||||
} else if (pct >= 90) {
|
||||
txt += ` · tight — risk of OOM/spill on long context or images`;
|
||||
} else {
|
||||
txt += ` · healthy`;
|
||||
}
|
||||
el.textContent = txt;
|
||||
el.style.color = color;
|
||||
return true;
|
||||
} catch {
|
||||
el.textContent = 'unavailable';
|
||||
el.style.color = '';
|
||||
return true;
|
||||
}
|
||||
}
|
||||
_refreshVramMonitor();
|
||||
// Poll every 4s while the panel is open; stop when it's removed from the DOM.
|
||||
const _vramTimer = setInterval(async () => {
|
||||
const ok = await _refreshVramMonitor();
|
||||
if (ok === false) clearInterval(_vramTimer);
|
||||
}, 4000);
|
||||
|
||||
// Show/hide backend-specific sections
|
||||
function updateBackendVisibility() {
|
||||
const b = panel.querySelector('[data-field="backend"]')?.value || 'vllm';
|
||||
@@ -551,6 +933,38 @@ function _rerenderCachedModels() {
|
||||
}
|
||||
updateBackendVisibility();
|
||||
|
||||
async function updateRuntimeReadinessNote() {
|
||||
const note = panel.querySelector('.hwfit-serve-runtime-note');
|
||||
if (!note) return;
|
||||
const backend = panel.querySelector('[data-field="backend"]')?.value || 'vllm';
|
||||
if (!['vllm', 'sglang', 'llamacpp', 'diffusers'].includes(backend)) {
|
||||
note.style.display = 'none';
|
||||
note.textContent = '';
|
||||
return;
|
||||
}
|
||||
const seq = (panel._runtimeReadinessSeq || 0) + 1;
|
||||
panel._runtimeReadinessSeq = seq;
|
||||
note.style.display = '';
|
||||
note.textContent = 'Checking runtime on selected server...';
|
||||
try {
|
||||
const { pkg, target } = await _fetchServeRuntimePackage(panel, backend);
|
||||
if (panel._runtimeReadinessSeq !== seq) return;
|
||||
note.textContent = _runtimeNoteText(backend, pkg, target);
|
||||
note.style.color = pkg?.installed ? 'var(--fg-muted)' : 'var(--red)';
|
||||
} catch (err) {
|
||||
if (panel._runtimeReadinessSeq !== seq) return;
|
||||
note.textContent = `Runtime readiness unavailable: ${err?.message || err}`;
|
||||
note.style.color = 'var(--fg-muted)';
|
||||
}
|
||||
}
|
||||
updateRuntimeReadinessNote();
|
||||
const runtimeServerSelect = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server');
|
||||
if (runtimeServerSelect) {
|
||||
const refreshRuntimeOnServerChange = () => updateRuntimeReadinessNote();
|
||||
runtimeServerSelect.addEventListener('change', refreshRuntimeOnServerChange);
|
||||
panel._cleanupRuntimeReadiness = () => runtimeServerSelect.removeEventListener('change', refreshRuntimeOnServerChange);
|
||||
}
|
||||
|
||||
// Wire save slots
|
||||
function _loadSlotIntoPanel(slotIdx) {
|
||||
const presets = _loadPresets();
|
||||
@@ -580,7 +994,17 @@ function _rerenderCachedModels() {
|
||||
gpu_mem: _ex(/--gpu-memory-utilization\s+([\d.]+)/) || '0.90',
|
||||
swap: _ex(/--swap-space\s+(\d+)/) || '',
|
||||
dtype: _ex(/--dtype\s+(\w+)/) || 'auto',
|
||||
vllm_kv_cache_dtype: _ex(/--kv-cache-dtype\s+([\w.-]+)/) || 'auto',
|
||||
max_seqs: _ex(/--max-num-seqs\s+(\d+)/) || '',
|
||||
cache_type: _ex(/(?:--cache-type-k|-ctk)\s+(\S+)/) || '',
|
||||
llama_fit: _ex(/(?:--fit|-fit)\s+(on|off)/) || '',
|
||||
llama_split_mode: _ex(/(?:--split-mode|-sm)\s+(none|layer|row|tensor)/) || '',
|
||||
llama_tensor_split: _ex(/(?:--tensor-split|-ts)\s+([0-9.,]+)/) || '',
|
||||
llama_main_gpu: _ex(/(?:--main-gpu|-mg)\s+(\d+)/) || '',
|
||||
llama_parallel: _ex(/(?:--parallel|-np)\s+(\d+)/) || '',
|
||||
llama_batch_size: _ex(/(?:--batch-size|-b)\s+(\d+)/) || '',
|
||||
llama_ubatch_size: _ex(/(?:--ubatch-size|-ub)\s+(\d+)/) || '',
|
||||
llama_spec_tokens: _ex(/--spec-draft-n-max\s+(\d+)/) || '3',
|
||||
venv: p.envPath || '',
|
||||
};
|
||||
const checks = {
|
||||
@@ -588,6 +1012,11 @@ function _rerenderCachedModels() {
|
||||
trust_remote: cmd.includes('--trust-remote-code'),
|
||||
prefix_cache: cmd.includes('--enable-prefix-caching'),
|
||||
auto_tool: cmd.includes('--enable-auto-tool-choice'),
|
||||
flash_attn: /--flash-attn\s+on\b/.test(cmd),
|
||||
unified_mem: /GGML_CUDA_ENABLE_UNIFIED_MEMORY=1/.test(cmd),
|
||||
llama_no_mmap: /--no-mmap\b/.test(cmd),
|
||||
llama_no_warmup: /--no-warmup\b/.test(cmd),
|
||||
llama_speculative_mtp: /--spec-type\s+\S*draft-mtp/.test(cmd),
|
||||
speculative: cmd.includes('--speculative-config'),
|
||||
};
|
||||
const _specMatch = cmd.match(/--speculative-config\s+'?\{[^}]*"method"\s*:\s*"([^"]+)"[^}]*"num_speculative_tokens"\s*:\s*(\d+)/);
|
||||
@@ -619,16 +1048,21 @@ function _rerenderCachedModels() {
|
||||
const _gf = panel.querySelector('[data-field="gpus"]');
|
||||
if (_gf) _gf.value = activeGpus.join(',');
|
||||
updateBackendVisibility();
|
||||
updateRuntimeReadinessNote();
|
||||
updateCmd();
|
||||
panel.querySelectorAll('.cookbook-slot-btn').forEach(b => b.classList.remove('active'));
|
||||
panel.querySelector(`.cookbook-slot-btn[data-slot="${slotIdx}"]`)?.classList.add('active');
|
||||
}
|
||||
|
||||
// Keep the arrow button's count in sync with the stored presets.
|
||||
// Keep the arrow button's count + tooltip in sync with stored presets.
|
||||
function _updateSavedToggleLabel() {
|
||||
const n = _presetsForModel(_loadPresets(), repo).length;
|
||||
const t = panel.querySelector('.cookbook-saved-arrow');
|
||||
if (t) t.textContent = n > 0 ? `${n} ▾` : '▾';
|
||||
if (!t) return;
|
||||
t.textContent = n > 0 ? `${n} ▾` : '▾';
|
||||
t.title = n > 0
|
||||
? `${n} saved launch config${n === 1 ? '' : 's'} for ${_repoShort} — click ▾ to load or delete`
|
||||
: `No saved launch configs for ${_repoShort} yet — click Save to add one`;
|
||||
}
|
||||
|
||||
// Save the current panel fields as a new named preset (shared by the menu's
|
||||
@@ -1154,6 +1588,10 @@ function _rerenderCachedModels() {
|
||||
const extraEl = panel.querySelector('[data-field="extra"]');
|
||||
if (extraEl) extraEl.value = '';
|
||||
updateBackendVisibility();
|
||||
updateRuntimeReadinessNote();
|
||||
}
|
||||
if (e.target.dataset.field === 'venv') {
|
||||
updateRuntimeReadinessNote();
|
||||
}
|
||||
updateCmd();
|
||||
});
|
||||
@@ -1185,6 +1623,7 @@ function _rerenderCachedModels() {
|
||||
// "back out" affordance next to Launch.
|
||||
panel.querySelector('.hwfit-serve-cancel')?.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
panel._cleanupRuntimeReadiness?.();
|
||||
panel.remove();
|
||||
item.classList.remove('doclib-card-expanded');
|
||||
item.style.flexDirection = '';
|
||||
@@ -1195,6 +1634,12 @@ function _rerenderCachedModels() {
|
||||
// Launch button
|
||||
panel.querySelector('.hwfit-serve-launch').addEventListener('click', async (ev) => {
|
||||
const _launchBtn = ev.currentTarget;
|
||||
// Final safety net: never launch with ctx beyond the model's trained
|
||||
// limit (or the absolute sanity ceiling when the limit is unknown). A
|
||||
// stale preset or typo (e.g. 16000000) overflows and, with a quantized
|
||||
// KV cache, can crash the GPU. Skip only if the user hand-edited the raw
|
||||
// command (then we respect their literal text).
|
||||
if (!_cmdManuallyEdited) _clampCtx(true);
|
||||
if (!_cmdManuallyEdited) updateCmd();
|
||||
const launchCmd = _cmdTextarea ? _cmdTextarea.value.trim() : panel._cmd;
|
||||
const serveState = {};
|
||||
@@ -1202,7 +1647,16 @@ function _rerenderCachedModels() {
|
||||
if (el.type === 'checkbox') serveState[el.dataset.field] = el.checked;
|
||||
else serveState[el.dataset.field] = el.value;
|
||||
});
|
||||
serveState.backend = (_detectBackend(m).backend) || serveState.backend || 'vllm';
|
||||
serveState.backend = serveState.backend || (_detectBackend(m).backend) || 'vllm';
|
||||
const backendWarning = _serveBackendWarning(m, repo, serveState.backend, serveState);
|
||||
if (backendWarning) {
|
||||
await window.styledConfirm(backendWarning.body, {
|
||||
title: backendWarning.title,
|
||||
confirmText: 'Edit settings',
|
||||
cancelText: 'Close',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Save in the { _byRepo, _lastUsed } schema — no legacy flat keys at
|
||||
// the root so per-model state doesn't leak between models.
|
||||
try {
|
||||
@@ -1515,7 +1969,10 @@ export async function _fetchCachedModels() {
|
||||
const data = await res.json();
|
||||
_dlWp.destroy();
|
||||
|
||||
const ready = data.models.filter(m => m.status === 'ready' && (m.backend === 'ollama' || !m.size.includes('MB')));
|
||||
// CHANGELOG: 'ready' already excludes partial downloads;
|
||||
// show every complete model regardless of size/backend.
|
||||
const ready = data.models.filter(m => m.status === 'ready');
|
||||
|
||||
const downloading = data.models.filter(m => m.status === 'downloading');
|
||||
const allModels = [...ready, ...downloading];
|
||||
_cachedAllModels = allModels;
|
||||
|
||||
+338
-34
@@ -29,6 +29,7 @@ import * as Modals from './modalManager.js';
|
||||
let _htmlPreviewActive = false; // true when inline HTML preview iframe is showing
|
||||
let _emailAccountsCache = null;
|
||||
let _emailAccountsCacheAt = 0;
|
||||
let _emailHeaderManualExpandUntil = 0;
|
||||
|
||||
// Diff mode state
|
||||
let _diffModeActive = false;
|
||||
@@ -152,6 +153,8 @@ import * as Modals from './modalManager.js';
|
||||
addDocToTabs,
|
||||
syncDocIndicator: _syncDocIndicator,
|
||||
});
|
||||
_maybeOpenDocFromHash();
|
||||
window.addEventListener('hashchange', _maybeOpenDocFromHash);
|
||||
}
|
||||
|
||||
/** Update overflow-doc-btn accent indicator, toolbar indicator, and session list icon */
|
||||
@@ -2306,6 +2309,53 @@ import * as Modals from './modalManager.js';
|
||||
return r && r.style.display !== 'none' ? r : null;
|
||||
}
|
||||
|
||||
function _captureEmailBodyFocusState() {
|
||||
const rich = _emailRichbodyActive();
|
||||
const ta = document.getElementById('doc-editor-textarea');
|
||||
const active = document.activeElement;
|
||||
if (rich && (active === rich || rich.contains(active))) {
|
||||
const sel = window.getSelection();
|
||||
const range = sel && sel.rangeCount ? sel.getRangeAt(0) : null;
|
||||
return {
|
||||
type: 'rich',
|
||||
range: range && rich.contains(range.commonAncestorContainer) ? range.cloneRange() : null,
|
||||
};
|
||||
}
|
||||
if (ta && active === ta) {
|
||||
return {
|
||||
type: 'textarea',
|
||||
start: ta.selectionStart,
|
||||
end: ta.selectionEnd,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _restoreEmailBodyFocusState(state) {
|
||||
if (!state) return;
|
||||
requestAnimationFrame(() => {
|
||||
if (state.type === 'rich') {
|
||||
const rich = _emailRichbodyActive();
|
||||
if (!rich) return;
|
||||
rich.focus({ preventScroll: true });
|
||||
if (state.range) {
|
||||
const sel = window.getSelection();
|
||||
if (sel) {
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(state.range);
|
||||
}
|
||||
}
|
||||
} else if (state.type === 'textarea') {
|
||||
const ta = document.getElementById('doc-editor-textarea');
|
||||
if (!ta) return;
|
||||
ta.focus({ preventScroll: true });
|
||||
if (Number.isFinite(state.start) && Number.isFinite(state.end)) {
|
||||
try { ta.setSelectionRange(state.start, state.end); } catch (_) {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _stripEmailReplyQuoteText(text) {
|
||||
const original = String(text || '');
|
||||
if (!original) return { body: '', stripped: false };
|
||||
@@ -2367,6 +2417,48 @@ import * as Modals from './modalManager.js';
|
||||
}
|
||||
}
|
||||
|
||||
function _syncEmailHeaderSummary() {
|
||||
const to = document.getElementById('doc-email-to')?.value?.trim() || 'No recipient';
|
||||
const subject = document.getElementById('doc-email-subject')?.value?.trim() || 'No subject';
|
||||
const cc = document.getElementById('doc-email-cc')?.value?.trim() || '';
|
||||
const bcc = document.getElementById('doc-email-bcc')?.value?.trim() || '';
|
||||
const summary = document.getElementById('doc-email-collapse-summary');
|
||||
if (!summary) return;
|
||||
const extras = [];
|
||||
if (cc) extras.push('Cc');
|
||||
if (bcc) extras.push('Bcc');
|
||||
summary.textContent = `${to} · ${subject}${extras.length ? ` · ${extras.join('/')}` : ''}`;
|
||||
summary.title = summary.textContent;
|
||||
}
|
||||
|
||||
function _setEmailHeaderCollapsed(collapsed, { manual = true } = {}) {
|
||||
const header = document.getElementById('doc-email-header');
|
||||
const btn = document.getElementById('doc-email-collapse-btn');
|
||||
if (!header) return;
|
||||
if (window.innerWidth > 768) collapsed = false;
|
||||
header.classList.toggle('doc-email-header-collapsed', !!collapsed);
|
||||
if (btn) {
|
||||
btn.setAttribute('aria-expanded', String(!collapsed));
|
||||
btn.title = collapsed ? 'Show email fields' : 'Hide email fields';
|
||||
}
|
||||
const doc = activeDocId && docs.get(activeDocId);
|
||||
if (doc && manual) doc._emailHeaderCollapsed = !!collapsed;
|
||||
if (manual && !collapsed) _emailHeaderManualExpandUntil = Date.now() + 1400;
|
||||
_syncEmailHeaderSummary();
|
||||
}
|
||||
|
||||
function _shouldAutoCollapseEmailHeader() {
|
||||
return window.innerWidth <= 768;
|
||||
}
|
||||
|
||||
function _maybeAutoCollapseEmailHeader() {
|
||||
const doc = activeDocId && docs.get(activeDocId);
|
||||
if (!doc || doc.language !== 'email') return;
|
||||
if (Date.now() < _emailHeaderManualExpandUntil) return;
|
||||
if (document.activeElement?.closest?.('#doc-email-fields')) return;
|
||||
if (_shouldAutoCollapseEmailHeader()) _setEmailHeaderCollapsed(true, { manual: false });
|
||||
}
|
||||
|
||||
function _showEmailFields(doc) {
|
||||
const emailHeader = document.getElementById('doc-email-header');
|
||||
const emailActions = document.getElementById('doc-email-actions');
|
||||
@@ -2405,6 +2497,7 @@ import * as Modals from './modalManager.js';
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
if (toInput) toInput.value = fields.to;
|
||||
if (subjectInput) subjectInput.value = fields.subject;
|
||||
_setEmailHeaderCollapsed(!!(doc && doc._emailHeaderCollapsed), { manual: false });
|
||||
if (subjectInput && !subjectInput._emailTabBodyBound) {
|
||||
subjectInput._emailTabBodyBound = true;
|
||||
subjectInput.addEventListener('keydown', (e) => {
|
||||
@@ -2546,6 +2639,7 @@ import * as Modals from './modalManager.js';
|
||||
if (ccRow) ccRow.style.display = hasCcBcc ? '' : 'none';
|
||||
if (bccRow) bccRow.style.display = hasCcBcc ? '' : 'none';
|
||||
if (ccToggle) ccToggle.style.display = hasCcBcc ? 'none' : '';
|
||||
_syncEmailHeaderSummary();
|
||||
}
|
||||
|
||||
async function _uploadComposeFiles(files) {
|
||||
@@ -3060,19 +3154,22 @@ import * as Modals from './modalManager.js';
|
||||
saveCurrentToMap();
|
||||
const doc = docs.get(docId);
|
||||
const snapshot = { id: docId, doc: { ...doc } };
|
||||
saveDocument({ silent: true }).catch(() => {});
|
||||
const wasActive = activeDocId === docId;
|
||||
if (wasActive) saveDocument({ silent: true }).catch(() => {});
|
||||
|
||||
const visibleBefore = _visibleDocIdsForCurrentSession();
|
||||
const idx = visibleBefore.indexOf(docId);
|
||||
docs.delete(docId);
|
||||
if (activeDocId === docId) activeDocId = null;
|
||||
if (wasActive) activeDocId = null;
|
||||
|
||||
const remaining = visibleBefore.filter(id => id !== docId && docs.has(id));
|
||||
const nextId = remaining[idx] || remaining[idx - 1] || remaining[0] || null;
|
||||
if (nextId) {
|
||||
switchToDoc(nextId);
|
||||
} else {
|
||||
closePanel();
|
||||
if (wasActive) {
|
||||
const remaining = visibleBefore.filter(id => id !== docId && docs.has(id));
|
||||
const nextId = remaining[idx] || remaining[idx - 1] || remaining[0] || null;
|
||||
if (nextId) {
|
||||
switchToDoc(nextId);
|
||||
} else {
|
||||
closePanel();
|
||||
}
|
||||
}
|
||||
renderTabs();
|
||||
_syncDocIndicator();
|
||||
@@ -3746,25 +3843,31 @@ import * as Modals from './modalManager.js';
|
||||
</div>
|
||||
<div class="doc-tab-bar" id="doc-tab-bar"></div>
|
||||
<div id="doc-email-header" class="doc-email-header" style="display:none">
|
||||
<div class="email-field" style="position:relative">
|
||||
<label>To</label>
|
||||
<input type="text" id="doc-email-to" placeholder="recipient@example.com" autocomplete="off" />
|
||||
<div id="doc-email-to-suggestions" class="email-autocomplete" style="display:none"></div>
|
||||
<button type="button" id="doc-email-show-cc" class="email-cc-toggle" title="Show Cc/Bcc">Cc</button>
|
||||
<button type="button" id="doc-email-collapse-btn" class="doc-email-collapse-btn" title="Hide email fields" aria-expanded="true">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 15 12 9 18 15"/></svg>
|
||||
<span id="doc-email-collapse-summary" class="doc-email-collapse-summary">No recipient · No subject</span>
|
||||
</button>
|
||||
<div id="doc-email-fields" class="doc-email-fields">
|
||||
<div class="email-field" style="position:relative">
|
||||
<label>To</label>
|
||||
<input type="text" id="doc-email-to" placeholder="recipient@example.com" autocomplete="off" />
|
||||
<div id="doc-email-to-suggestions" class="email-autocomplete" style="display:none"></div>
|
||||
<button type="button" id="doc-email-show-cc" class="email-cc-toggle" title="Show Cc/Bcc">Cc</button>
|
||||
</div>
|
||||
<div class="email-field" id="doc-email-cc-row" style="display:none;position:relative">
|
||||
<label>Cc</label>
|
||||
<input type="text" id="doc-email-cc" placeholder="cc@example.com" autocomplete="off" />
|
||||
<div id="doc-email-cc-suggestions" class="email-autocomplete" style="display:none"></div>
|
||||
</div>
|
||||
<div class="email-field" id="doc-email-bcc-row" style="display:none;position:relative">
|
||||
<label>Bcc</label>
|
||||
<input type="text" id="doc-email-bcc" placeholder="bcc@example.com" autocomplete="off" />
|
||||
<div id="doc-email-bcc-suggestions" class="email-autocomplete" style="display:none"></div>
|
||||
</div>
|
||||
<div class="email-field"><label>Subject</label><input type="text" id="doc-email-subject" placeholder="Subject" /></div>
|
||||
<div id="doc-email-attachments" class="email-attachments" style="display:none"></div>
|
||||
<div id="doc-email-compose-atts" class="email-compose-atts" style="display:none"></div>
|
||||
</div>
|
||||
<div class="email-field" id="doc-email-cc-row" style="display:none;position:relative">
|
||||
<label>Cc</label>
|
||||
<input type="text" id="doc-email-cc" placeholder="cc@example.com" autocomplete="off" />
|
||||
<div id="doc-email-cc-suggestions" class="email-autocomplete" style="display:none"></div>
|
||||
</div>
|
||||
<div class="email-field" id="doc-email-bcc-row" style="display:none;position:relative">
|
||||
<label>Bcc</label>
|
||||
<input type="text" id="doc-email-bcc" placeholder="bcc@example.com" autocomplete="off" />
|
||||
<div id="doc-email-bcc-suggestions" class="email-autocomplete" style="display:none"></div>
|
||||
</div>
|
||||
<div class="email-field"><label>Subject</label><input type="text" id="doc-email-subject" placeholder="Subject" /></div>
|
||||
<div id="doc-email-attachments" class="email-attachments" style="display:none"></div>
|
||||
<div id="doc-email-compose-atts" class="email-compose-atts" style="display:none"></div>
|
||||
<input type="hidden" id="doc-email-in-reply-to" />
|
||||
<input type="hidden" id="doc-email-references" />
|
||||
<input type="hidden" id="doc-email-source-uid" />
|
||||
@@ -4306,6 +4409,33 @@ import * as Modals from './modalManager.js';
|
||||
});
|
||||
document.getElementById('doc-email-ai-reply-btn')?.addEventListener('click', _aiReply);
|
||||
|
||||
const collapseBtn = document.getElementById('doc-email-collapse-btn');
|
||||
if (collapseBtn && !collapseBtn._emailCollapseWired) {
|
||||
collapseBtn._emailCollapseWired = true;
|
||||
collapseBtn.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const focusState = _captureEmailBodyFocusState();
|
||||
const header = document.getElementById('doc-email-header');
|
||||
const nextCollapsed = !header?.classList.contains('doc-email-header-collapsed');
|
||||
_setEmailHeaderCollapsed(nextCollapsed);
|
||||
if (!nextCollapsed) _restoreEmailBodyFocusState(focusState);
|
||||
});
|
||||
collapseBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
['doc-email-to', 'doc-email-cc', 'doc-email-bcc', 'doc-email-subject'].forEach(id => {
|
||||
document.getElementById(id)?.addEventListener('input', _syncEmailHeaderSummary);
|
||||
document.getElementById(id)?.addEventListener('focus', () => _setEmailHeaderCollapsed(false, { manual: false }));
|
||||
});
|
||||
document.getElementById('doc-email-richbody')?.addEventListener('focus', _maybeAutoCollapseEmailHeader);
|
||||
if (window.visualViewport && !window._docEmailViewportCollapseBound) {
|
||||
window._docEmailViewportCollapseBound = true;
|
||||
window.visualViewport.addEventListener('resize', _maybeAutoCollapseEmailHeader);
|
||||
}
|
||||
|
||||
// Split-button caret toggles the send-options menu (drops up).
|
||||
document.getElementById('doc-email-send-caret')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -4348,11 +4478,13 @@ import * as Modals from './modalManager.js';
|
||||
|
||||
// Cc/Bcc toggle
|
||||
document.getElementById('doc-email-show-cc')?.addEventListener('click', () => {
|
||||
_setEmailHeaderCollapsed(false, { manual: false });
|
||||
const ccRow = document.getElementById('doc-email-cc-row');
|
||||
const bccRow = document.getElementById('doc-email-bcc-row');
|
||||
if (ccRow) ccRow.style.display = '';
|
||||
if (bccRow) bccRow.style.display = '';
|
||||
document.getElementById('doc-email-show-cc').style.display = 'none';
|
||||
_syncEmailHeaderSummary();
|
||||
});
|
||||
|
||||
// Autocomplete for To / Cc / Bcc — typed fragment after the last
|
||||
@@ -5811,16 +5943,31 @@ import * as Modals from './modalManager.js';
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/document/${docId}`);
|
||||
if (!res.ok) throw new Error('Not found');
|
||||
if (!res.ok) throw new Error(res.status === 404 ? 'Not found' : `HTTP ${res.status}`);
|
||||
const doc = await res.json();
|
||||
addDocToTabs(doc, doc.session_id);
|
||||
_ensureDocPaneMounted();
|
||||
switchToDoc(doc.id);
|
||||
} catch (e) {
|
||||
console.error('Failed to load document:', e);
|
||||
if (uiModule) {
|
||||
const msg = e.message === 'Not found'
|
||||
? 'Document not found — try opening it from the Library.'
|
||||
: 'Could not open document.';
|
||||
uiModule.showError(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deep-link: #document-<id> opens that document on load / URL-bar nav.
|
||||
// Clicks on in-chat document anchors are handled separately (they call
|
||||
// preventDefault, so they don't change the hash); this covers refresh
|
||||
// and pasted/typed document URLs, which previously did nothing.
|
||||
function _maybeOpenDocFromHash() {
|
||||
const m = (window.location.hash || '').match(/^#document-(.+)$/);
|
||||
if (m) loadDocument(m[1]);
|
||||
}
|
||||
|
||||
/** Open panel and ensure a document exists, creating a session if needed */
|
||||
export async function ensureDocPanel() {
|
||||
let sessionId = _lastSessionId
|
||||
@@ -6175,13 +6322,170 @@ import * as Modals from './modalManager.js';
|
||||
}
|
||||
|
||||
/** Update the line number gutter */
|
||||
function updateLineNumbers(text) {
|
||||
let _lineNumberResizeObserver = null;
|
||||
let _lineNumberObservedTextarea = null;
|
||||
let _lineNumberResizeRaf = null;
|
||||
|
||||
function _lineNumberContentEl(gutter) {
|
||||
let inner = gutter.querySelector('.doc-line-number-content');
|
||||
if (!inner) {
|
||||
inner = document.createElement('div');
|
||||
inner.className = 'doc-line-number-content';
|
||||
gutter.textContent = '';
|
||||
gutter.appendChild(inner);
|
||||
}
|
||||
return inner;
|
||||
}
|
||||
|
||||
function _lineNumberStyleSignature(style) {
|
||||
return [
|
||||
style.fontFamily,
|
||||
style.fontSize,
|
||||
style.fontWeight,
|
||||
style.fontStyle,
|
||||
style.lineHeight,
|
||||
style.letterSpacing,
|
||||
style.tabSize,
|
||||
style.fontFeatureSettings,
|
||||
style.fontVariantLigatures,
|
||||
style.fontKerning,
|
||||
].join('|');
|
||||
}
|
||||
|
||||
function _textareaTextWidth(textarea, style) {
|
||||
const paddingLeft = parseFloat(style.paddingLeft) || 0;
|
||||
const paddingRight = parseFloat(style.paddingRight) || 0;
|
||||
return Math.max(0, textarea.clientWidth - paddingLeft - paddingRight);
|
||||
}
|
||||
|
||||
function _lineHeightPx(style) {
|
||||
const parsed = parseFloat(style.lineHeight);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
const fontSize = parseFloat(style.fontSize) || 11;
|
||||
return fontSize * 1.45;
|
||||
}
|
||||
|
||||
function _lineNumberMeasureEl(textarea) {
|
||||
const wrap = document.getElementById('doc-editor-wrap') || textarea.parentElement || document.body;
|
||||
let probe = wrap.querySelector('.doc-line-number-measure');
|
||||
if (!probe) {
|
||||
probe = document.createElement('textarea');
|
||||
probe.className = 'doc-line-number-measure';
|
||||
probe.setAttribute('aria-hidden', 'true');
|
||||
probe.tabIndex = -1;
|
||||
probe.readOnly = true;
|
||||
probe.wrap = 'soft';
|
||||
wrap.appendChild(probe);
|
||||
}
|
||||
return probe;
|
||||
}
|
||||
|
||||
function _syncLineNumberMeasureStyle(probe, style, textWidth) {
|
||||
probe.style.width = textWidth + 'px';
|
||||
probe.style.fontFamily = style.fontFamily;
|
||||
probe.style.fontSize = style.fontSize;
|
||||
probe.style.fontWeight = style.fontWeight;
|
||||
probe.style.fontStyle = style.fontStyle;
|
||||
probe.style.lineHeight = style.lineHeight;
|
||||
probe.style.letterSpacing = style.letterSpacing;
|
||||
probe.style.tabSize = style.tabSize;
|
||||
probe.style.fontFeatureSettings = style.fontFeatureSettings;
|
||||
probe.style.fontVariantLigatures = style.fontVariantLigatures;
|
||||
probe.style.fontKerning = style.fontKerning;
|
||||
probe.style.textRendering = style.textRendering;
|
||||
probe.style.whiteSpace = style.whiteSpace;
|
||||
probe.style.wordWrap = style.wordWrap;
|
||||
probe.style.overflowWrap = style.overflowWrap;
|
||||
}
|
||||
|
||||
function _measureLineNumberHeights(textarea, lines, textWidth, style) {
|
||||
const probe = _lineNumberMeasureEl(textarea);
|
||||
_syncLineNumberMeasureStyle(probe, style, textWidth);
|
||||
const lineHeight = _lineHeightPx(style);
|
||||
return lines.map(line => {
|
||||
probe.value = line || ' ';
|
||||
const visualRows = Math.max(1, Math.round(probe.scrollHeight / lineHeight));
|
||||
return visualRows * lineHeight;
|
||||
});
|
||||
}
|
||||
|
||||
function _renderLineNumberRows(inner, heights) {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let i = 0; i < heights.length; i++) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'doc-line-number-row';
|
||||
row.style.height = `${heights[i]}px`;
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'doc-line-number-label';
|
||||
label.textContent = String(i + 1);
|
||||
row.appendChild(label);
|
||||
frag.appendChild(row);
|
||||
}
|
||||
inner.textContent = '';
|
||||
inner.appendChild(frag);
|
||||
}
|
||||
|
||||
function _scheduleLineNumberRerender() {
|
||||
if (_lineNumberResizeRaf) return;
|
||||
const run = () => {
|
||||
_lineNumberResizeRaf = null;
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
if (textarea) updateLineNumbers(textarea.value, true);
|
||||
};
|
||||
if (typeof requestAnimationFrame === 'function') {
|
||||
_lineNumberResizeRaf = requestAnimationFrame(run);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
}
|
||||
|
||||
function _ensureLineNumberResizeObserver(textarea) {
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
if (!_lineNumberResizeObserver) {
|
||||
_lineNumberResizeObserver = new ResizeObserver(_scheduleLineNumberRerender);
|
||||
}
|
||||
if (_lineNumberObservedTextarea === textarea) return;
|
||||
if (_lineNumberObservedTextarea) {
|
||||
_lineNumberResizeObserver.unobserve(_lineNumberObservedTextarea);
|
||||
}
|
||||
_lineNumberObservedTextarea = textarea;
|
||||
_lineNumberResizeObserver.observe(textarea);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('resize', _scheduleLineNumberRerender);
|
||||
}
|
||||
|
||||
function updateLineNumbers(text, force = false) {
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
const gutter = document.getElementById('doc-line-numbers');
|
||||
if (!gutter) return;
|
||||
const count = (text || '').split('\n').length;
|
||||
let html = '';
|
||||
for (let i = 1; i <= count; i++) html += i + '\n';
|
||||
gutter.textContent = html;
|
||||
if (!textarea || !gutter) return;
|
||||
|
||||
const value = text || '';
|
||||
const lines = value.split('\n');
|
||||
const inner = _lineNumberContentEl(gutter);
|
||||
const style = getComputedStyle(textarea);
|
||||
const textWidth = _textareaTextWidth(textarea, style);
|
||||
const styleSig = _lineNumberStyleSignature(style);
|
||||
|
||||
_ensureLineNumberResizeObserver(textarea);
|
||||
if (
|
||||
!force &&
|
||||
inner._lineNumberText === value &&
|
||||
inner._lineNumberWidth === textWidth &&
|
||||
inner._lineNumberStyleSig === styleSig
|
||||
) {
|
||||
syncGutterScroll();
|
||||
return;
|
||||
}
|
||||
|
||||
const heights = _measureLineNumberHeights(textarea, lines, textWidth, style);
|
||||
_renderLineNumberRows(inner, heights);
|
||||
inner._lineNumberText = value;
|
||||
inner._lineNumberWidth = textWidth;
|
||||
inner._lineNumberStyleSig = styleSig;
|
||||
syncGutterScroll();
|
||||
}
|
||||
|
||||
/** Sync line number gutter scroll with textarea */
|
||||
@@ -6189,7 +6493,7 @@ import * as Modals from './modalManager.js';
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
const gutter = document.getElementById('doc-line-numbers');
|
||||
if (textarea && gutter) {
|
||||
gutter.scrollTop = textarea.scrollTop;
|
||||
_lineNumberContentEl(gutter).style.transform = `translateY(${-textarea.scrollTop}px)`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -652,9 +652,10 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
if (doc.session_id) {
|
||||
openItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryOpenInSession(doc); });
|
||||
} else {
|
||||
openItem.disabled = true;
|
||||
openItem.style.opacity = '0.35';
|
||||
openItem.title = 'Not linked to a session';
|
||||
// Orphaned doc (closed / session detached) is still openable in the editor
|
||||
// by id — libraryOpenDocument handles the no-session case (#1602).
|
||||
openItem.title = 'Open in the editor';
|
||||
openItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryOpenDocument(doc); });
|
||||
}
|
||||
dropdown.appendChild(openItem);
|
||||
|
||||
@@ -772,10 +773,10 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
openBtn.title = 'Open in original session';
|
||||
openBtn.addEventListener('click', (e) => { e.stopPropagation(); libraryOpenInSession(doc); });
|
||||
} else {
|
||||
openBtn.disabled = true;
|
||||
openBtn.style.opacity = '0.35';
|
||||
openBtn.style.cursor = 'not-allowed';
|
||||
openBtn.title = 'This document is not linked to a session';
|
||||
// Orphaned doc (closed / session detached) is still openable in the editor
|
||||
// by id — libraryOpenDocument handles the no-session case (#1602).
|
||||
openBtn.title = 'Open in the editor';
|
||||
openBtn.addEventListener('click', (e) => { e.stopPropagation(); libraryOpenDocument(doc); });
|
||||
}
|
||||
|
||||
const cloneBtn = document.createElement('button');
|
||||
@@ -2059,6 +2060,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
{ label: 'Copy', action: () => _copyChatById(s.id) },
|
||||
{ label: 'Archive', action: async () => { await fetch(API_BASE + '/api/session/' + s.id + '/archive', { method: 'POST', headers: {'Content-Type':'application/json'} }); _renderLibChats(); } },
|
||||
{ label: 'Delete', action: async () => {
|
||||
if (!await window.styledConfirm('Delete this chat?', { confirmText: 'Delete', danger: true })) return;
|
||||
await fetch(API_BASE + '/api/session/' + s.id, { method: 'DELETE' });
|
||||
card.style.maxHeight = `${Math.max(card.getBoundingClientRect().height, card.scrollHeight)}px`;
|
||||
card.classList.add('memory-tidy-removing');
|
||||
@@ -2412,7 +2414,11 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
{ label: 'Open', action: () => { if (window.sessionModule) window.sessionModule.selectSession(s.id); } },
|
||||
{ label: 'Copy', action: () => _copyChatById(s.id) },
|
||||
{ label: 'Restore', action: async () => { await fetch(API_BASE + '/api/session/' + s.id + '/unarchive', { method: 'POST' }); _renderLibArchive(); } },
|
||||
{ label: 'Delete', action: async () => { await fetch(API_BASE + '/api/session/' + s.id, { method: 'DELETE' }); _renderLibArchive(); }, danger: true },
|
||||
{ label: 'Delete', action: async () => {
|
||||
if (!await window.styledConfirm('Delete this chat permanently?', { confirmText: 'Delete', danger: true })) return;
|
||||
await fetch(API_BASE + '/api/session/' + s.id, { method: 'DELETE' });
|
||||
_renderLibArchive();
|
||||
}, danger: true },
|
||||
], { onSelect: () => {
|
||||
_arcSelectMode = true;
|
||||
_arcSelected.add('chats:' + s.id);
|
||||
@@ -3130,7 +3136,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
importFileBtn.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', async () => {
|
||||
if (fileInput.files.length === 0) return;
|
||||
const files = fileInput.files;
|
||||
const files = Array.from(fileInput.files);
|
||||
fileInput.value = '';
|
||||
// Swap the import icon for a whirlpool while files upload.
|
||||
const _orig = importFileBtn.innerHTML;
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { isAltGrEvent } from '../platform.js';
|
||||
|
||||
export function wireKeyboardShortcuts(deps) {
|
||||
const {
|
||||
@@ -79,7 +80,11 @@ export function wireKeyboardShortcuts(deps) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') return;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// Skip the Ctrl+Alt editor chords for an AltGr keystroke (see platform.js);
|
||||
// only the chord block is skipped, so the layout-character handlers below
|
||||
// still act — AltGr+5 / AltGr+8 stay as the [ ] brush-size shortcut on
|
||||
// AZERTY / QWERTZ.
|
||||
if ((e.ctrlKey || e.metaKey) && !isAltGrEvent(e)) {
|
||||
if (e.key === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); }
|
||||
// Ctrl+Shift+D = Deselect: clears the wand selection (and
|
||||
// lasso if active) without affecting layers.
|
||||
|
||||
@@ -37,7 +37,8 @@ export function computeSnap(layer, nx, ny, ctx) {
|
||||
{ y: ch, label: 'canvas-b' },
|
||||
{ y: ch / 2, label: 'canvas-cy' },
|
||||
];
|
||||
for (const other of ctx.otherLayers) {
|
||||
const otherLayers = Array.isArray(ctx.otherLayers) ? ctx.otherLayers : [];
|
||||
for (const other of otherLayers) {
|
||||
if (!other.visible || other.id === layer.id) continue;
|
||||
const o = other.offset || { x: 0, y: 0 };
|
||||
const ow = other.canvas.width, oh = other.canvas.height;
|
||||
|
||||
@@ -722,10 +722,12 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') {
|
||||
em.is_read = true;
|
||||
if (itemEl) itemEl.classList.remove('email-unread');
|
||||
|
||||
// Get my own address to exclude from Reply All. window._myEmailAddress
|
||||
// is populated from the configured account on init; the empty fallback
|
||||
// simply means "no exclusion" — better than baking in a real address.
|
||||
const myAddress = (window._myEmailAddress || '').toLowerCase();
|
||||
// Addresses to exclude from Reply All. Prefer the full set of configured
|
||||
// accounts (so a multi-account user's other mailboxes are excluded too),
|
||||
// falling back to the single active address. Empty ⇒ no exclusion.
|
||||
const myAddresses = (Array.isArray(window._myEmailAddresses) && window._myEmailAddresses.length)
|
||||
? window._myEmailAddresses
|
||||
: (window._myEmailAddress ? [window._myEmailAddress] : []);
|
||||
|
||||
let toAddress = data.from_address;
|
||||
let ccAddresses = '';
|
||||
@@ -733,7 +735,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') {
|
||||
|
||||
if (mode === 'reply-all') {
|
||||
// Build reply-all: TO = original sender, CC = everyone else (To + Cc minus me)
|
||||
ccAddresses = buildReplyAllCc(data, myAddress);
|
||||
ccAddresses = buildReplyAllCc(data, myAddresses);
|
||||
} else if (mode === 'forward') {
|
||||
toAddress = '';
|
||||
subjectPrefix = 'Fwd: ';
|
||||
|
||||
+384
-66
@@ -27,6 +27,183 @@ const API_BASE = window.location.origin;
|
||||
let _emailUnreadChipClickWired = false;
|
||||
let _libLoadSeq = 0;
|
||||
let _libFolderSeq = 0;
|
||||
let _libSearchSeq = 0;
|
||||
let _libSearchHadResults = false;
|
||||
let _activeEmailReaderForSelectAll = null;
|
||||
|
||||
function _isEmailTypingTarget(t) {
|
||||
return !!(t && (
|
||||
t.tagName === 'INPUT' ||
|
||||
t.tagName === 'TEXTAREA' ||
|
||||
t.tagName === 'SELECT' ||
|
||||
t.isContentEditable
|
||||
));
|
||||
}
|
||||
|
||||
function _selectEmailReaderContents(reader) {
|
||||
if (!reader || !reader.isConnected) return false;
|
||||
const hiddenModal = reader.closest('.modal.hidden');
|
||||
if (hiddenModal) return false;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(reader);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
return true;
|
||||
}
|
||||
|
||||
function _markEmailReaderActive(reader) {
|
||||
if (!reader) return;
|
||||
_activeEmailReaderForSelectAll = reader;
|
||||
if (reader.dataset.selectAllWired === '1') return;
|
||||
reader.dataset.selectAllWired = '1';
|
||||
reader.addEventListener('pointerdown', () => { _activeEmailReaderForSelectAll = reader; }, true);
|
||||
reader.addEventListener('focusin', () => { _activeEmailReaderForSelectAll = reader; }, true);
|
||||
}
|
||||
|
||||
const _COPY_EMAIL_ICON = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||
|
||||
function _decodeAttrValue(v) {
|
||||
const tmp = document.createElement('textarea');
|
||||
tmp.innerHTML = v || '';
|
||||
return tmp.value;
|
||||
}
|
||||
|
||||
function _emailAddressFromRecipientText(text) {
|
||||
const raw = String(text || '').trim();
|
||||
const angle = raw.match(/<\s*([^<>@\s]+@[^<>\s]+)\s*>/);
|
||||
if (angle) return angle[1].trim();
|
||||
const any = raw.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i);
|
||||
return any ? any[0].trim() : raw;
|
||||
}
|
||||
|
||||
function _splitRecipientList(raw) {
|
||||
const out = [];
|
||||
let cur = '';
|
||||
let quote = false;
|
||||
let angle = false;
|
||||
const s = String(raw || '');
|
||||
for (let i = 0; i < s.length; i += 1) {
|
||||
const ch = s[i];
|
||||
if (ch === '"' && s[i - 1] !== '\\') quote = !quote;
|
||||
else if (ch === '<' && !quote) angle = true;
|
||||
else if (ch === '>' && !quote) angle = false;
|
||||
|
||||
if (ch === ',' && !quote && !angle) {
|
||||
const part = cur.trim();
|
||||
if (part) out.push(part);
|
||||
cur = '';
|
||||
continue;
|
||||
}
|
||||
cur += ch;
|
||||
}
|
||||
const tail = cur.trim();
|
||||
if (tail) out.push(tail);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function _copyTextToClipboard(text) {
|
||||
const value = String(text || '');
|
||||
if (!value) return false;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = value;
|
||||
ta.setAttribute('readonly', '');
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
ta.style.top = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
const ok = document.execCommand('copy');
|
||||
ta.remove();
|
||||
return !!ok;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function _recipientChipHtml(full, label, extraClass = '') {
|
||||
const fullText = String(full || '').trim();
|
||||
const addr = _emailAddressFromRecipientText(fullText);
|
||||
const labelText = String(label || addr || fullText || '').trim();
|
||||
const cls = `recipient-chip${extraClass ? ` ${extraClass}` : ''}`;
|
||||
return `<span class="${cls}" data-full="${_esc(fullText || labelText)}" data-email="${_esc(addr)}" title="Click for details"><span class="recipient-chip-label">${_esc(labelText)}</span><button type="button" class="recipient-chip-copy" title="Copy email" aria-label="Copy email" hidden>${_COPY_EMAIL_ICON}</button></span>`;
|
||||
}
|
||||
|
||||
function _wireRecipientChips(root) {
|
||||
if (!root || root.dataset.recipientChipsWired === '1') return;
|
||||
root.dataset.recipientChipsWired = '1';
|
||||
root.addEventListener('click', async (ev) => {
|
||||
const copyBtn = ev.target.closest?.('.recipient-chip-copy');
|
||||
if (copyBtn && root.contains(copyBtn)) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const chip = copyBtn.closest('.recipient-chip');
|
||||
const email = chip?.dataset.email || _emailAddressFromRecipientText(_decodeAttrValue(chip?.dataset.full || ''));
|
||||
if (!email) return;
|
||||
try {
|
||||
const copied = await _copyTextToClipboard(email);
|
||||
if (!copied) throw new Error('copy failed');
|
||||
copyBtn.classList.add('copied');
|
||||
copyBtn.title = 'Copied';
|
||||
showToast?.('Email copied');
|
||||
setTimeout(() => {
|
||||
copyBtn.classList.remove('copied');
|
||||
copyBtn.title = 'Copy email';
|
||||
}, 900);
|
||||
} catch (_) {
|
||||
showToast?.('Copy failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const chip = ev.target.closest?.('.recipient-chip');
|
||||
if (!chip || !root.contains(chip)) return;
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const label = chip.querySelector('.recipient-chip-label');
|
||||
const copy = chip.querySelector('.recipient-chip-copy');
|
||||
if (chip.classList.contains('expanded')) {
|
||||
chip.classList.remove('expanded');
|
||||
if (label) label.textContent = chip.dataset.name || label.textContent;
|
||||
if (copy) copy.hidden = true;
|
||||
} else {
|
||||
if (!chip.dataset.name && label) chip.dataset.name = label.textContent.trim();
|
||||
chip.classList.add('expanded');
|
||||
const expandedText = _decodeAttrValue(chip.dataset.full || '').trim()
|
||||
|| chip.dataset.name
|
||||
|| chip.dataset.email
|
||||
|| label?.textContent?.trim()
|
||||
|| '';
|
||||
if (label && expandedText) label.textContent = expandedText;
|
||||
if (copy) copy.hidden = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _emailReaderForSelectAllTarget(target) {
|
||||
if (_isEmailTypingTarget(target)) return null;
|
||||
const direct = target?.closest?.('.email-card-reader, #email-lib-modal .doclib-card.doclib-card-expanded');
|
||||
if (direct) return direct.querySelector?.('.email-card-reader') || direct;
|
||||
const expanded = document.querySelector('#email-lib-modal:not(.hidden) .doclib-card.doclib-card-expanded .email-card-reader');
|
||||
if (expanded) return expanded;
|
||||
return _activeEmailReaderForSelectAll;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!(e.ctrlKey || e.metaKey) || String(e.key || '').toLowerCase() !== 'a') return;
|
||||
const reader = _emailReaderForSelectAllTarget(e.target);
|
||||
if (!_selectEmailReaderContents(reader)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation?.();
|
||||
}, true);
|
||||
|
||||
function _syncEmailReadState(uid, isRead = true) {
|
||||
if (uid == null) return;
|
||||
@@ -532,6 +709,15 @@ function _publishActiveAccount() {
|
||||
|| accts.find(a => a && a.is_default)
|
||||
|| accts[0];
|
||||
window._myEmailAddress = (active && (active.from_address || active.imap_user)) || '';
|
||||
// Also publish every configured address so reply-all can exclude all of
|
||||
// the user's own mailboxes, not just the active one (multi-account users
|
||||
// were getting their other addresses added to Cc).
|
||||
const all = [];
|
||||
for (const a of accts) {
|
||||
if (a && a.from_address) all.push(a.from_address);
|
||||
if (a && a.imap_user) all.push(a.imap_user);
|
||||
}
|
||||
window._myEmailAddresses = all;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -1038,10 +1224,26 @@ export function openEmailLibrary(opts = {}) {
|
||||
_bulkAction('delete');
|
||||
});
|
||||
|
||||
const selectExpandedEmailText = () => {
|
||||
const expanded = document.querySelector('#email-lib-modal .doclib-card.doclib-card-expanded');
|
||||
const reader = expanded?.querySelector('.email-card-reader') || expanded;
|
||||
return _selectEmailReaderContents(reader);
|
||||
};
|
||||
|
||||
// ESC to close + Arrow nav + Delete on the selected / currently-expanded email.
|
||||
state._libEscHandler = (e) => {
|
||||
const modal = document.getElementById('email-lib-modal');
|
||||
if (!modal || modal.classList.contains('hidden')) return;
|
||||
if ((e.ctrlKey || e.metaKey) && String(e.key || '').toLowerCase() === 'a') {
|
||||
const t = e.target;
|
||||
if (_isEmailTypingTarget(t)) return;
|
||||
if (selectExpandedEmailText()) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1058,7 +1260,7 @@ export function openEmailLibrary(opts = {}) {
|
||||
}
|
||||
// Don't hijack arrows / delete while the user is typing somewhere.
|
||||
const t = e.target;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
||||
if (_isEmailTypingTarget(t)) return;
|
||||
const isDeleteKey = e.key === 'Delete' || e.key === 'Backspace';
|
||||
if (isDeleteKey && state._selectMode && state._selectedUids.size > 0) {
|
||||
e.preventDefault();
|
||||
@@ -1184,6 +1386,23 @@ function _makeDraggable(content, modal, fsClass) {
|
||||
fsClass,
|
||||
skipSelector: '.close-btn, .modal-close',
|
||||
enableLeftDock: true, // park the email on the left while replying on the right
|
||||
onDragStart: ({ rect }) => {
|
||||
if (!modal.classList.contains('email-snap-left')) return;
|
||||
modal.classList.remove('email-snap-left');
|
||||
_clearEmailDocumentSplit();
|
||||
content.style.position = 'fixed';
|
||||
content.style.left = `${Math.round(rect.left)}px`;
|
||||
content.style.top = `${Math.round(rect.top)}px`;
|
||||
content.style.right = '';
|
||||
content.style.bottom = '';
|
||||
content.style.width = `${Math.max(420, Math.round(rect.width || 560))}px`;
|
||||
content.style.maxWidth = '';
|
||||
content.style.height = `${Math.max(320, Math.round(rect.height || 620))}px`;
|
||||
content.style.maxHeight = '85vh';
|
||||
content.style.borderRadius = '';
|
||||
content.style.transform = 'none';
|
||||
content.style.margin = '0';
|
||||
},
|
||||
onEnterFullscreen: fsClass ? enterFullscreen : null,
|
||||
onExitFullscreen: fsClass ? exitFullscreen : null,
|
||||
});
|
||||
@@ -1307,22 +1526,43 @@ function _crossFolderCandidates() {
|
||||
}
|
||||
|
||||
async function _doSearch() {
|
||||
const seq = ++_libSearchSeq;
|
||||
const q = state._libSearch.trim();
|
||||
if (q.length < 2) {
|
||||
// Empty or too short — show regular loaded emails
|
||||
// Empty or too short — restore the normal folder if a previous search
|
||||
// had replaced the grid contents.
|
||||
if (_libSearchHadResults) {
|
||||
_libSearchHadResults = false;
|
||||
state._libOffset = 0;
|
||||
await _loadEmails({ useCache: true });
|
||||
return;
|
||||
}
|
||||
_renderGrid();
|
||||
return;
|
||||
}
|
||||
const grid = document.getElementById('email-lib-grid');
|
||||
if (!grid) return;
|
||||
const sp = _renderEmailLoading(grid);
|
||||
const accountAtStart = state._libAccountId || '';
|
||||
const folderAtStart = state._libFolder || 'INBOX';
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/email/search?folder=${encodeURIComponent(state._libFolder)}${_acct()}&q=${encodeURIComponent(q)}&limit=100`);
|
||||
const accountQS = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
|
||||
const res = await fetch(`${API_BASE}/api/email/search?folder=${encodeURIComponent(folderAtStart)}${accountQS}&q=${encodeURIComponent(q)}&limit=100`);
|
||||
const data = await res.json();
|
||||
sp.destroy();
|
||||
if (
|
||||
seq !== _libSearchSeq ||
|
||||
q !== state._libSearch.trim() ||
|
||||
accountAtStart !== (state._libAccountId || '') ||
|
||||
folderAtStart !== (state._libFolder || 'INBOX')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
const results = data.emails || [];
|
||||
_libSearchHadResults = true;
|
||||
state._libEmails = results; // temporarily replace with search results
|
||||
_renderGrid();
|
||||
|
||||
@@ -1481,7 +1721,7 @@ async function _loadEmails({ force = false, useCache = true } = {}) {
|
||||
async function _loadScheduled(grid, sp) {
|
||||
const res = await fetch(`${API_BASE}/api/email/scheduled`);
|
||||
const data = await res.json();
|
||||
sp.destroy();
|
||||
if (sp) sp.destroy();
|
||||
const items = data.scheduled || [];
|
||||
grid.innerHTML = '';
|
||||
const stats = document.getElementById('email-lib-stats');
|
||||
@@ -1886,8 +2126,9 @@ function _syncCardNavArrows(card) {
|
||||
}
|
||||
|
||||
const _emailReadPrefetching = new Set();
|
||||
let _emailReadPrefetchTimer = null;
|
||||
|
||||
function _prefetchAdjacentEmails(card, count = 3) {
|
||||
function _prefetchAdjacentEmails(card, count = 1) {
|
||||
if (!card || state._libFolder === '__scheduled__') return;
|
||||
const grid = card.closest('.doclib-grid');
|
||||
if (!grid) return;
|
||||
@@ -1901,16 +2142,19 @@ function _prefetchAdjacentEmails(card, count = 3) {
|
||||
if (targets.length < count) {
|
||||
for (let i = 1; targets.length < count && cards[idx - i]; i++) targets.push(cards[idx - i]);
|
||||
}
|
||||
for (const target of targets) {
|
||||
const uid = target.dataset.uid;
|
||||
if (!uid) continue;
|
||||
const key = `${state._libAccountId || ''}|${state._libFolder}|${uid}`;
|
||||
if (_emailReadPrefetching.has(key)) continue;
|
||||
const target = targets.find(t => t?.dataset?.uid);
|
||||
const uid = target?.dataset?.uid;
|
||||
if (!uid) return;
|
||||
const key = `${state._libAccountId || ''}|${state._libFolder}|${uid}`;
|
||||
if (_emailReadPrefetching.has(key) || _emailReadPrefetching.size > 0) return;
|
||||
if (_emailReadPrefetchTimer) clearTimeout(_emailReadPrefetchTimer);
|
||||
_emailReadPrefetchTimer = setTimeout(() => {
|
||||
_emailReadPrefetchTimer = null;
|
||||
_emailReadPrefetching.add(key);
|
||||
fetch(`${API_BASE}/api/email/read/${encodeURIComponent(uid)}?folder=${encodeURIComponent(state._libFolder)}${_acct()}&mark_seen=false`)
|
||||
.catch(() => {})
|
||||
.finally(() => _emailReadPrefetching.delete(key));
|
||||
}
|
||||
}, 900);
|
||||
}
|
||||
|
||||
async function _toggleCardPreview(card, em) {
|
||||
@@ -1978,6 +2222,7 @@ async function _toggleCardPreview(card, em) {
|
||||
loadingWrap.appendChild(sp.element);
|
||||
reader.appendChild(loadingWrap);
|
||||
card.appendChild(reader);
|
||||
_markEmailReaderActive(reader);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`);
|
||||
@@ -2023,16 +2268,16 @@ async function _toggleCardPreview(card, em) {
|
||||
// Build recipient chip group from a comma-separated address list
|
||||
const buildRecipients = (str) => {
|
||||
if (!str) return '';
|
||||
const addrs = str.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const addrs = _splitRecipientList(str);
|
||||
if (addrs.length === 0) return '';
|
||||
return addrs.map(a => {
|
||||
const name = _extractName(a);
|
||||
return `<span class="recipient-chip" data-full="${_esc(a)}" title="Click for details">${_esc(name)}</span>`;
|
||||
return _recipientChipHtml(a, name);
|
||||
}).join('');
|
||||
};
|
||||
|
||||
// Build the From chip too — single chip with name, click reveals address
|
||||
const fromChip = `<span class="recipient-chip from-chip" data-full="${_esc(data.from_name)} <${_esc(data.from_address)}>" title="Click for details">${_esc(data.from_name || data.from_address)}</span>`;
|
||||
const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
|
||||
|
||||
reader.innerHTML = `
|
||||
<div class="email-reader-header">
|
||||
@@ -2060,6 +2305,7 @@ async function _toggleCardPreview(card, em) {
|
||||
${attsHtml}
|
||||
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
|
||||
`;
|
||||
_markEmailReaderActive(reader);
|
||||
reader.classList.remove('email-card-reader-loading');
|
||||
reader.style.minHeight = '';
|
||||
|
||||
@@ -2209,32 +2455,9 @@ async function _toggleCardPreview(card, em) {
|
||||
_showCachedSummary(reader, data.cached_summary, sumBtn);
|
||||
}
|
||||
|
||||
// Event delegation for recipient chip clicks (toggle expand)
|
||||
reader.addEventListener('click', (ev) => {
|
||||
const chip = ev.target.closest('.recipient-chip');
|
||||
if (chip && reader.contains(chip)) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const full = chip.getAttribute('data-full') || '';
|
||||
if (chip.classList.contains('expanded')) {
|
||||
chip.classList.remove('expanded');
|
||||
const name = chip.getAttribute('data-name');
|
||||
if (name != null) chip.textContent = name;
|
||||
} else {
|
||||
if (!chip.hasAttribute('data-name')) {
|
||||
chip.setAttribute('data-name', chip.textContent.trim());
|
||||
}
|
||||
chip.classList.add('expanded');
|
||||
// Decode HTML entities from the data-full attribute
|
||||
const tmp = document.createElement('textarea');
|
||||
tmp.innerHTML = full;
|
||||
chip.textContent = tmp.value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Always stop bubbling so the card's click doesn't fire
|
||||
ev.stopPropagation();
|
||||
});
|
||||
_wireRecipientChips(reader);
|
||||
// Always stop bubbling so the card's click doesn't fire while reading.
|
||||
reader.addEventListener('click', (ev) => { ev.stopPropagation(); });
|
||||
} catch (e) {
|
||||
reader.innerHTML = `<div style="padding:20px;color:var(--red,#e55)">Failed to load email</div>`;
|
||||
}
|
||||
@@ -3707,6 +3930,7 @@ async function _openEmailAsTab(em, folder) {
|
||||
// Fetch + render the email body using the exact same template as
|
||||
// _toggleCardPreview so the visuals match perfectly.
|
||||
const reader = modal.querySelector('.email-card-reader');
|
||||
_markEmailReaderActive(reader);
|
||||
const sp = spinnerModule.createWhirlpool(28);
|
||||
const loading = modal.querySelector('.email-reader-tab-loading');
|
||||
if (loading) loading.appendChild(sp.element);
|
||||
@@ -3720,12 +3944,12 @@ async function _openEmailAsTab(em, folder) {
|
||||
_syncEmailReadState(em.uid, true);
|
||||
const buildChips = (str) => {
|
||||
if (!str) return '';
|
||||
return str.split(',').map(s => s.trim()).filter(Boolean).map(a => {
|
||||
return _splitRecipientList(str).map(a => {
|
||||
const name = _extractName(a);
|
||||
return `<span class="recipient-chip" data-full="${_esc(a)}" title="Click for details">${_esc(name)}</span>`;
|
||||
return _recipientChipHtml(a, name);
|
||||
}).join('');
|
||||
};
|
||||
const fromChip = `<span class="recipient-chip from-chip" data-full="${_esc(data.from_name)} <${_esc(data.from_address)}>" title="Click for details">${_esc(data.from_name || data.from_address)}</span>`;
|
||||
const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
|
||||
let attsHtml = '';
|
||||
try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {}
|
||||
reader.innerHTML = `
|
||||
@@ -3754,6 +3978,8 @@ async function _openEmailAsTab(em, folder) {
|
||||
${attsHtml}
|
||||
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
|
||||
`;
|
||||
_markEmailReaderActive(reader);
|
||||
_wireRecipientChips(reader);
|
||||
try { _wireAttachmentHandlers(reader, useFolder); } catch {}
|
||||
const attsWrap = reader.querySelector('.email-reader-atts-wrap');
|
||||
if (attsWrap) {
|
||||
@@ -3866,18 +4092,19 @@ async function _openEmailWindow(em, folder) {
|
||||
// standalone viewer looks/feels exactly like a real email view.
|
||||
const _chipsFor = (addrs) => {
|
||||
if (!addrs) return '';
|
||||
const list = addrs.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const list = _splitRecipientList(addrs);
|
||||
return list.map(a => {
|
||||
const name = _extractName(a);
|
||||
return `<span class="recipient-chip" data-full="${_esc(a)}" title="Click for details">${_esc(name)}</span>`;
|
||||
return _recipientChipHtml(a, name);
|
||||
}).join('');
|
||||
};
|
||||
const fromChip = `<span class="recipient-chip from-chip" data-full="${_esc(data.from_name)} <${_esc(data.from_address)}>" title="Click for details">${_esc(data.from_name || data.from_address)}</span>`;
|
||||
const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
|
||||
let attsHtml = '';
|
||||
try { attsHtml = _buildAttsHtmlFor(em.uid, data); } catch {}
|
||||
// Repurpose bodyEl as a full email-card-reader so the inline reader's
|
||||
// CSS applies (sized header, action buttons in two rows, etc.).
|
||||
bodyEl.classList.add('email-card-reader');
|
||||
_markEmailReaderActive(bodyEl);
|
||||
bodyEl.style.padding = '0';
|
||||
bodyEl.innerHTML = `
|
||||
<div class="email-reader-header">
|
||||
@@ -3905,6 +4132,8 @@ async function _openEmailWindow(em, folder) {
|
||||
${attsHtml}
|
||||
<div class="email-reader-body${data.body_html ? ' html-body' : ''}">${_safeRenderEmailBody(data)}</div>
|
||||
`;
|
||||
_markEmailReaderActive(bodyEl);
|
||||
_wireRecipientChips(bodyEl);
|
||||
// Wire all the same action handlers the inline reader has.
|
||||
try { _wireAttachmentHandlers(bodyEl, useFolder); } catch {}
|
||||
const attsWrap = bodyEl.querySelector('.email-reader-atts-wrap');
|
||||
@@ -3977,11 +4206,22 @@ async function _swapReaderToUid(reader, uid, folder) {
|
||||
if (headerMeta) {
|
||||
const subj = data.subject || '(no subject)';
|
||||
const date = data.date ? new Date(data.date).toLocaleString() : '';
|
||||
const chipsFor = (addrs) => {
|
||||
if (!addrs) return '';
|
||||
return _splitRecipientList(addrs).map(a => {
|
||||
const name = _extractName(a);
|
||||
return _recipientChipHtml(a, name);
|
||||
}).join('');
|
||||
};
|
||||
const fromChip = _recipientChipHtml(`${data.from_name || ''} <${data.from_address || ''}>`, data.from_name || data.from_address, 'from-chip');
|
||||
headerMeta.innerHTML = `
|
||||
<div class="email-reader-meta-row"><strong>Subject:</strong> ${_esc(subj)}</div>
|
||||
<div class="email-reader-meta-row"><strong>From:</strong> ${_esc(data.from_name || data.from_address)} <${_esc(data.from_address)}></div>
|
||||
<div class="email-reader-meta-row"><strong>From:</strong><span class="recipient-chips">${fromChip}</span></div>
|
||||
${data.to ? `<div class="email-reader-meta-row"><strong>To:</strong><span class="recipient-chips">${chipsFor(data.to)}</span></div>` : ''}
|
||||
${data.cc ? `<div class="email-reader-meta-row"><strong>Cc:</strong><span class="recipient-chips">${chipsFor(data.cc)}</span></div>` : ''}
|
||||
${date ? `<div class="email-reader-meta-row"><strong>Date:</strong> ${_esc(date)}</div>` : ''}
|
||||
`;
|
||||
_wireRecipientChips(reader);
|
||||
}
|
||||
// Refresh the attachments block to match the new email. Build fresh HTML
|
||||
// and either replace the existing block, remove it (if the new email has
|
||||
@@ -4218,6 +4458,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
const _deleteForeverIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="14" y2="15"/><line x1="14" y1="11" x2="10" y2="15"/></svg>';
|
||||
const _bellIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
|
||||
const _newTabIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>';
|
||||
const _checkIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
|
||||
const closeAndRemove = async () => {
|
||||
// Pick the next neighbour BEFORE we re-render so we know which email to
|
||||
@@ -4300,6 +4541,24 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
_renderGrid();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: em.is_answered ? 'Not Done' : 'Done',
|
||||
icon: _checkIcon,
|
||||
action: async () => {
|
||||
const newState = !em.is_answered;
|
||||
em.is_answered = newState;
|
||||
if (newState) _syncEmailReadState(em.uid, true);
|
||||
try {
|
||||
if (newState) {
|
||||
await fetch(`${API_BASE}/api/email/mark-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
await fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
} else {
|
||||
await fetch(`${API_BASE}/api/email/clear-answered/${em.uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
}
|
||||
} catch (e) { console.error('Failed to toggle done:', e); }
|
||||
_renderGrid();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Archive',
|
||||
icon: _archIcon,
|
||||
@@ -4441,7 +4700,7 @@ function _showCardMenu(em, anchor) {
|
||||
const _checkForLabel = _cardForLabel ? _cardForLabel.querySelector('.email-card-done') : null;
|
||||
const _currentlyDone = _checkForLabel ? _checkForLabel.classList.contains('active') : !!em.is_answered;
|
||||
actions.push({
|
||||
label: _currentlyDone ? 'Mark Not Done' : 'Mark Done',
|
||||
label: _currentlyDone ? 'Not Done' : 'Done',
|
||||
icon: _checkIcon,
|
||||
action: async () => {
|
||||
const card = anchor.closest('.doclib-card');
|
||||
@@ -4570,7 +4829,9 @@ function _showBulkActionsMenu(anchor) {
|
||||
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:160px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:${rect.left}px;`;
|
||||
const _readIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7z"/></svg>';
|
||||
const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
||||
const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
const items = [
|
||||
{ label: 'Done', icon: _doneIco, action: () => _bulkAction('done') },
|
||||
{ label: 'Mark Read', icon: _readIco, action: () => _bulkAction('read') },
|
||||
{ label: 'Mark Unread', icon: _unreadIco, action: () => _bulkAction('unread') },
|
||||
];
|
||||
@@ -4631,6 +4892,7 @@ function _updateBulkBar() {
|
||||
async function _bulkAction(action) {
|
||||
const uids = Array.from(state._selectedUids);
|
||||
if (uids.length === 0) return;
|
||||
let failedReadSync = 0;
|
||||
if (action === 'delete') {
|
||||
const ok = await styledConfirm(
|
||||
`Delete ${uids.length} selected email${uids.length === 1 ? '' : 's'}?`,
|
||||
@@ -4639,31 +4901,87 @@ async function _bulkAction(action) {
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
for (const uid of uids) {
|
||||
try {
|
||||
if (action === 'archive') {
|
||||
await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
} else if (action === 'delete') {
|
||||
await fetch(`${API_BASE}/api/email/delete/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
|
||||
} else if (action === 'read' || action === 'unread') {
|
||||
// Local toggle for now (no backend endpoint yet)
|
||||
const em = state._libEmails.find(e => e.uid === uid);
|
||||
if (em) em.is_read = (action === 'read');
|
||||
}
|
||||
} catch (e) { console.error(`Failed to ${action} ${uid}:`, e); }
|
||||
const deleteBtn = action === 'delete' ? document.getElementById('email-lib-bulk-delete') : null;
|
||||
const actionsBtn = document.getElementById('email-lib-bulk-actions');
|
||||
const cancelBtn = document.getElementById('email-lib-bulk-cancel');
|
||||
const selectAll = document.getElementById('email-lib-select-all');
|
||||
const countEl = document.getElementById('email-lib-selected-count');
|
||||
const originalDeleteHtml = deleteBtn?.innerHTML || '';
|
||||
const originalCountText = countEl?.textContent || '';
|
||||
let busySpinner = null;
|
||||
if (action === 'delete') {
|
||||
if (deleteBtn) {
|
||||
deleteBtn.disabled = true;
|
||||
deleteBtn.classList.add('email-bulk-loading');
|
||||
deleteBtn.innerHTML = '<span class="email-bulk-loading-label">Deleting</span>';
|
||||
busySpinner = spinnerModule.create('', 'clean', 'whirlpool');
|
||||
const spEl = busySpinner.createElement();
|
||||
spEl.classList.add('email-bulk-whirlpool');
|
||||
deleteBtn.appendChild(spEl);
|
||||
busySpinner.start();
|
||||
}
|
||||
if (actionsBtn) actionsBtn.disabled = true;
|
||||
if (cancelBtn) cancelBtn.disabled = true;
|
||||
if (selectAll) selectAll.disabled = true;
|
||||
if (countEl) countEl.textContent = `Deleting ${uids.length}...`;
|
||||
}
|
||||
|
||||
if (action === 'archive' || action === 'delete') {
|
||||
await _animateEmailCardRemoval(uids);
|
||||
const removed = new Set(uids.map(uid => String(uid)));
|
||||
state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
|
||||
try {
|
||||
for (const uid of uids) {
|
||||
try {
|
||||
if (action === 'archive') {
|
||||
await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
} else if (action === 'delete') {
|
||||
await fetch(`${API_BASE}/api/email/delete/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' });
|
||||
} else if (action === 'done') {
|
||||
const em = state._libEmails.find(e => e.uid === uid);
|
||||
if (em) {
|
||||
em.is_answered = true;
|
||||
em.is_read = true;
|
||||
}
|
||||
await fetch(`${API_BASE}/api/email/mark-answered/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
await fetch(`${API_BASE}/api/email/mark-read/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
} else if (action === 'read' || action === 'unread') {
|
||||
const endpoint = action === 'read' ? 'mark-read' : 'mark-unread';
|
||||
const res = await fetch(`${API_BASE}/api/email/${endpoint}/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' });
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (_) {}
|
||||
if (!res.ok || data?.success === false) {
|
||||
throw new Error(data?.error || `HTTP ${res.status}`);
|
||||
}
|
||||
_syncEmailReadState(uid, action === 'read');
|
||||
}
|
||||
} catch (e) {
|
||||
if (action === 'read' || action === 'unread') failedReadSync += 1;
|
||||
console.error(`Failed to ${action} ${uid}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'archive' || action === 'delete') {
|
||||
await _animateEmailCardRemoval(uids);
|
||||
const removed = new Set(uids.map(uid => String(uid)));
|
||||
state._libEmails = state._libEmails.filter(e => !removed.has(String(e.uid)));
|
||||
}
|
||||
} finally {
|
||||
if (busySpinner) busySpinner.destroy();
|
||||
if (deleteBtn) {
|
||||
deleteBtn.disabled = false;
|
||||
deleteBtn.classList.remove('email-bulk-loading');
|
||||
deleteBtn.innerHTML = originalDeleteHtml;
|
||||
}
|
||||
if (actionsBtn) actionsBtn.disabled = false;
|
||||
if (cancelBtn) cancelBtn.disabled = false;
|
||||
if (selectAll) selectAll.disabled = false;
|
||||
if (countEl) countEl.textContent = originalCountText;
|
||||
}
|
||||
state._selectedUids.clear();
|
||||
state._selectMode = false;
|
||||
_updateBulkBar();
|
||||
_renderGrid();
|
||||
// Sync the local mutation (delete/archive, or in-place read/unread
|
||||
// flag flips on email objects) into the SWR cache so reopen doesn't
|
||||
if (failedReadSync > 0) {
|
||||
showToast(`Failed to update ${failedReadSync} email${failedReadSync === 1 ? '' : 's'}`);
|
||||
}
|
||||
// Sync successful local mutations into the SWR cache so reopen doesn't
|
||||
// briefly show the pre-bulk state.
|
||||
_libCacheWriteBack();
|
||||
}
|
||||
|
||||
@@ -12,14 +12,16 @@ export function extractEmail(addr) {
|
||||
// Reply-all CC = everyone on the original To + Cc, minus ourselves, with the
|
||||
// original "Name <email>" form preserved.
|
||||
//
|
||||
// `myAddress` empty/unknown ⇒ no exclusion. Comparing by exact extracted email
|
||||
// (not a substring `includes`) is what fixes issue #360: an empty self address
|
||||
// made `"...".includes("")` true for every recipient, so reply-all dropped the
|
||||
// entire Cc list and kept only the original sender.
|
||||
export function buildReplyAllCc(data, myAddress) {
|
||||
const me = (myAddress || '').toLowerCase();
|
||||
const split = (s) => (s || '').split(',').map((x) => x.trim()).filter(Boolean);
|
||||
// `mine` is a single address or a list of the user's own addresses (a
|
||||
// multi-account user has more than one). Empty/unknown ⇒ no exclusion.
|
||||
// Comparing by exact extracted email (not a substring `includes`) is what
|
||||
// fixes issue #360: an empty self address made `"...".includes("")` true for
|
||||
// every recipient, so reply-all dropped the entire Cc list.
|
||||
export function buildReplyAllCc(data, mine) {
|
||||
const list = Array.isArray(mine) ? mine : [mine];
|
||||
const me = new Set(list.map((a) => (a || '').toLowerCase()).filter(Boolean));
|
||||
const split = (s) => (typeof s === 'string' ? s : '').split(',').map((x) => x.trim()).filter(Boolean);
|
||||
return [...split(data && data.to), ...split(data && data.cc)]
|
||||
.filter((addr) => !me || extractEmail(addr) !== me)
|
||||
.filter((addr) => !me.has(extractEmail(addr)))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ export function _foldSummary(label, iconSvg, meta) {
|
||||
// "On <date>, <addr> wrote:". Returns a display string like
|
||||
// "Jane Doe · Mon, Apr 18, 2026 at 9:31 AM" or `''`.
|
||||
export function _extractQuoteMeta(html) {
|
||||
if (!html) return '';
|
||||
if (typeof html !== 'string' || !html) return '';
|
||||
const txt = html
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
@@ -154,7 +154,11 @@ export function _extractQuoteMeta(html) {
|
||||
let date = sentMatch ? sentMatch[1].trim() : '';
|
||||
|
||||
if (!from && !date) {
|
||||
const gmail = txt.match(/On\s+([^,]+?,[^,]+?\d{4}[^,]*),?\s+(.+?)\s+wrote\s*:/i);
|
||||
// The date may carry up to three commas before the year: the standard
|
||||
// US Gmail attribution is "On Mon, Apr 18, 2026 at 9:31 AM, Jane wrote:"
|
||||
// (weekday and day-of-month each add one). A single-comma pattern never
|
||||
// reached the year there, so the fold lost its sender/date headline.
|
||||
const gmail = txt.match(/On\s+((?:[^,]*,){0,3}?[^,]*?\d{4}[^,]*),?\s+(.+?)\s+wrote\s*:/i);
|
||||
if (gmail) { date = gmail[1].trim(); from = gmail[2].trim(); }
|
||||
}
|
||||
|
||||
@@ -298,7 +302,7 @@ export function _foldSignature(html, hintSig) {
|
||||
m = html.match(/<div[^>]*id=["'](?:Signature|signature|divRplyFwdMsg)["'][\s\S]*$/i);
|
||||
if (m) return wrap(html.slice(0, html.length - m[0].length), '', m[0]);
|
||||
|
||||
m = html.match(/(<br>|\n)\s*--\s*(<br>|\n)([\s\S]*)$/i);
|
||||
m = html.match(/(<br\s*\/?>|\n)\s*--\s*(<br\s*\/?>|\n)([\s\S]*)$/i);
|
||||
if (m) {
|
||||
const idx = html.lastIndexOf(m[0]);
|
||||
return wrap(html.slice(0, idx), m[1], m[3]);
|
||||
|
||||
@@ -15,7 +15,7 @@ export const _TALON_FROM = '(?:From|Från|Von|De|Da|От|Od|Van|差出人|发件
|
||||
export const _TALON_SENT = '(?:Sent|Skickat|Gesendet|Envoy[ée]|Inviato|Enviado|Verzonden|Отправлено|Wysłane|Date|送信日時|发送时间|寄件日期|Sendt|Lähetetty|Tarih|Datum|Data|Datum)';
|
||||
export const _TALON_SUBJ = '(?:Subject|Ämne|Betreff|Objet|Oggetto|Asunto|Onderwerp|Тема|Temat|件名|主题|主旨|Emne|Aihe|Onderwerp|Konu)';
|
||||
export const _TALON_TO = '(?:To|Till|An|À|A|Voor|Para|Naar|Кому|Do|宛先|收件人|Emri|Komu)';
|
||||
export const _TALON_ORIG_RE = /(?:^|\n)[\s>]*[-_=]{3,}\s*(?:Original\s+Message|Ursprüngliche\s+Nachricht|Mensaje\s+original|Messaggio\s+originale|Message\s+d['’]origine|Oorspronkelijk\s+bericht|Original\s+meddelande|Vor[ ]asal[a]\s+meddelande|原文|原始邮件|転送)\s*[-_=]{3,}/i;
|
||||
export const _TALON_ORIG_RE = /(?:^|\n)[\s>]*[-_=]{3,}\s*(?:Original\s+Message|Forwarded\s+message|Ursprüngliche\s+Nachricht|Mensaje\s+original|Messaggio\s+originale|Message\s+d['’]origine|Oorspronkelijk\s+bericht|Original\s+meddelande|Vor[ ]asal[a]\s+meddelande|原文|原始邮件|転送)\s*[-_=]{3,}/i;
|
||||
|
||||
// Minimum plain-text length of a "signature" before we bother folding it.
|
||||
// Short closings ("Cheers, John") stay inline — folding them would add
|
||||
|
||||
@@ -112,7 +112,7 @@ function _createChip(f, idx) {
|
||||
chip.classList.add('thumb-image'); // lets CSS overlay the remove-X on the corner (mobile)
|
||||
const img = document.createElement('img');
|
||||
img.className = 'thumb-img';
|
||||
img.src = URL.createObjectURL(f);
|
||||
img.src = _getPreviewUrl(f);
|
||||
img.alt = f.name || 'image';
|
||||
chip.appendChild(img);
|
||||
} else {
|
||||
@@ -172,6 +172,17 @@ export async function uploadPending() {
|
||||
method: 'POST',
|
||||
body: fd
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Surface the failure instead of swallowing it. Previously a non-OK
|
||||
// response (e.g. 429 rate limit, 413 too large) was ignored: the files
|
||||
// silently vanished and the chat sent with no attachments, so the model
|
||||
// "didn't even see them" (issue #1346). Show the server's reason and keep
|
||||
// pendingFiles so the strip re-renders for a retry (see finally below).
|
||||
let detail = '';
|
||||
try { const e = await res.json(); detail = e.detail || e.error || ''; } catch (_) {}
|
||||
_showToast('Upload failed' + (detail ? ': ' + detail : ` (HTTP ${res.status})`));
|
||||
return [];
|
||||
}
|
||||
const data = await res.json();
|
||||
uploaded = (data.files || []);
|
||||
pendingFiles = []; // clear only on success
|
||||
|
||||
+12
-7
@@ -8,6 +8,7 @@ import spinnerModule from './spinner.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { PROMPT_TEMPLATES, getAllPresets } from './presets.js';
|
||||
import { sortModelObjects } from './modelSort.js';
|
||||
import Storage from './storage.js';
|
||||
|
||||
let API_BASE = '';
|
||||
let _active = false;
|
||||
@@ -57,7 +58,7 @@ function _initGroupTab() {
|
||||
});
|
||||
});
|
||||
_modelsCache = sortModelObjects(result);
|
||||
return result;
|
||||
return _modelsCache;
|
||||
}
|
||||
|
||||
function _render() {
|
||||
@@ -298,13 +299,16 @@ async function _getCharacterList() {
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
// Load user templates and wait for them before returning
|
||||
// Load user templates and wait for them before returning.
|
||||
// The endpoint returns a JSON array directly (not {templates:[...]}).
|
||||
// All user templates are personas by definition — no isCharacter filter needed.
|
||||
try {
|
||||
const r = await fetch(API_BASE + '/api/presets/templates', { credentials: 'same-origin' });
|
||||
const data = await r.json();
|
||||
(data.templates || []).forEach(t => {
|
||||
if (t.isCharacter && !chars.find(c => c.id === t.id)) {
|
||||
chars.push({ id: t.id, name: t.name, prompt: t.prompt || '' });
|
||||
const templates = Array.isArray(data) ? data : (data.templates || []);
|
||||
templates.forEach(t => {
|
||||
if (t.id && t.name && !chars.find(c => c.id === t.id)) {
|
||||
chars.push({ id: t.id, name: t.name, prompt: t.system_prompt || t.prompt || '' });
|
||||
}
|
||||
});
|
||||
} catch (e) {}
|
||||
@@ -409,7 +413,7 @@ export async function showModelPicker() {
|
||||
});
|
||||
});
|
||||
_cachedModels = sortModelObjects(result);
|
||||
return result;
|
||||
return _cachedModels;
|
||||
}
|
||||
|
||||
async function render(filter) {
|
||||
@@ -546,7 +550,8 @@ export async function startGroup(models, parentSessionId) {
|
||||
_parentSessionId = pdata.id;
|
||||
// Register as group session for sidebar icon
|
||||
try {
|
||||
const gids = JSON.parse(localStorage.getItem('odysseus-group-sessions') || '[]');
|
||||
const storedGroupSessions = Storage.getJSON('odysseus-group-sessions', []);
|
||||
const gids = Array.isArray(storedGroupSessions) ? storedGroupSessions : [];
|
||||
if (!gids.includes(_parentSessionId)) { gids.push(_parentSessionId); localStorage.setItem('odysseus-group-sessions', JSON.stringify(gids)); }
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
|
||||
@@ -165,6 +165,39 @@ window.addEventListener('pageshow', clearFreshComposerRestore);
|
||||
window.addEventListener('resize', _sync);
|
||||
}
|
||||
|
||||
/* Keep minimized tool chips above the composer. Both the current modalManager
|
||||
dock and the legacy fallback dock consume this root-level clearance. */
|
||||
{
|
||||
const root = document.documentElement;
|
||||
const chatBar = document.querySelector('.chat-input-bar');
|
||||
const attachStrip = document.getElementById('attach-strip');
|
||||
const chatContainer = document.getElementById('chat-container');
|
||||
const _syncComposerClearance = () => {
|
||||
let top = window.innerHeight;
|
||||
for (const el of [attachStrip, chatBar]) {
|
||||
if (!el) continue;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.height > 0) top = Math.min(top, rect.top);
|
||||
}
|
||||
const clearance = Math.max(12, Math.ceil(window.innerHeight - top + 8));
|
||||
root.style.setProperty('--composer-clearance', clearance + 'px');
|
||||
};
|
||||
requestAnimationFrame(_syncComposerClearance);
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(_syncComposerClearance);
|
||||
if (chatBar) ro.observe(chatBar);
|
||||
if (attachStrip) ro.observe(attachStrip);
|
||||
}
|
||||
if (chatContainer && typeof MutationObserver !== 'undefined') {
|
||||
new MutationObserver(_syncComposerClearance).observe(chatContainer, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
}
|
||||
if (chatBar) chatBar.addEventListener('transitionend', _syncComposerClearance);
|
||||
window.addEventListener('resize', _syncComposerClearance);
|
||||
}
|
||||
|
||||
/* ---- Resizable sidebar — drag edge to resize, collapse if small, drag rail edge to expand ---- */
|
||||
{
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// Keyboard Shortcuts — dynamic keybinds
|
||||
// ============================================
|
||||
|
||||
import { IS_MAC, isAltGrEvent } from './platform.js';
|
||||
|
||||
const _defaultKeybinds = {
|
||||
search: 'ctrl+k', toggle_sidebar: 'ctrl+alt+b', new_session: 'ctrl+alt+n',
|
||||
fav_session: 'ctrl+alt+f', delete_session: 'ctrl+alt+d',
|
||||
@@ -13,8 +15,11 @@ const _defaultKeybinds = {
|
||||
open_notes: '', open_tasks: '', open_theme: '',
|
||||
};
|
||||
|
||||
function _matchesCombo(e, combo) {
|
||||
export function _matchesCombo(e, combo, isMac = IS_MAC) {
|
||||
if (!combo) return false;
|
||||
// Drop AltGr keystrokes so typing characters on non-US layouts can't fire a
|
||||
// Ctrl+Alt shortcut — e.g. the destructive delete_session. See platform.js.
|
||||
if (isAltGrEvent(e, isMac)) return false;
|
||||
const parts = combo.split('+');
|
||||
const needCtrl = parts.includes('ctrl');
|
||||
const needAlt = parts.includes('alt');
|
||||
|
||||
@@ -175,8 +175,8 @@ export function langIcon(lang, size = 14, opts = {}) {
|
||||
const key = String(lang).toLowerCase();
|
||||
const inner = ICONS[key] || ICONS[ALIASES[key]] || '';
|
||||
if (!inner) return '';
|
||||
const cls = opts.className ? ` class="${opts.className}"` : '';
|
||||
const style = opts.style ? ` style="${opts.style}"` : '';
|
||||
const cls = (opts && opts.className) ? ` class="${opts.className}"` : '';
|
||||
const style = (opts && opts.style) ? ` style="${opts.style}"` : '';
|
||||
return (
|
||||
`<svg${cls}${style} width="${size}" height="${size}" viewBox="0 0 24 24" ` +
|
||||
`fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">` +
|
||||
|
||||
+47
-40
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import { splitTableRow } from './markdown/tableRow.js';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -371,10 +372,46 @@ export function processWithThinking(text) {
|
||||
* Convert markdown to HTML
|
||||
*/
|
||||
export function mdToHtml(src) {
|
||||
// CRITICAL: Extract allowed HTML blocks first (details/summary)
|
||||
const allowedHtmlBlocks = [];
|
||||
const codeBlocks = [];
|
||||
const mermaidBlocks = [];
|
||||
let s = (src ?? '');
|
||||
|
||||
// Extract fenced code blocks before any markdown/HTML preservation passes.
|
||||
// Otherwise placeholders from the allowed-HTML sanitizer (e.g.
|
||||
// ___ALLOWED_HTML_0___) can leak into quoted HTML/JS samples, because the
|
||||
// placeholder gets captured as literal code content and never restored inside
|
||||
// the final <pre><code> block.
|
||||
s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => {
|
||||
const cleaned = code
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/[ \t]+$/gm, '')
|
||||
.replace(/^\s*\n+/, '')
|
||||
.replace(/\n+\s*$/g, '');
|
||||
|
||||
// Mermaid diagrams: render as diagram instead of code block
|
||||
if (lang && lang.toLowerCase() === 'mermaid') {
|
||||
const mermaidId = 'mermaid-' + Date.now() + '-' + mermaidBlocks.length;
|
||||
const raw = cleaned.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
const placeholder = `___MERMAID_BLOCK_${mermaidBlocks.length}___`;
|
||||
mermaidBlocks.push(`<div class="mermaid-container"><pre class="mermaid" id="${mermaidId}">${escapeHtml(raw)}</pre></div>`);
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
const escaped = cleaned.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
const placeholder = `___CODE_BLOCK_${codeBlocks.length}___`;
|
||||
|
||||
const langClass = lang ? ` class="language-${lang}"` : '';
|
||||
const runnableLangs = ['python','py','javascript','js','html','bash','sh','shell','zsh'];
|
||||
const runBtn = (lang && runnableLangs.includes(lang.toLowerCase()))
|
||||
? `<button type="button" class="run-code" data-code="${escapeHtml(escaped)}" data-lang="${lang}" title="Run code"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>`
|
||||
: '';
|
||||
const editBtn = `<button type="button" class="edit-code" title="Edit"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>`;
|
||||
codeBlocks.push(`<pre><code${langClass} data-lang="${lang || ''}">${escapeHtml(escaped)}</code>${runBtn}${editBtn}<button type="button" class="copy-code" data-code="${escapeHtml(escaped)}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></pre>`);
|
||||
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// Repair common ways the agent mangles the entity-anchor convention
|
||||
// (`[Name](#kind-<id>)`). Models reliably get the single-link case
|
||||
// right but slip into other formats when listing many in a table.
|
||||
@@ -449,39 +486,6 @@ export function mdToHtml(src) {
|
||||
|
||||
s = s.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
// CRITICAL: Extract code blocks and replace with placeholders
|
||||
const codeBlocks = [];
|
||||
const mermaidBlocks = [];
|
||||
s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => {
|
||||
const cleaned = code
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/[ \t]+$/gm, '')
|
||||
.replace(/^\s*\n+/, '')
|
||||
.replace(/\n+\s*$/g, '');
|
||||
|
||||
// Mermaid diagrams: render as diagram instead of code block
|
||||
if (lang && lang.toLowerCase() === 'mermaid') {
|
||||
const mermaidId = 'mermaid-' + Date.now() + '-' + mermaidBlocks.length;
|
||||
const raw = cleaned.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
const placeholder = `___MERMAID_BLOCK_${mermaidBlocks.length}___`;
|
||||
mermaidBlocks.push(`<div class="mermaid-container"><pre class="mermaid" id="${mermaidId}">${escapeHtml(raw)}</pre></div>`);
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
const escaped = cleaned.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
const placeholder = `___CODE_BLOCK_${codeBlocks.length}___`;
|
||||
|
||||
const langClass = lang ? ` class="language-${lang}"` : '';
|
||||
const runnableLangs = ['python','py','javascript','js','html','bash','sh','shell','zsh'];
|
||||
const runBtn = (lang && runnableLangs.includes(lang.toLowerCase()))
|
||||
? `<button type="button" class="run-code" data-code="${escapeHtml(escaped)}" data-lang="${lang}" title="Run code"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>`
|
||||
: '';
|
||||
const editBtn = `<button type="button" class="edit-code" title="Edit"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>`;
|
||||
codeBlocks.push(`<pre><code${langClass} data-lang="${lang || ''}">${escapeHtml(escaped)}</code>${runBtn}${editBtn}<button type="button" class="copy-code" data-code="${escapeHtml(escaped)}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></pre>`);
|
||||
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// KaTeX math rendering (after code blocks are extracted, so math in code is safe)
|
||||
const mathBlocks = [];
|
||||
if (window.katex) {
|
||||
@@ -535,16 +539,18 @@ export function mdToHtml(src) {
|
||||
let html = '<table style="border-collapse: collapse; width: 100%; margin: 10px 0;">';
|
||||
|
||||
rows.forEach((row, idx) => {
|
||||
const cells = row.split('|').filter(cell => cell.trim() !== '');
|
||||
if (idx === 1 && /^[\s|:\-]+$/.test(row)) {
|
||||
html += '<tbody>';
|
||||
return;
|
||||
}
|
||||
const cells = splitTableRow(row);
|
||||
if (cells.length === 0) return;
|
||||
|
||||
html += idx === 1 ? '<tbody>' : '';
|
||||
html += '<tr>';
|
||||
|
||||
cells.forEach(cell => {
|
||||
const tag = idx === 0 ? 'th' : 'td';
|
||||
const style = idx === 1 ? 'style="border-top: 2px solid var(--red);"' : '';
|
||||
html += `<${tag} ${style} style="padding: 8px; text-align: left; border-bottom: 1px solid var(--border);">${cell.trim()}</${tag}>`;
|
||||
html += `<${tag} style="padding: 8px; text-align: left; border-bottom: 1px solid var(--border);">${cell.trim()}</${tag}>`;
|
||||
});
|
||||
|
||||
html += '</tr>';
|
||||
@@ -580,8 +586,9 @@ export function mdToHtml(src) {
|
||||
s = s.replace(/(?:^|\n)(<oli>[\s\S]*?)(?=\n(?!<oli>)|$)/g, m => `<ol>${m.trim().replace(/<\/?oli>/g, (t) => t === '<oli>' ? '<li>' : '</li>')}</ol>`);
|
||||
|
||||
// Unordered lists
|
||||
s = s.replace(/^(?:- |\* )(.*)$/gm, '<li>$1</li>');
|
||||
s = s.replace(/(?:^|\n)(<li>[\s\S]*?)(?=\n(?!<li>)|$)/g, m => `<ul>${m.trim()}</ul>`);
|
||||
s = s.replace(/^(?:- |\* )(.*)$/gm, '<uli>$1</uli>');
|
||||
s = s.replace(/(^|\n)((?:<uli>[^\n]*<\/uli>(?:\n|$))+)/g, (_, prefix, block) =>
|
||||
`${prefix}<ul>${block.trim().replace(/<\/?uli>/g, (t) => t === '<uli>' ? '<li>' : '</li>')}</ul>`);
|
||||
|
||||
// Blockquotes
|
||||
s = s.replace(/^> (.*)$/gm, '<bq>$1</bq>');
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// static/js/markdown/tableRow.js
|
||||
//
|
||||
// Pure helper for splitting a markdown table row into cells. No DOM —
|
||||
// safe to import anywhere and to unit-test under node.
|
||||
|
||||
// Split a "| a | b | c |" row into trimmed cell strings.
|
||||
//
|
||||
// Strip only the optional leading/trailing pipe, then split — filtering out
|
||||
// every empty cell (the old behaviour) dropped intentionally-empty interior
|
||||
// cells too, so "| a | | c |" collapsed to 2 columns and misaligned with the
|
||||
// header.
|
||||
export function splitTableRow(row) {
|
||||
const text = typeof row === 'string' ? row : '';
|
||||
return text
|
||||
.replace(/^\s*\|/, '')
|
||||
.replace(/\|\s*$/, '')
|
||||
.split('|')
|
||||
.map((cell) => cell.trim());
|
||||
}
|
||||
@@ -78,10 +78,20 @@ function _captureRestoreHeight(modal, state) {
|
||||
if (!modal || !state) return;
|
||||
const content = modal.querySelector('.modal-content');
|
||||
if (!content) return;
|
||||
if (modal.id === 'email-lib-modal'
|
||||
&& (modal.classList.contains('modal-left-docked')
|
||||
|| modal.classList.contains('email-snap-left')
|
||||
|| document.body.classList.contains('email-doc-split-active'))) {
|
||||
delete state.restoreMinHeight;
|
||||
return;
|
||||
}
|
||||
const rect = content.getBoundingClientRect();
|
||||
if (!rect || rect.height < 120) return;
|
||||
const maxHeight = Math.max(180, window.innerHeight - 24);
|
||||
state.restoreMinHeight = `${Math.round(Math.min(rect.height, maxHeight))}px`;
|
||||
const minHeight = modal.id === 'email-lib-modal' && window.innerWidth > 768
|
||||
? Math.min(560, maxHeight)
|
||||
: 0;
|
||||
state.restoreMinHeight = `${Math.round(Math.max(minHeight, Math.min(rect.height, maxHeight)))}px`;
|
||||
}
|
||||
|
||||
function _applyRestoreHeight(modal, state) {
|
||||
@@ -90,7 +100,10 @@ function _applyRestoreHeight(modal, state) {
|
||||
if (!content) return;
|
||||
const maxHeight = Math.max(180, window.innerHeight - 24);
|
||||
const requested = parseInt(state.restoreMinHeight, 10);
|
||||
const height = Number.isFinite(requested) ? Math.min(requested, maxHeight) : null;
|
||||
const minHeight = modal.id === 'email-lib-modal' && window.innerWidth > 768
|
||||
? Math.min(560, maxHeight)
|
||||
: 0;
|
||||
const height = Number.isFinite(requested) ? Math.max(minHeight, Math.min(requested, maxHeight)) : null;
|
||||
if (height) content.style.minHeight = `${height}px`;
|
||||
}
|
||||
|
||||
@@ -380,7 +393,7 @@ function _renderDock() {
|
||||
chip.style.setProperty('position', 'fixed', 'important');
|
||||
chip.style.setProperty('left', `${pos.left}px`, 'important');
|
||||
chip.style.setProperty('top', `${pos.top}px`, 'important');
|
||||
chip.style.setProperty('z-index', '999', 'important');
|
||||
chip.style.setProperty('z-index', '10020', 'important');
|
||||
document.body.appendChild(chip);
|
||||
} else {
|
||||
dock.appendChild(chip);
|
||||
@@ -820,7 +833,7 @@ function _wireChipDrag(chip, dock) {
|
||||
// inline styles set via .style on some Safari versions.
|
||||
chip.style.setProperty('transition', 'none', 'important');
|
||||
chip.style.setProperty('transform', `translate(${tx}px, ${ty}px) scale(${inZone ? 1.12 : 1.05})`, 'important');
|
||||
chip.style.setProperty('z-index', '10000', 'important');
|
||||
chip.style.setProperty('z-index', '10030', 'important');
|
||||
chip.style.setProperty('position', 'fixed', 'important');
|
||||
chip.style.setProperty('left', `${chipStartLeft}px`, 'important');
|
||||
chip.style.setProperty('top', `${chipStartTop}px`, 'important');
|
||||
@@ -836,7 +849,7 @@ function _wireChipDrag(chip, dock) {
|
||||
if (dragMode === 'reorder') {
|
||||
chip.style.transition = 'none';
|
||||
chip.style.transform = `translate(${dx}px, ${dy}px) scale(1.05)`;
|
||||
chip.style.zIndex = '1000';
|
||||
chip.style.zIndex = '10030';
|
||||
|
||||
// Find sibling under cursor and swap
|
||||
const siblings = [...dock.querySelectorAll('.minimized-dock-chip:not(.dragging)')];
|
||||
@@ -1214,7 +1227,9 @@ export function minimize(id) {
|
||||
// If this window is edge-docked (right/left), SUSPEND the dock: release
|
||||
// the body push so the chat returns to full width while the window is
|
||||
// minimized, but keep the dock so restoring the chip snaps it back in.
|
||||
if (modal.classList.contains('modal-right-docked') || modal.classList.contains('modal-left-docked')) {
|
||||
if (modal.classList.contains('modal-right-docked')
|
||||
|| modal.classList.contains('modal-left-docked')
|
||||
|| modal.classList.contains('email-snap-left')) {
|
||||
try { suspendDock(modal); } catch (e) { console.warn('suspendDock on minimize failed', e); }
|
||||
}
|
||||
modal.classList.add('hidden');
|
||||
@@ -1453,6 +1468,24 @@ const _SWIPE_DOWN_MINIMIZES = new Set([
|
||||
// (per-email reader tabs) survive swipe-down too.
|
||||
const _SWIPE_DOWN_MINIMIZES_PREFIX = ['email-reader-'];
|
||||
|
||||
function _clearEmailSplitAfterMinimize() {
|
||||
document.body.classList.remove('email-doc-split-active', 'email-front');
|
||||
document.documentElement.style.removeProperty('--email-doc-split-left-x');
|
||||
document.documentElement.style.removeProperty('--email-doc-split-email-w');
|
||||
document.documentElement.style.removeProperty('--email-doc-split-right-x');
|
||||
const docPane = document.getElementById('doc-editor-pane');
|
||||
if (docPane) {
|
||||
[
|
||||
'position', 'left', 'right', 'top', 'bottom', 'width', 'max-width',
|
||||
'height', 'z-index', 'transform',
|
||||
].forEach(prop => docPane.style.removeProperty(prop));
|
||||
}
|
||||
const divider = document.getElementById('doc-divider');
|
||||
if (divider) divider.style.display = '';
|
||||
requestAnimationFrame(() => window.dispatchEvent(new Event('resize')));
|
||||
setTimeout(() => window.dispatchEvent(new Event('resize')), 80);
|
||||
}
|
||||
|
||||
// Re-route swipe-dismiss to minimize-rather-than-close — but only for the
|
||||
// allowlisted tools above. For every other modal, return early so the
|
||||
// default close handler runs and the modal goes away.
|
||||
@@ -1479,7 +1512,16 @@ window.addEventListener('modal-dismissed', (e) => {
|
||||
s.isMinimized = true;
|
||||
_setBadge(s.btnIds, true);
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) modal.classList.add('modal-minimized');
|
||||
if (modal) {
|
||||
const isEmailModal = id === 'email-lib-modal' || id.startsWith('email-reader-');
|
||||
if (modal.classList.contains('modal-right-docked')
|
||||
|| modal.classList.contains('modal-left-docked')
|
||||
|| modal.classList.contains('email-snap-left')) {
|
||||
try { suspendDock(modal); } catch (err) { console.warn('suspendDock on dismissed failed', err); }
|
||||
}
|
||||
if (isEmailModal) _clearEmailSplitAfterMinimize();
|
||||
modal.classList.add('modal-minimized');
|
||||
}
|
||||
_ensureDock();
|
||||
_renderDock();
|
||||
// Stop legacy listeners that reset internal `_open` state
|
||||
|
||||
+47
-5
@@ -426,11 +426,16 @@ function _applyDockInternal(modal, side, dockClass) {
|
||||
// its padding-right.
|
||||
if (!modal._dockCloseWatcher && typeof MutationObserver !== 'undefined') {
|
||||
const onGone = () => _onDockedModalGone(modal, dockClass);
|
||||
// Watch the modal itself for hidden-class flips and parent removal.
|
||||
const obs = new MutationObserver(() => {
|
||||
if (!modal.isConnected || modal.classList.contains('hidden')) onGone();
|
||||
});
|
||||
obs.observe(modal, { attributes: true, attributeFilter: ['class'] });
|
||||
// Watch the modal for: the `.hidden` class flip, an inline
|
||||
// `display:none` (how the draggable modals — calendar, plan, workspace,
|
||||
// etc. — actually close), and parent removal. Without the `style` filter
|
||||
// a display:none close left the body's dock padding on, so the chat
|
||||
// stayed shifted after the docked modal was closed.
|
||||
const _isGone = () => !modal.isConnected
|
||||
|| modal.classList.contains('hidden')
|
||||
|| modal.style.display === 'none';
|
||||
const obs = new MutationObserver(() => { if (_isGone()) onGone(); });
|
||||
obs.observe(modal, { attributes: true, attributeFilter: ['class', 'style'] });
|
||||
// A second observer catches DOM removal — childList on the parent
|
||||
// is the reliable signal for `.remove()` / `.removeChild()` calls.
|
||||
if (modal.parentNode) {
|
||||
@@ -475,6 +480,25 @@ function _onDockedModalGone(modal, dockClass) {
|
||||
}
|
||||
modal.classList.remove('modal-right-docked');
|
||||
modal.classList.remove('modal-left-docked');
|
||||
// Clear the content's docked inline geometry. Singleton modals (plan,
|
||||
// workspace, calendar, …) reuse the same element across open/close, so if we
|
||||
// only drop the body push the element stays positioned (position:fixed;
|
||||
// right:0; fixed width) on the next open — floating over the chat with no
|
||||
// push. We deliberately do NOT restore the pre-dock snapshot here: that
|
||||
// snapshot is the drag position from when the user pulled the window to the
|
||||
// edge (near the side), so restoring it would reopen the modal off to the
|
||||
// side, still overlapping. Clearing the inline styles lets the modal reopen
|
||||
// at its CSS default (centered). Drag-to-undock still uses clearRightDock,
|
||||
// which DOES restore the snapshot for the peel-off feel.
|
||||
if (_c) {
|
||||
for (const prop of ['position', 'inset', 'left', 'top', 'right', 'bottom',
|
||||
'width', 'maxWidth', 'height', 'maxHeight',
|
||||
'borderRadius', 'transform', 'margin']) {
|
||||
_c.style[prop] = '';
|
||||
}
|
||||
delete _c._preDockSnapshot;
|
||||
delete _c._dockSide;
|
||||
}
|
||||
}
|
||||
|
||||
function _expandSidebarFromRail() {
|
||||
@@ -498,6 +522,9 @@ export function clearRightDock(modal, cx, cy, dockClass) {
|
||||
if (!modal.classList.contains(dockClass)) return;
|
||||
modal.classList.remove(dockClass);
|
||||
clearDockSide(side, modal);
|
||||
if (side === 'left' && !_hasOtherDockedWindow('left', modal)) {
|
||||
_clearEmailDocSplitGeometry();
|
||||
}
|
||||
delete content._dockSide;
|
||||
_disconnectLeftDockObservers(content);
|
||||
const snap = content._preDockSnapshot;
|
||||
@@ -555,8 +582,10 @@ export function suspendDock(modal) {
|
||||
const nodes = _resolveDockNodes(modal);
|
||||
if (!nodes || !nodes.content) return null;
|
||||
const content = nodes.content;
|
||||
const hadEmailSnapLeft = modal.classList.contains('email-snap-left');
|
||||
const side = content._dockSide
|
||||
|| (modal.classList.contains('modal-left-docked') ? 'left'
|
||||
: modal.classList.contains('email-snap-left') ? 'left'
|
||||
: modal.classList.contains('modal-right-docked') ? 'right' : null);
|
||||
if (!side) return null;
|
||||
// Stop the close-watcher from tearing the dock fully down when `.hidden`
|
||||
@@ -568,6 +597,19 @@ export function suspendDock(modal) {
|
||||
}
|
||||
// Release the body push + restore the sidebar so the chat fills the width.
|
||||
clearDockSide(side, modal);
|
||||
if (side === 'left') {
|
||||
_disconnectLeftDockObservers(content);
|
||||
}
|
||||
if (hadEmailSnapLeft) {
|
||||
modal.classList.remove('email-snap-left');
|
||||
_clearEmailDocSplitGeometry();
|
||||
delete content._dockSide;
|
||||
delete content._dockSuspended;
|
||||
return null;
|
||||
}
|
||||
if (side === 'left' && !_hasOtherDockedWindow('left', modal)) {
|
||||
_clearEmailDocSplitGeometry();
|
||||
}
|
||||
if (content._preDockSnapshot?.collapsedSidebar && !_hasAnyOtherDockedWindow(modal)) {
|
||||
_expandSidebarFromRail();
|
||||
}
|
||||
|
||||
+101
-15
@@ -209,6 +209,54 @@ function _initModelPickerDropdown() {
|
||||
return sortModelObjects(result);
|
||||
}
|
||||
|
||||
// ── Provider display names and grouping ──
|
||||
const _PROVIDER_NAMES = {
|
||||
'01-ai': 'Yi', 'abacusai': 'Abacus AI', 'adept': 'Adept',
|
||||
'ai21': 'AI21 Labs', 'ai21labs': 'AI21 Labs', 'aion-labs': 'Aion Labs',
|
||||
'aisingapore': 'AI Singapore', 'allenai': 'Allen AI', 'amazon': 'Amazon',
|
||||
'anthracite-org': 'Anthracite', 'anthropic': 'Anthropic', 'arcee-ai': 'Arcee AI',
|
||||
'baai': 'BAAI', 'baidu': 'Baidu', 'bigcode': 'BigCode',
|
||||
'black-forest-labs': 'Black Forest Labs', 'bytedance': 'ByteDance',
|
||||
'bytedance-seed': 'ByteDance', 'cognitivecomputations': 'Cognitive Computations',
|
||||
'cohere': 'Cohere', 'databricks': 'Databricks', 'deepcogito': 'DeepCogito',
|
||||
'deepseek': 'DeepSeek', 'deepseek-ai': 'DeepSeek', 'essentialai': 'Essential AI',
|
||||
'google': 'Google', 'gryphe': 'Gryphe', 'ibm': 'IBM',
|
||||
'ibm-granite': 'IBM Granite', 'inception': 'Inception',
|
||||
'inclusionai': 'Inclusion AI', 'inflection': 'Inflection',
|
||||
'kwaipilot': 'KwaiPilot', 'liquid': 'Liquid AI', 'mancer': 'Mancer',
|
||||
'meta': 'Llama', 'meta-llama': 'Llama', 'microsoft': 'Microsoft',
|
||||
'minimax': 'MiniMax', 'minimaxai': 'MiniMax', 'mistralai': 'Mistral',
|
||||
'moonshotai': 'Moonshot', 'morph': 'Morph', 'nex-agi': 'Nex AGI',
|
||||
'nousresearch': 'Nous Research', 'nv-mistralai': 'NVIDIA x Mistral',
|
||||
'nvidia': 'NVIDIA', 'openai': 'OpenAI', 'openrouter': 'OpenRouter',
|
||||
'perceptron': 'Perceptron', 'perplexity': 'Perplexity', 'poolside': 'Poolside',
|
||||
'prime-intellect': 'Prime Intellect', 'qwen': 'Qwen', 'rekaai': 'Reka',
|
||||
'relace': 'Relace', 'sao10k': 'Sao10k', 'sarvamai': 'Sarvam AI',
|
||||
'snowflake': 'Snowflake', 'stepfun': 'StepFun', 'stepfun-ai': 'StepFun',
|
||||
'stockmark': 'Stockmark', 'switchpoint': 'SwitchPoint', 'tencent': 'Tencent',
|
||||
'thedrummer': 'TheDrummer', 'undi95': 'Undi95', 'upstage': 'Upstage',
|
||||
'writer': 'Writer', 'x-ai': 'xAI', 'xiaomi': 'Xiaomi',
|
||||
'z-ai': 'Zhipu', 'zyphra': 'Zyphra',
|
||||
'~anthropic': 'Anthropic', '~google': 'Google',
|
||||
'~moonshotai': 'Moonshot', '~openai': 'OpenAI',
|
||||
};
|
||||
const _PROVIDER_ALIAS = {
|
||||
'meta-llama': 'meta', 'deepseek': 'deepseek-ai', 'minimaxai': 'minimax',
|
||||
'stepfun-ai': 'stepfun', 'ai21labs': 'ai21', 'ibm-granite': 'ibm',
|
||||
'bytedance-seed': 'bytedance', '~anthropic': 'anthropic',
|
||||
'~google': 'google', '~moonshotai': 'moonshotai', '~openai': 'openai',
|
||||
};
|
||||
function _providerDisplayName(slug) {
|
||||
return _PROVIDER_NAMES[slug] || slug.charAt(0).toUpperCase() + slug.slice(1).replace(/-/g, ' ');
|
||||
}
|
||||
function _providerSlug(mid) {
|
||||
const slash = mid.indexOf('/');
|
||||
let slug = slash > 0 ? mid.substring(0, slash) : 'other';
|
||||
return _PROVIDER_ALIAS[slug] || slug;
|
||||
}
|
||||
const _collapsedProviders = new Set(_loadList('odysseus-model-collapsed'));
|
||||
let _justExpandedProvider = null;
|
||||
|
||||
function _populate(filter) {
|
||||
listEl.innerHTML = '';
|
||||
const all = _getAllModels();
|
||||
@@ -319,13 +367,11 @@ function _initModelPickerDropdown() {
|
||||
|
||||
// ── Search mode: flat, filtered results across the whole catalog ──
|
||||
if (q) {
|
||||
const matches = all.filter(m =>
|
||||
[
|
||||
m.mid,
|
||||
m.display,
|
||||
m.epName,
|
||||
m.providerText,
|
||||
].filter(Boolean).join(' ').toLowerCase().includes(q));
|
||||
const matches = all.filter(m => {
|
||||
const provName = _providerDisplayName(_providerSlug(m.mid)).toLowerCase();
|
||||
return [m.mid, m.display, m.epName, m.providerText, provName]
|
||||
.filter(Boolean).join(' ').toLowerCase().includes(q);
|
||||
});
|
||||
if (matches.length === 0) _addEmpty('No matching models');
|
||||
else matches.forEach(_addRow);
|
||||
return;
|
||||
@@ -355,14 +401,54 @@ function _initModelPickerDropdown() {
|
||||
if (shown.size) _addSection('All models');
|
||||
rest.forEach(_addRow);
|
||||
}
|
||||
} else if (!recentModels.length && !favModels.length) {
|
||||
// Large catalog, nothing pinned yet — point them at the search box.
|
||||
const hint = document.createElement('div');
|
||||
hint.className = 'model-switch-empty mp-empty-hint';
|
||||
hint.innerHTML =
|
||||
'<span class="mp-empty-title">Search ' + all.length + ' models</span>'
|
||||
+ '<span class="mp-empty-sub">Picks land in Recent · tap the dot to favorite</span>';
|
||||
listEl.appendChild(hint);
|
||||
} else {
|
||||
// Large catalog: show provider groups with collapsible sections.
|
||||
const rest = all.filter(m => !shown.has(m.mid));
|
||||
const groups = new Map();
|
||||
rest.forEach(m => {
|
||||
const slug = _providerSlug(m.mid);
|
||||
if (!groups.has(slug)) groups.set(slug, []);
|
||||
groups.get(slug).push(m);
|
||||
});
|
||||
const sorted = [...groups.keys()].sort((a, b) =>
|
||||
_providerDisplayName(a).localeCompare(_providerDisplayName(b)));
|
||||
|
||||
sorted.forEach(provider => {
|
||||
const models = groups.get(provider);
|
||||
const isCollapsed = _collapsedProviders.has(provider);
|
||||
const header = document.createElement('div');
|
||||
header.className = 'mp-provider-header';
|
||||
header.innerHTML =
|
||||
`<svg class="mp-provider-chevron${isCollapsed ? ' collapsed' : ''}" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`
|
||||
+ `<span class="mp-provider-name">${_providerDisplayName(provider)}</span>`
|
||||
+ `<span class="mp-provider-count">${models.length}</span>`;
|
||||
header.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (_collapsedProviders.has(provider)) {
|
||||
_collapsedProviders.delete(provider);
|
||||
_justExpandedProvider = provider;
|
||||
} else {
|
||||
_collapsedProviders.add(provider);
|
||||
_justExpandedProvider = null;
|
||||
}
|
||||
_saveList('odysseus-model-collapsed', [..._collapsedProviders]);
|
||||
const st = listEl.scrollTop;
|
||||
_populate('');
|
||||
listEl.scrollTop = st;
|
||||
});
|
||||
listEl.appendChild(header);
|
||||
if (!isCollapsed) {
|
||||
const group = document.createElement('div');
|
||||
group.className = 'mp-provider-group' + (_justExpandedProvider === provider ? ' mp-just-expanded' : '');
|
||||
models.forEach(m => {
|
||||
_addRow(m);
|
||||
// Move the just-appended row into the group container
|
||||
group.appendChild(listEl.lastElementChild);
|
||||
});
|
||||
listEl.appendChild(group);
|
||||
if (_justExpandedProvider === provider) _justExpandedProvider = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,12 @@ function _compareText(a, b) {
|
||||
});
|
||||
}
|
||||
|
||||
function _arrayOrEmpty(models) {
|
||||
return Array.isArray(models) ? models : [];
|
||||
}
|
||||
|
||||
export function sortModelIds(models) {
|
||||
return (models || []).slice().sort(_compareText);
|
||||
return _arrayOrEmpty(models).slice().sort(_compareText);
|
||||
}
|
||||
|
||||
export function compareModelObjects(a, b) {
|
||||
@@ -25,5 +29,5 @@ export function compareModelObjects(a, b) {
|
||||
}
|
||||
|
||||
export function sortModelObjects(models) {
|
||||
return (models || []).slice().sort(compareModelObjects);
|
||||
return _arrayOrEmpty(models).slice().sort(compareModelObjects);
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{ "type": "module" }
|
||||
@@ -0,0 +1,47 @@
|
||||
// ============================================
|
||||
// Platform detection + AltGr-keystroke helper
|
||||
// ============================================
|
||||
// Shared by the keybind code: root keyboard-shortcuts.js, the editor's
|
||||
// keyboard-shortcuts.js, and settings.js. Single source of truth so the three
|
||||
// guards can't drift.
|
||||
|
||||
// AltGr (right Alt on AZERTY/QWERTZ and most non-US layouts, used to type
|
||||
// @ # { } [ ] | \ and €) is reported by browsers as Ctrl+Alt. macOS is the
|
||||
// exception: there the Option key — a normal part of Mac shortcuts — also sets
|
||||
// the AltGraph modifier state, so it must NOT be treated as AltGr.
|
||||
//
|
||||
// IS_MAC covers all Apple platforms, iPad/iPhone included: a Magic Keyboard's
|
||||
// Option key sets AltGraph exactly like a Mac's, so they need the same carve-out
|
||||
// — narrowing to macOS-only would re-break them. The name and the
|
||||
// /Mac|iPhone|iPad/ test deliberately mirror the existing isMac checks in
|
||||
// calendar.js and sessions.js; this is their single shared source of truth.
|
||||
export const IS_MAC =
|
||||
/Mac|iPhone|iPad/.test((typeof navigator !== 'undefined' && navigator.platform) || '') ||
|
||||
/Mac/.test((typeof navigator !== 'undefined' && navigator.userAgent) || '');
|
||||
|
||||
// True when `e` is an AltGr keystroke we should ignore for Ctrl+Alt shortcut
|
||||
// purposes. getModifierState('AltGraph') is true for AltGr but false for a
|
||||
// genuine left Ctrl+Alt, so real shortcuts still work. Always false on macOS,
|
||||
// where Option legitimately sets AltGraph.
|
||||
//
|
||||
// We also require ctrlKey+altKey: the collision we defend against is precisely
|
||||
// "AltGr reported AS Ctrl+Alt", so an event that asserts AltGraph WITHOUT
|
||||
// presenting as Ctrl+Alt (a Linux ISO_Level3_Shift layout, a stray modifier
|
||||
// state) is left alone instead of being swallowed.
|
||||
//
|
||||
// Trade-off: on Windows AltGr *is* Ctrl+right-Alt, so a deliberate
|
||||
// Ctrl+Alt+<char> shortcut typed via AltGr is unreachable too — accepted; use
|
||||
// the left Ctrl+Alt.
|
||||
//
|
||||
// NOTE: the AltGr -> AltGraph mapping is taken from the UI Events spec / MDN,
|
||||
// not proven by our tests. Older Firefox and some Linux setups historically did
|
||||
// not report AltGraph; where a browser sets ctrlKey+altKey without it this
|
||||
// guard is simply a no-op (the pre-fix behaviour) rather than a regression.
|
||||
export function isAltGrEvent(e, isMac = IS_MAC) {
|
||||
return (
|
||||
!isMac &&
|
||||
!!e.ctrlKey &&
|
||||
!!e.altKey &&
|
||||
!!(e.getModifierState && e.getModifierState('AltGraph'))
|
||||
);
|
||||
}
|
||||
+30
-12
@@ -8,6 +8,24 @@ let API_BASE = '';
|
||||
let selectedPreset = null;
|
||||
let presets = {};
|
||||
|
||||
export function loadStoredArray(key) {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(key) || '[]');
|
||||
return Array.isArray(value) ? value : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function loadStoredObject(key) {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(key) || '{}');
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Built-in prompt templates (moved from cot_prompts.py)
|
||||
export const PROMPT_TEMPLATES = [
|
||||
{
|
||||
@@ -220,7 +238,7 @@ function initNameDropdown() {
|
||||
if (!charName || charName === '__default__') return;
|
||||
const match = userTemplates.find(t => t.name === charName);
|
||||
const isBuiltin = PROMPT_TEMPLATES.some(t => t.name === charName);
|
||||
if (!await window.styledConfirm(`Delete "${charName}"?\n\nThis will remove the character and all its memories.`, { confirmText: 'Delete', danger: true })) return;
|
||||
if (!await window.styledConfirm(`Delete "${charName}"?\n\nThis will remove the persona and all its memories.`, { confirmText: 'Delete', danger: true })) return;
|
||||
try {
|
||||
// Delete saved template if exists
|
||||
if (match) {
|
||||
@@ -228,7 +246,7 @@ function initNameDropdown() {
|
||||
}
|
||||
// Hide built-in preset
|
||||
if (isBuiltin) {
|
||||
const hidden = JSON.parse(localStorage.getItem('odysseus-hidden-presets') || '[]');
|
||||
const hidden = loadStoredArray('odysseus-hidden-presets');
|
||||
if (!hidden.includes(charName)) hidden.push(charName);
|
||||
localStorage.setItem('odysseus-hidden-presets', JSON.stringify(hidden));
|
||||
}
|
||||
@@ -296,7 +314,7 @@ function _populateCharSelect() {
|
||||
const select = document.getElementById('char-template-select');
|
||||
if (!select) return;
|
||||
const currentVal = select.value;
|
||||
select.innerHTML = '<option value="__default__">Default (no character)</option>';
|
||||
select.innerHTML = '<option value="__default__">Default (no persona)</option>';
|
||||
|
||||
const savedNames = new Set(userTemplates.map(t => t.name));
|
||||
if (userTemplates.length) {
|
||||
@@ -311,7 +329,7 @@ function _populateCharSelect() {
|
||||
select.appendChild(group);
|
||||
}
|
||||
|
||||
const hiddenPresets = JSON.parse(localStorage.getItem('odysseus-hidden-presets') || '[]');
|
||||
const hiddenPresets = loadStoredArray('odysseus-hidden-presets');
|
||||
const builtins = PROMPT_TEMPLATES.filter(t => !savedNames.has(t.name) && !hiddenPresets.includes(t.name));
|
||||
if (builtins.length) {
|
||||
const group = document.createElement('optgroup');
|
||||
@@ -405,7 +423,7 @@ function initPersistentChat() {
|
||||
await fetch(`${API_BASE}/api/session/${sessionId}/important`, { method: 'POST', body: favFd });
|
||||
|
||||
// Save session → character mapping so it restores on switch
|
||||
const charSessions = JSON.parse(localStorage.getItem('odysseus-char-sessions') || '{}');
|
||||
const charSessions = loadStoredObject('odysseus-char-sessions');
|
||||
charSessions[sessionId] = charName;
|
||||
localStorage.setItem('odysseus-char-sessions', JSON.stringify(charSessions));
|
||||
|
||||
@@ -437,7 +455,7 @@ function initSaveAsTemplate() {
|
||||
|
||||
let name = nameInput ? nameInput.value.trim() : '';
|
||||
if (!name) {
|
||||
name = prompt('Enter a name for this character:');
|
||||
name = prompt('Enter a name for this persona:');
|
||||
if (!name || !name.trim()) return;
|
||||
name = name.trim();
|
||||
if (nameInput) nameInput.value = name;
|
||||
@@ -616,7 +634,7 @@ export function openCustomPresetModal() {
|
||||
} else {
|
||||
// Character/persona tab. "Save & " prefix when the user edited a template,
|
||||
// so it's clear the edit is being saved on start.
|
||||
label = changed ? 'Save & Start Character' : 'Start Character';
|
||||
label = changed ? 'Save & Start Persona' : 'Start Persona';
|
||||
}
|
||||
btn.textContent = label;
|
||||
// Show a "Cancel" button next to Start when the active tab's feature is
|
||||
@@ -708,7 +726,7 @@ export function openCustomPresetModal() {
|
||||
const notice = document.createElement('div');
|
||||
notice.id = 'char-lock-notice';
|
||||
notice.style.cssText = 'font-size:11px;color:var(--color-muted);text-align:center;padding:6px;margin-bottom:8px;border:1px dashed var(--border);border-radius:6px;';
|
||||
notice.textContent = 'Persistent chat — character is locked. Style, temperature, and memory can still be changed.';
|
||||
notice.textContent = 'Persistent chat — persona is locked. Style, temperature, and memory can still be changed.';
|
||||
modal.querySelector('.modal-body').prepend(notice);
|
||||
}
|
||||
} else {
|
||||
@@ -825,7 +843,7 @@ export async function saveCustomPreset(showToast, showError) {
|
||||
|
||||
if (showToast) {
|
||||
// The Inject tab is a plain tuned "prompt" chat, not a persona — say so.
|
||||
showToast(_isInjectStart ? 'Prompt saved' : 'Character saved');
|
||||
showToast(_isInjectStart ? 'Prompt saved' : 'Persona saved');
|
||||
}
|
||||
const modal = document.getElementById('custom-preset-modal');
|
||||
if (modal) {
|
||||
@@ -962,7 +980,7 @@ function _syncCharIndicator() {
|
||||
if (hasChar) {
|
||||
if (iconEl) iconEl.innerHTML = _AVATAR;
|
||||
if (nameSpan) nameSpan.textContent = custom.character_name;
|
||||
btn.title = `Character: ${custom.character_name} — click to configure`;
|
||||
btn.title = `Persona: ${custom.character_name} — click to configure`;
|
||||
} else {
|
||||
// Inject/tuning chat — syringe tag labeled "Prompt" to match the
|
||||
// window identity, no persona name.
|
||||
@@ -1011,7 +1029,7 @@ function _syncCharIndicator() {
|
||||
let _prevSessionId = null;
|
||||
|
||||
export function onSessionSwitch(sessionId) {
|
||||
const charSessions = JSON.parse(localStorage.getItem('odysseus-char-sessions') || '{}');
|
||||
const charSessions = loadStoredObject('odysseus-char-sessions');
|
||||
|
||||
// Leaving a persistent chat — deactivate for this switch only
|
||||
if (window._persistentChatSession) {
|
||||
@@ -1059,7 +1077,7 @@ export function isPersistentChat() {
|
||||
* Remove a session from persistent chat mappings (call when session is deleted).
|
||||
*/
|
||||
export function removePersistentChat(sessionId) {
|
||||
const charSessions = JSON.parse(localStorage.getItem('odysseus-char-sessions') || '{}');
|
||||
const charSessions = loadStoredObject('odysseus-char-sessions');
|
||||
if (charSessions[sessionId]) {
|
||||
delete charSessions[sessionId];
|
||||
localStorage.setItem('odysseus-char-sessions', JSON.stringify(charSessions));
|
||||
|
||||
@@ -32,8 +32,8 @@ const _PROVIDERS = [
|
||||
[/meta|llama(?![.\-_ ]?cpp)/i,
|
||||
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6.915 4.03c-1.968 0-3.683 1.28-4.871 3.113C.704 9.208 0 11.883 0 14.449c0 .706.07 1.369.21 1.973a6.624 6.624 0 0 0 .265.86 5.297 5.297 0 0 0 .371.761c.696 1.159 1.818 1.927 3.593 1.927 1.497 0 2.633-.671 3.965-2.444.76-1.012 1.144-1.626 2.663-4.32l.756-1.339.186-.325c.061.1.121.196.183.3l2.152 3.595c.724 1.21 1.665 2.556 2.47 3.314 1.046.987 1.992 1.22 3.06 1.22 1.075 0 1.876-.355 2.455-.843a3.743 3.743 0 0 0 .81-.973c.542-.939.861-2.127.861-3.745 0-2.72-.681-5.357-2.084-7.45-1.282-1.912-2.957-2.93-4.716-2.93-1.047 0-2.088.467-3.053 1.308-.652.57-1.257 1.29-1.82 2.05-.69-.875-1.335-1.547-1.958-2.056-1.182-.966-2.315-1.303-3.454-1.303zm10.16 2.053c1.147 0 2.188.758 2.992 1.999 1.132 1.748 1.647 4.195 1.647 6.4 0 1.548-.368 2.9-1.839 2.9-.58 0-1.027-.23-1.664-1.004-.496-.601-1.343-1.878-2.832-4.358l-.617-1.028a44.908 44.908 0 0 0-1.255-1.98c.07-.109.141-.224.211-.327 1.12-1.667 2.118-2.602 3.358-2.602zm-10.201.553c1.265 0 2.058.791 2.675 1.446.307.327.737.871 1.234 1.579l-1.02 1.566c-.757 1.163-1.882 3.017-2.837 4.338-1.191 1.649-1.81 1.817-2.486 1.817-.524 0-1.038-.237-1.383-.794-.263-.426-.464-1.13-.464-2.046 0-2.221.63-4.535 1.66-6.088.454-.687.964-1.226 1.533-1.533a2.264 2.264 0 0 1 1.088-.285z"/></svg>'],
|
||||
|
||||
// Mistral AI (official Simple Icons)
|
||||
[/mistral/i,
|
||||
// Mistral AI (official Simple Icons). Match Mixtral and Ministral too.
|
||||
[/mi[sx]tral|ministral/i,
|
||||
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.143 3.429v3.428h-3.429v3.429h-3.428V6.857H6.857V3.43H3.43v13.714H0v3.428h10.286v-3.428H6.857v-3.429h3.429v3.429h3.429v-3.429h3.428v3.429h-3.428v3.428H24v-3.428h-3.43V3.429z"/></svg>'],
|
||||
|
||||
// Qwen (Tongyi Qianwen) — official geometric hexagonal logo
|
||||
|
||||
+59
-10
@@ -78,6 +78,42 @@ function _deselectCurrentSession(sid) {
|
||||
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
|
||||
}
|
||||
|
||||
function _removeSessionFromLocalState(sid) {
|
||||
if (!sid) return;
|
||||
const id = String(sid);
|
||||
sessions = sessions.filter(s => String(s.id) !== id);
|
||||
_selectedIds.delete(id);
|
||||
try {
|
||||
const savedOrder = Storage.get('session-order');
|
||||
if (savedOrder) {
|
||||
const orderIds = JSON.parse(savedOrder);
|
||||
if (Array.isArray(orderIds) && orderIds.some(x => String(x) === id)) {
|
||||
Storage.set('session-order', JSON.stringify(orderIds.filter(x => String(x) !== id)));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to prune deleted session order:', e);
|
||||
}
|
||||
document.querySelectorAll('.list-item[data-session-id]').forEach(el => {
|
||||
if (String(el.dataset.sessionId) === id) el.remove();
|
||||
});
|
||||
_deselectCurrentSession(id);
|
||||
}
|
||||
|
||||
function _normalizeSessionsList(fetched) {
|
||||
if (!Array.isArray(fetched)) return [];
|
||||
const seen = new Set();
|
||||
const unique = [];
|
||||
for (const session of fetched) {
|
||||
if (!session || session.id == null) continue;
|
||||
const id = String(session.id);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
unique.push(session);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
// Initialize dependencies from app.js (no-op: dependencies now imported directly)
|
||||
export function initDependencies() {}
|
||||
|
||||
@@ -616,15 +652,17 @@ function createSessionItem(s) {
|
||||
return;
|
||||
}
|
||||
dropdown.style.display = 'none';
|
||||
// Optimistic: remove from UI immediately
|
||||
const sessionEl = document.querySelector(`.list-item[data-session-id="${s.id}"]`);
|
||||
if (sessionEl) sessionEl.remove();
|
||||
if (!await uiModule.styledConfirm('Delete this session?', { confirmText: 'Delete', danger: true })) {
|
||||
_forceSidebarOpen();
|
||||
return;
|
||||
}
|
||||
const wasCurrentSession = currentSessionId === s.id;
|
||||
// If streaming, abort it before deleting
|
||||
if (wasCurrentSession && window.chatModule && window.chatModule.abortCurrentRequest) {
|
||||
window.chatModule.abortCurrentRequest();
|
||||
}
|
||||
_deselectCurrentSession(s.id);
|
||||
_removeSessionFromLocalState(s.id);
|
||||
_skipAutoSelect = true;
|
||||
// Clean up persistent chat mapping
|
||||
try {
|
||||
@@ -640,10 +678,11 @@ function createSessionItem(s) {
|
||||
} else {
|
||||
_forceSidebarOpen();
|
||||
}
|
||||
// Fire API and reload in background
|
||||
fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' })
|
||||
.then(() => loadSessions())
|
||||
.catch(() => loadSessions());
|
||||
// Await API deletion, then reload the authoritative list from the server
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' });
|
||||
} catch (e) { /* network error — session may still exist server-side */ }
|
||||
await loadSessions();
|
||||
});
|
||||
|
||||
archiveItem.addEventListener('click', async () => {
|
||||
@@ -1317,7 +1356,7 @@ export async function loadSessions() {
|
||||
const res = await fetch(`${API_BASE}/api/sessions`);
|
||||
fetched = await res.json();
|
||||
}
|
||||
sessions = fetched;
|
||||
sessions = _normalizeSessionsList(fetched);
|
||||
renderSessionList();
|
||||
|
||||
const sessionsSection = uiModule.el('sessions-section');
|
||||
@@ -1606,7 +1645,15 @@ export async function selectSession(id, { keepSidebar = false } = {}) {
|
||||
} else if (msgHistory.length) {
|
||||
for (const msg of msgHistory) {
|
||||
const meta = msg.metadata ? { ...msg.metadata, _fromHistory: true } : null;
|
||||
let displayContent = typeof msg.content === 'string' ? msg.content : (msg.content ? String(msg.content) : '');
|
||||
let displayContent;
|
||||
if (typeof msg.content === 'string') {
|
||||
displayContent = msg.content;
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
// Multimodal (image/audio attachments): extract text parts, skip binary
|
||||
displayContent = msg.content.filter(p => p.type === 'text').map(p => p.text).join('\n').trim();
|
||||
} else {
|
||||
displayContent = '';
|
||||
}
|
||||
// Clean up doc selection context for display
|
||||
if (msg.role === 'user') {
|
||||
// Hide "Continue where you left off" bubbles
|
||||
@@ -1871,7 +1918,7 @@ export function setCurrentSessionId(id) {
|
||||
}
|
||||
|
||||
// Session list keyboard navigation: arrows to move, Delete to delete
|
||||
function _onSessionListKeydown(e) {
|
||||
async function _onSessionListKeydown(e) {
|
||||
const item = e.target.closest('.list-item[data-session-id]');
|
||||
if (!item) return;
|
||||
|
||||
@@ -1899,6 +1946,8 @@ function _onSessionListKeydown(e) {
|
||||
uiModule.showToast('Unfavorite before deleting');
|
||||
return;
|
||||
}
|
||||
const ok = await uiModule.styledConfirm('Delete this session?', { confirmText: 'Delete', danger: true });
|
||||
if (!ok) return;
|
||||
_sessionListFocused = true;
|
||||
(async () => {
|
||||
await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' });
|
||||
|
||||
+71
-3
@@ -6,6 +6,7 @@ import searchModule from './search.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { clearDockSide } from './modalSnap.js';
|
||||
import { sortModelIds } from './modelSort.js';
|
||||
import { isAltGrEvent } from './platform.js';
|
||||
|
||||
let initialized = false;
|
||||
let modalEl = null;
|
||||
@@ -1074,6 +1075,7 @@ var _searchKeyFields = {
|
||||
async function initSearchSettings() {
|
||||
var provSel = el('set-searchProvider');
|
||||
var countSel = el('set-searchResultCount');
|
||||
var countCustomInput = el('set-searchResultCountCustom');
|
||||
var urlInput = el('set-searchUrl');
|
||||
var urlRow = el('set-searchUrlRow');
|
||||
var keyInput = el('set-searchApiKey');
|
||||
@@ -1105,15 +1107,37 @@ async function initSearchSettings() {
|
||||
loadKeyForProvider(prov);
|
||||
}
|
||||
|
||||
function updateCountDisplay() {
|
||||
var val = _settings.search_result_count || 5;
|
||||
var presets = ['3', '5', '10', '20'];
|
||||
if (presets.includes(String(val))) {
|
||||
countSel.value = String(val);
|
||||
countCustomInput.style.display = 'none';
|
||||
} else {
|
||||
countSel.value = 'custom';
|
||||
countCustomInput.value = Math.max(1, Math.min(100, val));
|
||||
countCustomInput.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
var res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
|
||||
_settings = await res.json();
|
||||
if (_settings.search_provider) provSel.value = _settings.search_provider;
|
||||
if (_settings.search_result_count) countSel.value = String(_settings.search_result_count);
|
||||
updateCountDisplay();
|
||||
if (_settings.search_url) urlInput.value = _settings.search_url;
|
||||
if (_settings.google_pse_cx) cxInput.value = _settings.google_pse_cx;
|
||||
} catch (e) { console.warn('Failed to load search settings', e); }
|
||||
|
||||
countSel.addEventListener('change', function() {
|
||||
if (this.value === 'custom') {
|
||||
countCustomInput.style.display = 'block';
|
||||
countCustomInput.focus();
|
||||
} else {
|
||||
countCustomInput.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
updateVisibility();
|
||||
|
||||
async function refreshStatus() {
|
||||
@@ -1141,9 +1165,20 @@ async function initSearchSettings() {
|
||||
async function saveSearch() {
|
||||
try {
|
||||
var prov = provSel.value;
|
||||
var resultCount;
|
||||
if (countSel.value === 'custom') {
|
||||
var customVal = parseInt(countCustomInput.value, 10);
|
||||
if (isNaN(customVal) || customVal < 1 || customVal > 100) {
|
||||
resultCount = _settings.search_result_count || 5;
|
||||
} else {
|
||||
resultCount = customVal;
|
||||
}
|
||||
} else {
|
||||
resultCount = parseInt(countSel.value, 10);
|
||||
}
|
||||
var payload = {
|
||||
search_provider: prov,
|
||||
search_result_count: parseInt(countSel.value, 10),
|
||||
search_result_count: resultCount,
|
||||
search_url: urlInput.value.trim(),
|
||||
google_pse_cx: cxInput.value.trim(),
|
||||
};
|
||||
@@ -1367,6 +1402,7 @@ async function initResearchSettings() {
|
||||
var tokensInput = el('set-researchMaxTokens');
|
||||
var extractTimeoutInput = el('set-researchExtractTimeout');
|
||||
var extractConcurrencyInput = el('set-researchExtractConcurrency');
|
||||
var runTimeoutInput = el('set-researchRunTimeout');
|
||||
var msg = el('set-researchMsg');
|
||||
var endpoints = [];
|
||||
|
||||
@@ -1389,6 +1425,9 @@ async function initResearchSettings() {
|
||||
if (settings.research_max_tokens) tokensInput.value = settings.research_max_tokens;
|
||||
if (settings.research_extraction_timeout_seconds) extractTimeoutInput.value = settings.research_extraction_timeout_seconds;
|
||||
if (settings.research_extraction_concurrency) extractConcurrencyInput.value = settings.research_extraction_concurrency;
|
||||
if (settings.research_run_timeout_seconds !== undefined && settings.research_run_timeout_seconds !== null) {
|
||||
runTimeoutInput.value = settings.research_run_timeout_seconds;
|
||||
}
|
||||
} catch (e) { console.warn('Failed to load research settings', e); }
|
||||
|
||||
function showStatus() {
|
||||
@@ -1407,6 +1446,12 @@ async function initResearchSettings() {
|
||||
if (extractConcurrencyInput.value) {
|
||||
parts.push('Parallel: ' + extractConcurrencyInput.value);
|
||||
}
|
||||
if (runTimeoutInput.value !== '') {
|
||||
var rtv = parseInt(runTimeoutInput.value, 10);
|
||||
if (!isNaN(rtv)) {
|
||||
parts.push(rtv === 0 ? 'Max time: no limit' : 'Max time: ' + rtv + 's');
|
||||
}
|
||||
}
|
||||
if (parts.length) {
|
||||
msg.textContent = parts.join(' · ');
|
||||
msg.style.color = 'var(--fg)';
|
||||
@@ -1425,9 +1470,16 @@ async function initResearchSettings() {
|
||||
var tv = parseInt(tokensInput.value, 10);
|
||||
if (tv && tv >= 1024) payload.research_max_tokens = tv;
|
||||
var et = parseInt(extractTimeoutInput.value, 10);
|
||||
if (et && et >= 15 && et <= 600) payload.research_extraction_timeout_seconds = et;
|
||||
if (et && et >= 15 && et <= 3600) payload.research_extraction_timeout_seconds = et;
|
||||
var ec = parseInt(extractConcurrencyInput.value, 10);
|
||||
if (ec && ec >= 1 && ec <= 12) payload.research_extraction_concurrency = ec;
|
||||
if (runTimeoutInput.value !== '') {
|
||||
var rt = parseInt(runTimeoutInput.value, 10);
|
||||
// 0 = no limit (disables the hard timeout); otherwise 60s..86400s (24h)
|
||||
if (!isNaN(rt) && (rt === 0 || (rt >= 60 && rt <= 86400))) {
|
||||
payload.research_run_timeout_seconds = rt;
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1446,6 +1498,7 @@ async function initResearchSettings() {
|
||||
tokensInput.addEventListener('change', saveResearch);
|
||||
extractTimeoutInput.addEventListener('change', saveResearch);
|
||||
extractConcurrencyInput.addEventListener('change', saveResearch);
|
||||
runTimeoutInput.addEventListener('change', saveResearch);
|
||||
|
||||
_registerAiEndpointRefresh(function(nextEndpoints) {
|
||||
endpoints = nextEndpoints;
|
||||
@@ -1710,6 +1763,10 @@ function _formatKeyCaps(combo) {
|
||||
}
|
||||
|
||||
function _comboFromEvent(e) {
|
||||
// Drop a stray AltGr keystroke (e.g. AltGr+E to type €) so it isn't recorded
|
||||
// as a bogus ctrl+alt+<char> binding — onKey ignores empty combos. See
|
||||
// platform.js for the macOS carve-out and Windows trade-off.
|
||||
if (isAltGrEvent(e)) return '';
|
||||
const parts = [];
|
||||
if (e.ctrlKey || e.metaKey) parts.push('ctrl');
|
||||
if (e.altKey) parts.push('alt');
|
||||
@@ -2555,6 +2612,7 @@ async function initEmailAccountsSettings() {
|
||||
const _providerOptions = Object.entries(PROVIDERS)
|
||||
.map(([k, v]) => `<option value="${k}">${esc(v.label)}</option>`)
|
||||
.join('');
|
||||
const _smtpSecurity = (acct) => acct?.smtp_security || ((parseInt(acct?.smtp_port || 465) === 587) ? 'starttls' : 'ssl');
|
||||
formEl.innerHTML = `
|
||||
<h3 style="font-size:12px;margin:0 0 8px">${isEdit ? 'Edit Account' : 'New Account'}</h3>
|
||||
<div class="settings-col">
|
||||
@@ -2570,6 +2628,7 @@ async function initEmailAccountsSettings() {
|
||||
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px">SMTP (Sending) <span style="font-weight:normal;opacity:0.7">— optional, leave blank for read-only</span></div>
|
||||
<div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com, smtp.migadu.com. Leave blank to make this account read-only.')}</label><input id="eaf-smtp-host" class="settings-input" value="${esc(a.smtp_host || '')}"></div>
|
||||
<div class="settings-row"><label class="settings-label">Port${_hint('465 for SSL/SMTPS, 587 for STARTTLS. 25 is usually blocked by ISPs.')}</label><input id="eaf-smtp-port" class="settings-input" type="number" value="${esc(a.smtp_port || 465)}" style="max-width:100px"></div>
|
||||
<div class="settings-row"><label class="settings-label">Security${_hint('SSL for port 465, STARTTLS for port 587, or None for local SMTP bridges such as Proton Mail Bridge.')}</label><select id="eaf-smtp-security" class="settings-select"><option value="ssl">SSL</option><option value="starttls">STARTTLS</option><option value="none">None</option></select></div>
|
||||
<div class="settings-row"><label class="settings-label">Same as IMAP${_hint('Use the IMAP username and password for SMTP too (this is right for almost every provider). Turn off to enter separate SMTP credentials.')}</label><label class="admin-switch"><input type="checkbox" id="eaf-smtp-same" ${(!isEdit || (a.smtp_user && a.imap_user && a.smtp_user === a.imap_user)) ? 'checked' : ''}><span class="admin-slider"></span></label></div>
|
||||
<div class="settings-row eaf-smtp-creds"><label class="settings-label">Username${_hint('Usually the same as your IMAP username (your email address).')}</label><input id="eaf-smtp-user" class="settings-input" value="${esc(a.smtp_user || '')}"></div>
|
||||
<div class="settings-row eaf-smtp-creds"><label class="settings-label">Password${_hint('Your SMTP password — often the same as your IMAP password.')}</label><input id="eaf-smtp-pass" class="settings-input" type="password" placeholder="${isEdit && a.has_smtp_password ? '(unchanged)' : ''}"></div>
|
||||
@@ -2596,7 +2655,9 @@ async function initEmailAccountsSettings() {
|
||||
el('eaf-imap-starttls').checked = !!p.imap.starttls;
|
||||
el('eaf-smtp-host').value = p.smtp.host;
|
||||
el('eaf-smtp-port').value = p.smtp.port;
|
||||
el('eaf-smtp-security').value = p.smtp.security || ((parseInt(p.smtp.port || 465) === 587) ? 'starttls' : 'ssl');
|
||||
});
|
||||
el('eaf-smtp-security').value = _smtpSecurity(a);
|
||||
|
||||
// "Same as IMAP" toggle — hide the SMTP creds rows when on. The save
|
||||
// handler copies the IMAP user/password into SMTP at submit time.
|
||||
@@ -2620,6 +2681,7 @@ async function initEmailAccountsSettings() {
|
||||
imap_starttls: el('eaf-imap-starttls').checked,
|
||||
smtp_host: el('eaf-smtp-host').value.trim(),
|
||||
smtp_port: parseInt(el('eaf-smtp-port').value) || 465,
|
||||
smtp_security: el('eaf-smtp-security').value,
|
||||
smtp_user: el('eaf-smtp-user').value.trim(),
|
||||
};
|
||||
if (el('eaf-imap-pass').value) body.imap_password = el('eaf-imap-pass').value;
|
||||
@@ -3642,6 +3704,7 @@ async function initUnifiedIntegrations() {
|
||||
};
|
||||
const _providerOptions = Object.entries(PROVIDERS)
|
||||
.map(([k, v]) => `<option value="${k}">${esc(v.label)}</option>`).join('');
|
||||
const _smtpSecurity = (acct) => acct?.smtp_security || ((parseInt(acct?.smtp_port || 465) === 587) ? 'starttls' : 'ssl');
|
||||
formEl.innerHTML = `
|
||||
<div class="admin-card" style="margin-top:8px">
|
||||
<h2 style="font-size:13px">${isEdit ? 'Edit' : 'Add'} Email Account</h2>
|
||||
@@ -3659,6 +3722,7 @@ async function initUnifiedIntegrations() {
|
||||
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px">SMTP (Sending) <span style="font-weight:normal;opacity:0.7">— optional, leave blank for read-only</span></div>
|
||||
<div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com. Leave blank to make this account read-only.')}</label><input id="uf-smtp-host" class="settings-input" placeholder="smtp.example.com"></div>
|
||||
<div class="settings-row"><label class="settings-label">Port${_hint('465 for SSL/SMTPS, 587 for STARTTLS. 25 is usually blocked by ISPs.')}</label><input id="uf-smtp-port" class="settings-input" type="number" placeholder="465" style="max-width:100px"></div>
|
||||
<div class="settings-row"><label class="settings-label">Security${_hint('SSL for port 465, STARTTLS for port 587, or None for local SMTP bridges such as Proton Mail Bridge.')}</label><select id="uf-smtp-security" class="settings-select"><option value="ssl">SSL</option><option value="starttls">STARTTLS</option><option value="none">None</option></select></div>
|
||||
<div class="settings-row"><label class="settings-label">Same as IMAP${_hint('Use the IMAP username and password for SMTP too (right for almost every provider). Turn off to enter separate SMTP credentials.')}</label><label class="admin-switch" style="margin-left:0"><input type="checkbox" id="uf-smtp-same" checked><span class="admin-slider"></span></label></div>
|
||||
<div class="settings-row uf-smtp-creds"><label class="settings-label">Username${_hint('Usually the same as your IMAP username (your email address).')}</label><input id="uf-smtp-user" class="settings-input"></div>
|
||||
<div class="settings-row uf-smtp-creds"><label class="settings-label">Password${_hint('Your SMTP password — often the same as your IMAP password.')}</label><input id="uf-smtp-pass" class="settings-input" type="password" placeholder="${placeholderPass}"></div>
|
||||
@@ -3785,6 +3849,7 @@ async function initUnifiedIntegrations() {
|
||||
el('uf-imap-starttls').checked = !!p.imap.starttls;
|
||||
el('uf-smtp-host').value = p.smtp.host;
|
||||
el('uf-smtp-port').value = p.smtp.port;
|
||||
el('uf-smtp-security').value = p.smtp.security || ((parseInt(p.smtp.port || 465) === 587) ? 'starttls' : 'ssl');
|
||||
if (p.emailEx) {
|
||||
el('uf-email-from').placeholder = p.emailEx;
|
||||
el('uf-imap-user').placeholder = p.emailEx;
|
||||
@@ -3810,6 +3875,7 @@ async function initUnifiedIntegrations() {
|
||||
el('uf-imap-starttls').checked = existing.imap_starttls !== false;
|
||||
el('uf-smtp-host').value = existing.smtp_host || '';
|
||||
el('uf-smtp-port').value = existing.smtp_port || 465;
|
||||
el('uf-smtp-security').value = _smtpSecurity(existing);
|
||||
el('uf-smtp-user').value = existing.smtp_user || '';
|
||||
el('uf-email-default').checked = !!existing.is_default;
|
||||
// If the saved SMTP user matches the IMAP user, keep the "Same as
|
||||
@@ -3821,6 +3887,7 @@ async function initUnifiedIntegrations() {
|
||||
} else {
|
||||
el('uf-imap-port').value = 993;
|
||||
el('uf-smtp-port').value = 465;
|
||||
el('uf-smtp-security').value = 'ssl';
|
||||
}
|
||||
el('uf-email-cancel').addEventListener('click', () => { formEl.style.display = 'none'; });
|
||||
|
||||
@@ -3856,6 +3923,7 @@ async function initUnifiedIntegrations() {
|
||||
imap_starttls: el('uf-imap-starttls').checked,
|
||||
smtp_host: el('uf-smtp-host').value.trim(),
|
||||
smtp_port: parseInt(el('uf-smtp-port').value) || 465,
|
||||
smtp_security: el('uf-smtp-security').value,
|
||||
smtp_user: el('uf-smtp-user').value.trim(),
|
||||
is_default: el('uf-email-default').checked,
|
||||
};
|
||||
|
||||
+7
-4
@@ -7,6 +7,7 @@ import markdownModule from './markdown.js';
|
||||
import * as spinnerModule from './spinner.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { sortModelIds } from './modelSort.js';
|
||||
import { ordinalSuffix } from './util/ordinal.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
let _open = false;
|
||||
@@ -244,7 +245,7 @@ function _scheduleLabel(task) {
|
||||
}
|
||||
if (task.schedule === 'monthly') {
|
||||
const d = task.scheduled_day ?? 1;
|
||||
const suffix = d === 1 ? 'st' : d === 2 ? 'nd' : d === 3 ? 'rd' : 'th';
|
||||
const suffix = ordinalSuffix(d);
|
||||
return `Monthly on ${d}${suffix} at ${localTime}`;
|
||||
}
|
||||
return task.schedule || '—';
|
||||
@@ -2253,8 +2254,9 @@ function _renderActivityEntry(entry) {
|
||||
const hue = _categoryHue(entry.taskName, entry.kind);
|
||||
// CSS vars feed the colored title + accent stripe.
|
||||
const styleVars = `--cat-hue:${hue};`;
|
||||
const _runningPlaceholder = /^(Starting…|Starting\.\.\.|_Running…_|_Running\.\.\._|_Queued\b)/i.test((entry.result || '').trim());
|
||||
const hasResult = !!(entry.result && entry.result.trim() && entry.status !== 'running' && entry.status !== 'queued');
|
||||
const hasRunningProgress = !!(entry.result && entry.result.trim() && (entry.status === 'running' || entry.status === 'queued'));
|
||||
const hasRunningProgress = !!(entry.result && entry.result.trim() && !_runningPlaceholder && (entry.status === 'running' || entry.status === 'queued'));
|
||||
// "Open in chat" only makes sense for runs whose result is a real assistant
|
||||
// message (Prompt / Research tasks). Action/event runs are just log lines
|
||||
// (e.g. "No recent emails", "Tidied N memories") — for those, replace the
|
||||
@@ -2299,11 +2301,12 @@ function _renderActivityEntry(entry) {
|
||||
let rightHtml;
|
||||
if (_isRunning) {
|
||||
const isQueued = entry.status === 'queued';
|
||||
const label = isQueued ? 'Queued' : 'Running';
|
||||
// Initial elapsed for the first paint; the 1s interval below keeps it live.
|
||||
const startMs = entry.ts ? new Date(entry.ts).getTime() : Date.now();
|
||||
const stale = !isQueued && (Date.now() - startMs) > 30 * 60 * 1000;
|
||||
const label = isQueued ? 'Queued' : stale ? 'Still running' : 'Running';
|
||||
const elapsedInit = isQueued ? '' : `<span class="task-log-running-elapsed" data-since="${startMs}">${_fmtElapsed(Date.now() - startMs)}</span>`;
|
||||
const forceBtn = isQueued && entry.taskId ? `<button class="task-log-force-run" type="button" title="Start now in parallel, bypassing the queue" style="border:0;background:transparent;box-shadow:none;margin-left:5px;padding:0;width:12px;height:12px;display:inline-flex;align-items:center;justify-content:center;font-size:10px;line-height:1;color:inherit;opacity:.8;"><svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor" style="display:block;"><polygon points="6 4 20 12 6 20 6 4"/></svg></button>` : '';
|
||||
const forceBtn = isQueued && entry.taskId ? `<button class="task-log-force-run" type="button" title="Start now in parallel, bypassing the queue"><svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor"><polygon points="6 4 20 12 6 20 6 4"/></svg><span>Start now</span></button>` : '';
|
||||
const stopBtn = entry.taskId ? `<button class="task-log-stop" type="button" title="Stop this task"><svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="1"/></svg></button>` : '';
|
||||
rightHtml = `<span class="task-log-running-inline"><span class="task-log-running-label">${label}</span>${elapsedInit}<span data-spin-here="1"></span>${forceBtn}${stopBtn}</span>`;
|
||||
} else {
|
||||
|
||||
+8
-9
@@ -4,6 +4,7 @@
|
||||
import Storage from './storage.js';
|
||||
import uiModule from './ui.js';
|
||||
import { initColorPickers, attachColorPicker } from './colorPicker.js';
|
||||
import { hexToRgb } from './color/hex.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { snapModalToZone } from './tileManager.js';
|
||||
|
||||
@@ -128,10 +129,10 @@ function _syncCustomThemesToServer(ct) {
|
||||
|
||||
// --- Syntax color derivation from theme base colors ---
|
||||
function hexToHSL(hex) {
|
||||
hex = hex.replace('#', '');
|
||||
const r = parseInt(hex.substring(0, 2), 16) / 255;
|
||||
const g = parseInt(hex.substring(2, 4), 16) / 255;
|
||||
const b = parseInt(hex.substring(4, 6), 16) / 255;
|
||||
const rgb = hexToRgb(hex) || { r: 0, g: 0, b: 0 };
|
||||
const r = rgb.r / 255;
|
||||
const g = rgb.g / 255;
|
||||
const b = rgb.b / 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
let h, s, l = (max + min) / 2;
|
||||
if (max === min) { h = s = 0; }
|
||||
@@ -1797,8 +1798,7 @@ function _initPerlinFlow() {
|
||||
if (bg !== _cachedBg) {
|
||||
_cachedBg = bg;
|
||||
// Parse hex to rgb for rgba fade
|
||||
const h = bg.replace('#', '');
|
||||
const r = parseInt(h.substring(0, 2), 16), g = parseInt(h.substring(2, 4), 16), b = parseInt(h.substring(4, 6), 16);
|
||||
const { r, g, b } = hexToRgb(bg) || { r: 0, g: 0, b: 0 };
|
||||
_fadeStyle = `rgba(${r},${g},${b},0.02)`;
|
||||
}
|
||||
return _fadeStyle;
|
||||
@@ -1982,9 +1982,8 @@ function _initEmbers() {
|
||||
return s.getPropertyValue('--bg-effect-color').trim() || s.getPropertyValue('--fg').trim() || '#c9a95a';
|
||||
}
|
||||
function rgba(hex, a) {
|
||||
const h = hex.replace('#', '');
|
||||
const n = parseInt(h, 16);
|
||||
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
|
||||
const { r, g, b } = hexToRgb(hex) || { r: 0, g: 0, b: 0 };
|
||||
return `rgba(${r},${g},${b},${a})`;
|
||||
}
|
||||
function draw() {
|
||||
if (!document.body.classList.contains('bg-pattern-embers')) {
|
||||
|
||||
+42
-6
@@ -519,7 +519,20 @@ export function getAutoScroll() {
|
||||
export function autoResize(textarea) {
|
||||
const lineHeight = parseInt(getComputedStyle(textarea).lineHeight);
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const maxHeight = isMobile ? 150 : lineHeight * 8;
|
||||
const autoMaxHeight = isMobile ? 150 : lineHeight * 8;
|
||||
|
||||
// Keep a height chosen with the native desktop resize handle. Automatic
|
||||
// changes are recorded before the observer runs, so only a real drag
|
||||
// updates the manual floor.
|
||||
if (!textarea._manualResizeObserver && typeof ResizeObserver !== 'undefined') {
|
||||
textarea._manualResizeObserver = new ResizeObserver(() => {
|
||||
const height = textarea.offsetHeight;
|
||||
if (Math.abs(height - (textarea._autoResizeHeight || height)) > 1) {
|
||||
textarea._manualResizeHeight = height;
|
||||
}
|
||||
});
|
||||
textarea._manualResizeObserver.observe(textarea);
|
||||
}
|
||||
|
||||
// Use a hidden clone to measure without disrupting the real textarea
|
||||
let clone = textarea._resizeClone;
|
||||
@@ -539,9 +552,12 @@ export function autoResize(textarea) {
|
||||
clone.style.width = textarea.offsetWidth + 'px';
|
||||
clone.value = textarea.value;
|
||||
clone.style.height = '0';
|
||||
const newHeight = Math.min(Math.max(clone.scrollHeight, lineHeight), maxHeight);
|
||||
const manualHeight = textarea._manualResizeHeight || 0;
|
||||
const maxHeight = Math.max(autoMaxHeight, manualHeight);
|
||||
const newHeight = Math.min(Math.max(clone.scrollHeight, lineHeight, manualHeight), maxHeight);
|
||||
textarea._autoResizeHeight = newHeight;
|
||||
textarea.style.height = newHeight + 'px';
|
||||
textarea.style.overflow = newHeight >= maxHeight ? 'auto' : 'hidden';
|
||||
textarea.style.overflow = newHeight >= autoMaxHeight ? 'auto' : 'hidden';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -579,8 +595,8 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
||||
overlay.id = 'styled-confirm-overlay';
|
||||
overlay.className = 'modal';
|
||||
overlay.innerHTML =
|
||||
'<div class="modal-content styled-confirm-box">' +
|
||||
'<div class="modal-header"><h4>Confirm</h4></div>' +
|
||||
'<div class="modal-content styled-confirm-box" role="dialog" aria-modal="true" aria-labelledby="styled-confirm-title" aria-describedby="styled-confirm-msg">' +
|
||||
'<div class="modal-header"><h4 id="styled-confirm-title">Confirm</h4></div>' +
|
||||
'<div class="modal-body"><p id="styled-confirm-msg"></p></div>' +
|
||||
'<div class="modal-footer">' +
|
||||
'<button id="styled-confirm-cancel"></button>' +
|
||||
@@ -600,6 +616,8 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
||||
okBtn.className = danger ? 'confirm-btn confirm-btn-danger' : 'confirm-btn confirm-btn-primary';
|
||||
cancelBtn.className = 'confirm-btn confirm-btn-secondary';
|
||||
|
||||
// Remember what had focus so we can restore it when the dialog closes.
|
||||
const _prevFocus = document.activeElement;
|
||||
overlay.classList.remove('hidden');
|
||||
overlay.style.display = '';
|
||||
|
||||
@@ -610,6 +628,7 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
||||
cancelBtn.removeEventListener('click', onCancel);
|
||||
overlay.removeEventListener('click', onBackdrop);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
try { _prevFocus && _prevFocus.focus && _prevFocus.focus(); } catch {}
|
||||
resolve(result);
|
||||
}
|
||||
function onOk() { cleanup(true); }
|
||||
@@ -626,6 +645,13 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
cleanup(false);
|
||||
} else if (e.key === 'Tab') {
|
||||
// Trap focus inside the dialog so Tab can't wander to the page behind.
|
||||
e.preventDefault();
|
||||
const f = [cancelBtn, okBtn];
|
||||
const i = f.indexOf(document.activeElement);
|
||||
const n = e.shiftKey ? (i <= 0 ? f.length - 1 : i - 1) : (i >= f.length - 1 ? 0 : i + 1);
|
||||
f[n].focus();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,7 +682,7 @@ export function styledPrompt(message, {
|
||||
overlay.id = 'styled-prompt-overlay';
|
||||
overlay.className = 'modal';
|
||||
overlay.innerHTML =
|
||||
'<div class="modal-content styled-confirm-box styled-prompt-box">' +
|
||||
'<div class="modal-content styled-confirm-box styled-prompt-box" role="dialog" aria-modal="true" aria-labelledby="styled-prompt-title" aria-describedby="styled-prompt-msg">' +
|
||||
'<div class="modal-header"><h4 id="styled-prompt-title"></h4></div>' +
|
||||
'<div class="modal-body">' +
|
||||
'<p id="styled-prompt-msg"></p>' +
|
||||
@@ -685,6 +711,8 @@ export function styledPrompt(message, {
|
||||
okBtn.textContent = confirmText;
|
||||
cancelBtn.textContent = cancelText;
|
||||
|
||||
// Remember what had focus so we can restore it when the dialog closes.
|
||||
const _prevFocus = document.activeElement;
|
||||
overlay.classList.remove('hidden');
|
||||
overlay.style.display = '';
|
||||
|
||||
@@ -696,6 +724,7 @@ export function styledPrompt(message, {
|
||||
overlay.removeEventListener('click', onBackdrop);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
input.removeEventListener('keydown', onInputKey);
|
||||
try { _prevFocus && _prevFocus.focus && _prevFocus.focus(); } catch {}
|
||||
resolve(result);
|
||||
}
|
||||
function onOk() { cleanup((input.value || '').trim()); }
|
||||
@@ -707,6 +736,13 @@ export function styledPrompt(message, {
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
cleanup(null);
|
||||
} else if (e.key === 'Tab') {
|
||||
// Trap focus inside the dialog (input → Cancel → OK → input …).
|
||||
e.preventDefault();
|
||||
const f = [input, cancelBtn, okBtn];
|
||||
const i = f.indexOf(document.activeElement);
|
||||
const n = e.shiftKey ? (i <= 0 ? f.length - 1 : i - 1) : (i >= f.length - 1 ? 0 : i + 1);
|
||||
f[n].focus();
|
||||
}
|
||||
}
|
||||
function onInputKey(e) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Pure (browser-free) English ordinal suffix, e.g. 1 -> "st", 21 -> "st",
|
||||
// 22 -> "nd", 23 -> "rd", 11/12/13 -> "th". Extracted so it can be unit-tested.
|
||||
export function ordinalSuffix(n) {
|
||||
const a = Math.abs(Math.trunc(Number(n) || 0));
|
||||
const mod100 = a % 100;
|
||||
if (mod100 >= 11 && mod100 <= 13) return 'th';
|
||||
switch (a % 10) {
|
||||
case 1: return 'st';
|
||||
case 2: return 'nd';
|
||||
case 3: return 'rd';
|
||||
default: return 'th';
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export function makeWindowDraggable(modal, options = {}) {
|
||||
const onExitFullscreen = options.onExitFullscreen || null;
|
||||
const enableFullscreen = options.enableFullscreen !== false && !!onEnterFullscreen;
|
||||
const onDragEnd = options.onDragEnd || null;
|
||||
const onDragStart = options.onDragStart || null;
|
||||
const skipSelector = options.skipSelector || 'button, input, select';
|
||||
const mobileSkip = (typeof options.mobileSkip === 'number') ? options.mobileSkip : 768;
|
||||
const enableTouch = options.enableTouch !== false;
|
||||
@@ -147,7 +148,11 @@ export function makeWindowDraggable(modal, options = {}) {
|
||||
|
||||
const _startDrag = (cx, cy) => {
|
||||
dragging = true;
|
||||
if (modal) modal.classList.add('modal-dragging');
|
||||
const rect = content.getBoundingClientRect();
|
||||
if (onDragStart) {
|
||||
try { onDragStart({ rect, cx, cy }); } catch (_) {}
|
||||
}
|
||||
startX = cx; startY = cy;
|
||||
startLeft = rect.left; startTop = rect.top;
|
||||
// Pin position so the drag follows the cursor instead of fighting a
|
||||
@@ -237,6 +242,7 @@ export function makeWindowDraggable(modal, options = {}) {
|
||||
const _onEnd = (cx, cy) => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
if (modal) modal.classList.remove('modal-dragging');
|
||||
_showSnapHint(false);
|
||||
// Top edge wins over side edges — fullscreen is the more common gesture.
|
||||
if (enableFullscreen && typeof cy === 'number' && cy <= SNAP_PX) {
|
||||
|
||||
@@ -150,6 +150,14 @@
|
||||
color: var(--fg); font-size: 0.95rem; font-family: 'Fira Code', monospace;
|
||||
}
|
||||
input:focus { outline: none; border-color: var(--red); }
|
||||
/* On touch devices keep inputs at >=16px so iOS Safari doesn't zoom the whole
|
||||
page when a field is focused (it auto-zooms any focused input under 16px).
|
||||
This page has its own inline styles, so it doesn't inherit the main app's
|
||||
equivalent rule in static/style.css; mirror it here. !important also lifts
|
||||
the dynamically-inserted 2FA input, which pins font-size:14px inline. */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
input:not(.remember-check) { font-size: 16px !important; }
|
||||
}
|
||||
/* Clear, visible focus ring for keyboard users on every focusable control. */
|
||||
input:focus-visible, a:focus-visible, button:focus-visible {
|
||||
outline: 2px solid var(--red);
|
||||
|
||||
+683
-51
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user