Merge remote-tracking branch 'upstream/dev' into fix/no-scroll-snapping

This commit is contained in:
shdrs
2026-06-09 09:00:10 +08:00
851 changed files with 90788 additions and 9906 deletions
+266 -129
View File
@@ -4,6 +4,7 @@
// ============================================
import Storage from './js/storage.js';
import uiModule from './js/ui.js';
import workspaceModule from './js/workspace.js';
import fileHandlerModule from './js/fileHandler.js';
import modelsModule from './js/models.js';
import ragModule from './js/rag.js';
@@ -13,6 +14,7 @@ import chatModule from './js/chat.js';
import compareModule from './js/compare/index.js';
import documentModule from './js/document.js';
import searchChatModule from './js/search-chat.js';
import { makeWindowDraggable } from './js/windowDrag.js';
import markdownModule from './js/markdown.js';
import chatRenderer from './js/chatRenderer.js';
import sessionModule from './js/sessions.js';
@@ -85,6 +87,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 +305,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]'];
@@ -490,6 +527,22 @@ function initializeEventListeners() {
return;
}
// Calendar owns a few inner Escape layers (settings panel, event form,
// then the calendar modal itself). Let calendar.js handle those instead
// of falling through to unrelated page-level fallbacks like document
// panel minimize.
const calendarModal = document.getElementById('calendar-modal');
if (calendarModal && !calendarModal.classList.contains('hidden') && getComputedStyle(calendarModal).display !== 'none') {
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 = {
@@ -501,7 +554,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') {
@@ -1502,6 +1555,7 @@ function initializeEventListeners() {
const MODE_TOOLS = [
{ btnId: 'web-toggle-btn', checkboxId: 'web-toggle', stateKey: 'web' },
{ btnId: 'bash-toggle-btn', checkboxId: 'bash-toggle', stateKey: 'bash' },
{ btnId: 'plan-toggle-btn', checkboxId: 'plan-toggle', stateKey: 'plan' },
];
function _modeKey(stateKey, mode) { return `${stateKey}_${mode}`; }
@@ -1510,6 +1564,9 @@ function initializeEventListeners() {
const state = loadToggleState();
const key = _modeKey(stateKey, mode);
if (Object.prototype.hasOwnProperty.call(state, key)) return !!state[key];
// Plan mode is opt-in: never default it on, otherwise every agent turn
// would be forced into planning.
if (stateKey === 'plan') return false;
return mode === 'agent'; // default: ON in agent, OFF in chat
}
@@ -1522,6 +1579,7 @@ function initializeEventListeners() {
const TOOL_TOGGLE_TOAST_LABELS = {
web: 'Web search',
bash: 'Shell',
plan: 'Plan mode',
};
function showToolToggleToast(stateKey, active) {
@@ -1533,7 +1591,15 @@ function initializeEventListeners() {
function applyModeToToggles(mode) {
MODE_TOOLS.forEach(({ btnId, checkboxId, stateKey }) => {
const btn = el(btnId);
if (!btn || btn.style.display === 'none') return;
if (!btn) return;
// Hide bash and plan buttons in chat mode
if (mode === 'chat' && (stateKey === 'bash' || stateKey === 'plan')) {
btn.style.display = 'none';
return;
}
// Show buttons in agent mode (or for web toggle in any mode)
btn.style.display = '';
if (btn.style.display === 'none') return;
const on = loadToolPref(stateKey, mode);
btn.classList.toggle('active', on);
if (checkboxId) { const chk = el(checkboxId); if (chk) chk.checked = on; }
@@ -1548,6 +1614,14 @@ function initializeEventListeners() {
const state = loadToggleState();
let currentMode = state.mode || 'chat';
// Immediately hide bash/plan buttons in chat mode on page load
if (currentMode === 'chat') {
const bashBtn = el('bash-toggle-btn');
const planBtn = el('plan-toggle-btn');
if (bashBtn) bashBtn.style.display = 'none';
if (planBtn) planBtn.style.display = 'none';
}
function setMode(mode) {
currentMode = mode;
const st = loadToggleState();
@@ -1555,6 +1629,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');
@@ -1612,11 +1688,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);
@@ -1631,6 +1709,82 @@ function initializeEventListeners() {
}
setupToggle('web-toggle-btn', 'web-toggle', 'web');
setupToggle('bash-toggle-btn', 'bash-toggle', 'bash');
try { workspaceModule.initWorkspace(); } catch (_) {}
setupToggle('plan-toggle-btn', 'plan-toggle', 'plan');
// Set plan mode on/off directly (checkbox + button state + saved pref) WITHOUT
// going through the button's click handler — used by the plan menu and by the
// "Approve & Run" flow. Going through .click() would hit the plan-menu
// intercept below (a stored plan re-opens the menu instead of toggling), which
// is exactly the bug that left approved plans stuck in plan mode.
function _setPlanMode(on) {
const btn = el('plan-toggle-btn');
const chk = el('plan-toggle');
const mode = (loadToggleState().mode) || 'chat';
if (chk) chk.checked = !!on;
if (btn) { btn.classList.toggle('active', !!on); btn.setAttribute('aria-pressed', String(!!on)); }
saveToolPref('plan', mode, !!on);
}
window._setPlanMode = _setPlanMode;
// ── Plan-button menu ──
// When a plan exists for this chat, clicking the plan button opens a small
// menu (Show plan / Plan mode on-off) instead of plain-toggling — so the plan
// window can be re-opened and docked at any time while the agent works. With
// no plan, the button behaves as before (one-click toggle).
(function initPlanMenu() {
const planBtn = el('plan-toggle-btn');
if (!planBtn) return;
const _hasPlan = () => { try { return !!(window._getStoredPlan && window._getStoredPlan()); } catch (_) { return false; } };
const _close = () => { const m = document.getElementById('plan-menu'); if (m) m.remove(); };
function _open() {
_close();
const planChk = el('plan-toggle');
const on = !!(planChk && planChk.checked);
const menu = document.createElement('div');
menu.id = 'plan-menu';
menu.className = 'overflow-menu plan-menu';
menu.innerHTML =
'<button type="button" class="overflow-menu-item" data-act="show">'
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>'
+ '<span>Show plan</span></button>'
+ '<button type="button" class="overflow-menu-item" data-act="toggle">'
+ '<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="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'
+ '<span>Plan mode: ' + (on ? 'On' : 'Off') + '</span></button>';
document.body.appendChild(menu);
const r = planBtn.getBoundingClientRect();
menu.style.position = 'fixed';
menu.style.left = Math.round(r.left) + 'px';
menu.style.top = Math.round(r.top - menu.offsetHeight - 6) + 'px';
menu.querySelector('[data-act="show"]').addEventListener('click', () => {
_close();
const txt = window._getStoredPlan ? window._getStoredPlan() : '';
if (txt && window.planWindowModule) window.planWindowModule.openPlanWindow(txt, null);
});
menu.querySelector('[data-act="toggle"]').addEventListener('click', () => {
_close();
_setPlanMode(!on); // flip state directly (no click → no menu re-open)
});
// Dismiss on any outside click (capture so it beats other handlers) / Escape.
setTimeout(() => {
const off = (e) => {
if (!menu.contains(e.target) && e.target !== planBtn) {
_close(); document.removeEventListener('click', off, true); document.removeEventListener('keydown', esc, true);
}
};
const esc = (e) => { if (e.key === 'Escape') { _close(); document.removeEventListener('click', off, true); document.removeEventListener('keydown', esc, true); } };
document.addEventListener('click', off, true);
document.addEventListener('keydown', esc, true);
}, 0);
}
planBtn.addEventListener('click', (e) => {
// With a stored plan, the button opens the menu (Show plan / toggle).
// Without one, it falls through to the normal one-click toggle.
if (_hasPlan()) { e.preventDefault(); e.stopImmediatePropagation(); _open(); }
}, true); // capture phase: intercept before setupToggle's bubble handler
})();
try { workspaceModule.initWorkspace(); } catch (_) {}
// Document editor toggle (special: uses module panel, not a checkbox)
const overflowDocBtn = el('overflow-doc-btn');
@@ -2359,7 +2513,7 @@ function initializeEventListeners() {
};
// Keys hidden by default on first run (no localStorage yet)
const UI_VIS_DEFAULT_OFF = new Set(['models-section', 'rag-toggle-btn']);
const UI_VIS_DEFAULT_OFF = new Set(['models-section', 'rag-toggle-btn', 'text-emojis']);
// Keys that need admin to toggle off (reserved for future use)
const UI_VIS_ADMIN_ONLY = new Set([]);
@@ -2387,11 +2541,9 @@ function initializeEventListeners() {
document.querySelectorAll('.section[draggable]').forEach(el => {
el.setAttribute('draggable', dragEnabled ? 'true' : 'false');
});
// Text-only emojis toggle. Default is ON (the checkbox defaults to
// checked because text-emojis isn't in UI_VIS_DEFAULT_OFF), so treat
// an absent value as enabled — otherwise the toggle looked on at
// startup but the effect only activated after the user flipped it.
applyTextEmojis(state['text-emojis'] !== false);
// Text-only emojis toggle. Default is OFF so model-emitted shortcodes
// like `:blush:` render through the normal monochrome emoji path.
applyTextEmojis(state['text-emojis'] === true);
// Hide thinking sections toggle (show-thinking: checked=show, unchecked=hide)
document.body.classList.toggle('hide-thinking', state['show-thinking'] === false);
}
@@ -2628,82 +2780,38 @@ function initializeEventListeners() {
// Apply saved visibility on load
applyUIVis(loadUIVis());
// Generic draggable for all .modal elements
const _sharedDragModalIds = new Set(['settings-modal']);
try { document.querySelectorAll('.modal').forEach(m => {
if (_sharedDragModalIds.has(m.id)) return;
const content = m.querySelector('.modal-content');
const header = m.querySelector('.modal-header');
if (!content || !header) return;
let dragX, dragY, startLeft, startTop, dragging = false;
// Reset to flex-centered position each time modal opens
new MutationObserver(() => {
if (!m.classList.contains('hidden')) {
content.style.position = '';
content.style.left = '';
content.style.top = '';
content.style.right = '';
content.style.bottom = '';
content.style.margin = '';
}
}).observe(m, { attributes: true, attributeFilter: ['class'] });
function startDrag(clientX, clientY) {
dragging = true;
const rect = content.getBoundingClientRect();
dragX = clientX; dragY = clientY;
startLeft = rect.left; startTop = rect.top;
// Switch to fixed so it can be freely positioned
content.style.position = 'fixed';
content.style.left = startLeft + 'px';
content.style.top = startTop + 'px';
content.style.margin = '0';
}
header.addEventListener('mousedown', (e) => {
if (e.target.closest('.close-btn')) return;
e.preventDefault();
startDrag(e.clientX, e.clientY);
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', stopDrag);
});
function onDrag(e) {
if (!dragging) return;
content.style.left = (startLeft + e.clientX - dragX) + 'px';
content.style.top = (startTop + e.clientY - dragY) + 'px';
}
function stopDrag() {
dragging = false;
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', stopDrag);
}
// Touch drag is desktop-only — on mobile, modals are bottom sheets and
// ui.js handles swipe-down-to-dismiss. Attaching this listener fights
// the swipe-dismiss gesture.
if (window.innerWidth > 768) {
header.addEventListener('touchstart', (e) => {
if (e.target.closest('.close-btn')) return;
const t = e.touches[0];
startDrag(t.clientX, t.clientY);
document.addEventListener('touchmove', onTouchDrag, { passive: false });
document.addEventListener('touchend', stopTouchDrag);
// The only two modals without a per-module makeWindowDraggable call. Wire
// them onto the shared helper, drag-only, to match their old behavior.
try {
['custom-preset-modal', 'rename-session-modal'].forEach((id) => {
const m = document.getElementById(id);
if (!m) return;
const content = m.querySelector('.modal-content');
const header = m.querySelector('.modal-header');
if (!content || !header) return;
makeWindowDraggable(m, {
content, header,
skipSelector: '.close-btn',
enableDock: false,
enableResize: false,
});
}
function onTouchDrag(e) {
if (!dragging) return;
e.preventDefault();
const t = e.touches[0];
content.style.left = (startLeft + t.clientX - dragX) + 'px';
content.style.top = (startTop + t.clientY - dragY) + 'px';
}
function stopTouchDrag() {
dragging = false;
document.removeEventListener('touchmove', onTouchDrag);
document.removeEventListener('touchend', stopTouchDrag);
}
}); } catch(e) { console.error('Modal drag init error:', e); }
// Re-center on open (these persist in the DOM). Guard on the
// hidden→visible edge so it never fires mid-drag.
let wasHidden = m.classList.contains('hidden');
new MutationObserver(() => {
const isHidden = m.classList.contains('hidden');
if (wasHidden && !isHidden) {
content.style.position = '';
content.style.left = '';
content.style.top = '';
content.style.right = '';
content.style.bottom = '';
content.style.margin = '';
}
wasHidden = isHidden;
}).observe(m, { attributes: true, attributeFilter: ['class'] });
});
} catch (e) { console.error('Dialog drag init error:', e); }
})();
// ── Modal minimize → dock ──
@@ -3002,27 +3110,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();
@@ -3067,23 +3155,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();
@@ -3120,10 +3192,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) {
@@ -3150,7 +3219,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();
@@ -3723,7 +3792,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
@@ -3847,7 +3916,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);
+161 -84
View File
@@ -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>
@@ -300,38 +300,47 @@
<input type="file" id="memory-import-file" accept=".txt,.md,.pdf,.csv,.log,.json,.py,.js,.html" hidden />
</div>
<p class="memory-desc doclib-desc" style="margin:4px 0 6px;">
Import a <code>.txt</code>, <code>.md</code>, <code>.pdf</code>, <code>.csv</code>, <code>.log</code>, <code>.json</code>, <code>.py</code>, <code>.js</code>, or <code>.html</code> file &mdash; the AI reads it and suggests candidate memories you can approve. Needs an open chat session (it uses that session's model).
Import a <code>.txt</code>, <code>.md</code>, <code>.pdf</code>, <code>.csv</code>, <code>.log</code>, <code>.json</code>, <code>.py</code>, <code>.js</code>, or <code>.html</code> file &mdash; the AI reads it and suggests candidate memories you can approve.
</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> &mdash; 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>
<select id="new-memory-category" class="memory-edit-cat-select" aria-label="Memory category"></select>
</div>
</div>
<div class="admin-card">
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
<h2 style="margin:0;padding:0;line-height:1;"><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"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Add Skill</h2>
</div>
<p class="memory-desc doclib-desc" style="margin-top:6px;">Create a skill by hand — title, what it solves, and an approach.</p>
<p class="memory-desc doclib-desc" style="margin-top:6px;">Import a skill from GitHub or <a href="https://skills.sh" target="_blank" rel="noopener noreferrer">skills.sh</a> (folder with <code>SKILL.md</code> and optional templates).</p>
<div class="memory-add-row" style="margin-top:6px;margin-bottom:10px;">
<div class="skill-ph-wrap" style="flex:1;min-width:0;">
<input type="url" id="skill-import-url" placeholder=" " class="memory-add-input skill-hint-input" aria-label="Skill import URL" />
<span class="skill-rich-ph"><span class="k">Import URL</span> — e.g. GitHub tree link to a skill folder</span>
</div>
<button type="button" id="skill-import-url-btn" class="theme-io-btn" title="Import skill from URL" style="flex:none;height:28px;font-size:12px;"><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;" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Import</button>
</div>
<p class="memory-desc doclib-desc" style="margin-top:0;">Or 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;">
<button id="add-skill-btn" class="memory-toolbar-btn">Add Skill</button>
<button id="add-skill-btn" class="confirm-btn confirm-btn-primary">Add Skill</button>
</div>
</div>
</div>
@@ -368,7 +377,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 +416,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 +441,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">&#x2716;</button>
<button class="close-btn" id="close-theme-popup" aria-label="Close theme">&#x2716;</button>
</div>
<!-- Theme tabs -->
<div class="admin-tabs" id="theme-tabs">
@@ -464,12 +473,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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</button></div>
</div>
</div>
<div class="theme-adv-toggle" id="theme-adv-toggle">
@@ -479,38 +488,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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</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">&#x21BA;</button></div>
</div>
</div>
<div class="theme-adv-group">
@@ -559,7 +568,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 +576,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>
@@ -697,10 +706,9 @@
<div style="position:relative; display:inline-block; display:flex; gap:4px; align-items:center;">
<button type="button" class="section-header-btn chats-manage-btn" id="chats-library-btn" title="Manage Chats (Library)">
<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="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
<path d="M9 7h6M9 11h4"/>
</svg>
</button>
<button type="button" class="section-header-btn" id="session-sort-btn" title="Sort sessions">
@@ -844,7 +852,7 @@
<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>
<span class="grow">Cookbook</span>
<span id="cookbook-bg-status" style="display:none;font-size:9px;opacity:0.5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-left:6px;flex-shrink:1;min-width:0;position:relative;top:-1px;"></span>
<span id="cookbook-bg-status" style="display:none;font-size:9px;opacity:0.5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-right:12px;flex-shrink:1;min-width:0;position:relative;top:-1px;"></span>
<span class="cookbook-notif-dot" id="cookbook-notif-dot" style="display:none;margin-left:6px;margin-right:4px;position:relative;top:-1px;left:0px;"></span>
</div>
<div class="list-item" id="tool-research-btn">
@@ -922,13 +930,18 @@
</div>
</nav>
<main class="chat-container welcome-active" id="chat-container" role="region" aria-label="Chat area" aria-busy="false">
<main class="chat-container welcome-active" id="chat-container" aria-label="Chat area" aria-busy="false">
<!-- Persistent page heading for assistive tech. Visually hidden so it
never affects layout, but always present inside the main landmark
(the sidebar that shows the visible brand is hidden off-canvas on
mobile) so the page always exposes a single level-1 heading. -->
<h1 class="a11y-visually-hidden">Odysseus</h1>
<div class="chat-top-bar">
<button type="button" class="incognito-indicator" id="incognito-indicator" title="Nobody mode active — click to deactivate" style="display:none;"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg></button>
<div class="chat-meta-overlay"><span id="current-meta">Odysseus Chat</span><span id="current-meta-count" class="chat-meta-count" aria-hidden="true"></span><span id="session-cost-display" class="session-cost-display" style="display:none;"></span><span class="export-dropdown-wrap" id="export-dropdown-wrap"><button type="button" class="export-dl-btn" id="export-dl-btn" title="More"><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 9 12 15 18 9"/></svg></button><div class="export-dropdown-menu" id="export-dropdown-menu"><div class="export-dropdown-item" id="export-rename-btn"><span class="dropdown-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="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></span><span>Rename</span></div><div class="export-dropdown-item" id="export-copy-btn"><span class="dropdown-icon"><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></span><span>Copy Chat</span></div><div class="export-dropdown-item" id="export-pdf-btn"><span class="dropdown-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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 15v-2h2a1.5 1.5 0 0 1 0 3H9z"/></svg></span><span>PDF</span></div><div class="export-dropdown-item" id="export-doc-btn"><span class="dropdown-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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span><span>Save to Documents</span></div></div></span></div> </div>
<div id="welcome-screen">
<div class="welcome-name"><svg class="welcome-boat" viewBox="0 0 32 32"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity="0.6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg>Odysseus</div>
<div class="welcome-sub" id="welcome-sub">Welcome, type /setup to get started.</div>
<div class="welcome-sub" id="welcome-sub">Welcome, <span class="setup-trigger-link" style="color:var(--accent,var(--red));font-weight:600;cursor:pointer;text-decoration:underline;" title="Click to launch setup">type /setup</span> to get started.</div>
<div class="welcome-tip" id="welcome-tip"></div>
<button type="button" class="incognito-btn" id="incognito-btn" title="Enable Nobody mode — no memory, no history saved">
<svg class="eye-open" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -977,7 +990,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">
@@ -989,7 +1002,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>
@@ -1003,7 +1016,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>
@@ -1027,6 +1040,13 @@
<span>RAG</span>
<span class="overflow-active-dot"></span>
</button>
<button type="button" class="overflow-menu-item" id="overflow-workspace-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
</svg>
<span>Workspace</span>
<span class="overflow-active-dot"></span>
</button>
<!-- Inline "deep research mode" toggle removed (superseded by the
Deep Research sidebar / trigger_research). The hidden
#research-toggle checkbox is kept inert so existing JS refs
@@ -1047,17 +1067,29 @@
</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>
</button>
<!-- Workspace indicator (hidden until a folder is set) -->
<button type="button" class="input-icon-btn tool-indicator" title="Workspace — click to clear" id="workspace-indicator-btn" aria-label="Clear workspace" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
<span style="font-size:11px;margin-left:2px;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" id="workspace-indicator-name"></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>
</button>
<!-- Plan mode (investigate read-only, propose a plan to approve) -->
<button type="button" class="input-icon-btn" title="Plan mode — investigate read-only, then propose a plan to approve" id="plan-toggle-btn" data-mode-tool="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
</button>
<!-- RAG toolbar indicator (hidden until active) -->
<button type="button" class="input-icon-btn tool-indicator" title="RAG active — click to deactivate" id="rag-indicator-btn" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -1080,7 +1112,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>
@@ -1095,8 +1127,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>
@@ -1106,21 +1138,22 @@
<!-- Hidden checkboxes for state -->
<input type="checkbox" id="web-toggle" style="display:none;">
<input type="checkbox" id="bash-toggle" style="display:none;">
<input type="checkbox" id="plan-toggle" style="display:none;">
</div>
<form id="chat-form" autocomplete="off" action="javascript:void(0);" style="display:none;"></form>
<!-- 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) -->
@@ -1147,25 +1180,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">&#x21BA; 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>
@@ -1258,7 +1291,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>
@@ -1266,10 +1299,10 @@
<div class="modal-body">
<div style="margin-bottom: 12px;">
<label for="session-name-input" style="display: block; margin-bottom: 6px; font-weight: 500;">Session Name</label>
<input
type="text"
id="session-name-input"
placeholder="Enter session name"
<input
type="text"
id="session-name-input"
placeholder="Enter session name"
style="width: 100%; padding: 8px; border-radius: 4px;"
/>
</div>
@@ -1284,10 +1317,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>
@@ -1295,14 +1328,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">&#x2699;</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">
@@ -1391,7 +1424,7 @@
</div>
<div class="admin-card">
<h2 style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:1px;opacity:0.6;flex-shrink:0"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>Utility Model <span style="font-size:0.72em;opacity:0.55;font-weight:normal;">(Recommended: Local Endpoint)</span></h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">Runs background tasks (compaction, cleanup, auto-naming) on a small/local model instead of your chat model. Leave blank to use the chat model.</div>
<div class="admin-toggle-sub" style="margin-bottom:8px">Runs background tasks (compaction, cleanup, auto-naming, retrieving memories from files) on a small/local model instead of your chat model. Leave blank to use the chat model.</div>
<div class="settings-col">
<div class="settings-row">
<label class="settings-label">Endpoint</label>
@@ -1459,6 +1492,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>
@@ -1470,6 +1507,10 @@
<label class="settings-label">Tool call limit</label>
<input id="set-agentMaxTools" type="text" inputmode="numeric" placeholder="0 = unlimited" class="settings-select" style="width:120px;">
</div>
<div class="settings-row">
<label class="settings-label">Max steps per message</label>
<input id="set-agentMaxRounds" type="text" inputmode="numeric" placeholder="20" class="settings-select" style="width:120px;">
</div>
<div id="set-agentMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div>
</div>
@@ -1598,12 +1639,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>
@@ -1804,7 +1849,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 &amp; system prompt</span></span>
<span class="vis-label">Personas <span class="vis-hint">Persona picker &amp; system prompt</span></span>
<input type="checkbox" checked data-ui-key="preset-mini-btn"><span class="vis-switch"></span>
</label>
</div>
@@ -1874,7 +1919,15 @@
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>Email Accounts</h2>
<div class="settings-row" style="align-items:center;">
<div class="admin-toggle-sub" style="margin:0;flex:1;">Add, edit, delete, and test accounts in Integrations.</div>
<button class="admin-btn-add" id="set-email-open-integrations">Manage in Integrations</button>
<button class="admin-btn-add" id="set-email-open-integrations" style="display:inline-flex;align-items:center;gap:6px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.7"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Manage in Integrations</button>
</div>
</div>
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M9 16l2 2 4-4"/></svg>Email Tasks</h2>
<div class="settings-row" style="align-items:center;">
<div class="admin-toggle-sub" style="margin:0;flex:1;">Manage email background tasks in Tasks.</div>
<button class="admin-btn-add" id="set-email-open-tasks" style="display:inline-flex;align-items:center;gap:6px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="opacity:0.7"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><path d="M9 16l2 2 4-4"/></svg>Open Tasks</button>
</div>
</div>
@@ -1904,6 +1957,7 @@
<option value="browser">Browser notification (default)</option>
<option value="email" id="set-reminder-channel-email-opt">Email</option>
<option value="ntfy" id="set-reminder-channel-ntfy-opt">ntfy</option>
<option value="webhook" id="set-reminder-channel-webhook-opt">Webhook</option>
</select>
</div>
<div id="set-reminder-email-from-row" class="settings-row" style="display:none">
@@ -1918,13 +1972,21 @@
<label class="settings-label">ntfy topic</label>
<input id="set-reminder-ntfy-topic" class="settings-select" type="text" placeholder="reminders" />
</div>
<div id="set-reminder-webhook-intg-row" class="settings-row" style="display:none">
<label class="settings-label">Integration</label>
<select id="set-reminder-webhook-intg" class="settings-select"></select>
</div>
<div id="set-reminder-webhook-template-row" class="settings-row" style="display:none;align-items:flex-start">
<label class="settings-label" style="padding-top:6px">Payload</label>
<textarea id="set-reminder-webhook-template" class="settings-select" rows="3" style="font-family:inherit;resize:vertical;flex:1" placeholder='{"content": "{{title}}: {{message}}"}'></textarea>
</div>
<div id="set-reminder-channel-hint" style="font-size:11px;opacity:0.6;"></div>
<div style="font-size:11px;opacity:0.6;margin-top:4px;">Configure email account, ntfy server, etc. in <a href="#" id="set-reminders-open-integrations" style="color:var(--accent, var(--red));text-decoration:none;font-weight:600;">Integrations</a>.</div>
</div>
</div>
<div class="admin-card">
<h2 style="display:flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:1px;opacity:0.6;flex-shrink:0"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>AI Synthesis<span style="flex:1"></span><label class="admin-switch" title="Use the utility model to write reminder messages"><input type="checkbox" id="set-reminder-llm-toggle"><span class="admin-slider"></span></label></h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">When on, the utility model writes a short, warm one-line reminder for browser, email, AND ntfy reminders instead of just the raw note content.</div>
<div class="admin-toggle-sub" style="margin-bottom:8px">When on, the utility model writes a short, warm one-line reminder for browser, email, ntfy, AND webhook reminders instead of just the raw note content.</div>
</div>
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Public App URL</h2>
@@ -1966,7 +2028,7 @@
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>Add User</h2>
<div class="admin-add-form">
<input id="adm-newUsername" type="text" placeholder="Username (email)">
<input id="adm-newUsername" type="text" placeholder="Username">
<input id="adm-newPassword" type="password" placeholder="Password (min 8)">
<div class="admin-switch-inline" title="Grant full admin access"><label class="admin-switch"><input type="checkbox" id="adm-newIsAdmin"><span class="admin-slider"></span></label> Admin</div>
</div>
@@ -1998,6 +2060,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>
@@ -2043,6 +2108,8 @@
<option value="https://api.anthropic.com" data-logo="anthropic">Anthropic</option>
<option value="https://api.deepseek.com/v1" data-logo="deepseek" selected>DeepSeek</option>
<option value="https://api.openai.com/v1" data-logo="openai">OpenAI</option>
<option value="copilot" data-logo="github" data-auth-flow="copilot">GitHub Copilot</option>
<option value="chatgpt-subscription" data-logo="openai" data-auth-flow="chatgpt-subscription">ChatGPT Subscription</option>
<option value="https://openrouter.ai/api/v1" data-logo="openrouter">OpenRouter</option>
<option value="https://ollama.com/api" data-logo="ollama">Ollama Cloud</option>
<option value="https://api.groq.com/openai/v1" data-logo="groq">Groq</option>
@@ -2052,9 +2119,16 @@
<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://opencode.ai/zen/v1" data-logo="opencode">OpenCode Zen</option>
<option value="https://opencode.ai/zen/go/v1" data-logo="opencode">OpenCode Go</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">
<select id="adm-epKind" style="padding:5px;width:82px;">
<option value="proxy">Proxy</option>
<option value="api">API</option>
</select>
<select id="adm-epType" style="padding:5px;width:80px;">
<option value="llm">LLM</option>
<option value="image">Image</option>
@@ -2064,6 +2138,7 @@
<button class="admin-btn-add" id="adm-epAddBtn" style="width:55px;text-align:center;">Add</button>
</div>
<div id="adm-epApiMsg" class="adm-ep-inline-msg"></div>
<div id="adm-deviceAuthStatus" class="adm-ep-inline-msg"></div>
</div>
</div>
</div>
@@ -2091,12 +2166,12 @@
<!-- ═══ INTEGRATIONS TAB ═══ -->
<div data-settings-panel="integrations" class="hidden">
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Connections</h2>
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>Integrations</h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">All external service connections in one place.</div>
<div id="unified-integrations-list"></div>
<div id="unified-intg-form" style="display:none"></div>
<div style="text-align:center;padding:8px 0;">
<button type="button" class="admin-btn-sm" id="unified-intg-add-btn">+ Add Integration</button>
<button type="button" class="admin-btn-sm" id="unified-intg-add-btn" style="display:inline-flex;align-items:center;gap:6px;">+ Add Integration<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>
</div>
</div>
</div>
@@ -2112,7 +2187,7 @@
<!-- ═══ SYSTEM TAB ═══ -->
<div data-settings-panel="system" class="hidden">
<div class="admin-card">
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>Data Backup</h2>
<div class="admin-toggle-sub" style="margin-bottom:8px">Export or import your user data (memories, presets, settings, skills, preferences) as a JSON file.</div>
@@ -2236,8 +2311,9 @@
<script type="module" src="/static/js/chatRenderer.js"></script>
<script type="module" src="/static/js/codeRunner.js"></script>
<script type="module" src="/static/js/chatStream.js"></script>
<script type="module" src="/static/js/chat.js?v=20260520m"></script>
<script type="module" src="/static/js/chat.js?v=20260604s"></script>
<script type="module" src="/static/js/cookbook.js"></script>
<script src="/static/js/cookbookSchedule.js"></script>
<script type="module" src="/static/js/search-chat.js"></script>
<script type="module" src="/static/js/compare/index.js"></script>
<script type="module" src="/static/js/theme.js"></script>
@@ -2247,6 +2323,7 @@
<script type="module" src="/static/js/assistant.js"></script>
<script type="module" src="/static/app.js"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/js/init.js"></script>
<script type="module" src="/static/js/a11y.js"></script>
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script>
</body>
</html>
+10 -2
View File
@@ -3,6 +3,14 @@
## Purpose
This document describes what each JavaScript module is responsible for.
> **Note:** This file is a partial, historical overview — not a complete authoritative
> inventory. The authoritative module set is the current `static/js/` tree plus the
> scripts loaded by `static/index.html`. As of this writing that tree holds **65 `.js`
> files** across **8 subdirectories** (`calendar/`, `color/`, `compare/`, `editor/`,
> `emailLibrary/`, `markdown/`, `research/`, `util/`), and `static/index.html` loads
> **35** `/static…` script tags. The catalog below covers only the original core
> modules and is not kept in sync with every module.
---
## Core Modules (in static/js/)
@@ -23,7 +31,7 @@ This document describes what each JavaScript module is responsible for.
- Content rendering for message arrays
- Text cleanup (`squashOutsideCode`)
### 3. **session.js**
### 3. **sessions.js**
- Session/chat management
- Create, load, delete, switch sessions
- Session history loading
@@ -54,7 +62,7 @@ This document describes what each JavaScript module is responsible for.
### 7. **models.js**
- Model scanning and display
- Local model discovery (ports 8000-8010)
- Local model discovery (ports 8000-8020)
- Provider management (OpenAI)
- Model selection UI
+165
View File
@@ -0,0 +1,165 @@
// Accessibility enhancements for keyboard + screen-reader users.
//
// Several primary controls in Odysseus are authored as click-only <div>s
// (most notably the whole sidebar navigation: New Chat, Search, Brain,
// Calendar, Compare, Cookbook, Deep Research, Gallery, Library, Notes,
// Tasks, Theme, plus the account row). <div>s are not in the tab order and
// are not announced as buttons, so keyboard and screen-reader users cannot
// reach or operate them.
//
// This module enhances those rows in place — making them focusable
// (tabindex=0), announcing them as buttons when it's safe to do so, and
// activating them with Enter / Space — without changing how they look or
// how they behave for mouse users. The visible focus ring already exists in
// style.css (`.list-item:focus-visible`); it simply never fired because the
// rows were never focusable.
(function () {
'use strict';
// Click-as-button rows we want reachable by keyboard.
var ROW_SELECTOR = ['#sidebar .list-item', '#user-bar-profile'].join(',');
// Native interactive descendants. If a row contains one of these we must
// NOT give the row role="button" — a button inside a button is invalid
// (axe "nested-interactive") and confuses screen readers. Such rows still
// become focusable + Enter/Space-activatable, just without the role.
var NESTED_INTERACTIVE =
'a[href],button,input,select,textarea,[contenteditable="true"],[tabindex]:not([tabindex="-1"])';
function enhanceRow(el) {
if (!el || el.nodeType !== 1 || el.dataset.a11yEnhanced === '1') return;
var tag = el.tagName;
// Leave genuine native controls alone.
if (tag === 'BUTTON' || tag === 'A' || tag === 'INPUT' ||
tag === 'SELECT' || tag === 'TEXTAREA') return;
el.dataset.a11yEnhanced = '1';
if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0');
el.setAttribute('data-a11y-activatable', '1');
if (!el.querySelector(NESTED_INTERACTIVE) && !el.hasAttribute('role')) {
el.setAttribute('role', 'button');
}
// Guarantee an accessible name. Visible text normally supplies it; fall
// back to the title attribute for icon-only rows.
if (!el.getAttribute('aria-label') &&
!(el.textContent || '').trim() &&
el.getAttribute('title')) {
el.setAttribute('aria-label', el.getAttribute('title'));
}
}
function enhanceAll(root) {
(root || document).querySelectorAll(ROW_SELECTOR).forEach(enhanceRow);
}
// ---- Modal dialogs -----------------------------------------------------
// Odysseus modals are plain <div class="modal-content"> boxes. Marking
// them as ARIA dialogs lets screen readers announce them as dialogs and
// exempts their content from the "all content in landmarks" rule. We also
// normalize the modal title to heading level 2 (one below the page <h1>)
// so heading order stays valid no matter which tag the markup uses.
var titleSeq = 0;
// Each modal "kind" is a container selector plus where to find its title
// heading. Standard modals use .modal-content/.modal-header; the docked
// Notes pane uses its own markup.
var MODAL_KINDS = [
{
sel: '.modal-content',
heading: '.modal-header h1, .modal-header h2, .modal-header h3, ' +
'.modal-header h4, .modal-header h5, .modal-header h6'
},
{ sel: '.notes-pane', heading: '.notes-pane-title' }
];
var MODAL_SEL = MODAL_KINDS.map(function (k) { return k.sel; }).join(',');
function enhanceModal(mc, headingSel) {
if (!mc || mc.nodeType !== 1 || mc.dataset.a11yDialog === '1') return;
mc.dataset.a11yDialog = '1';
if (!mc.hasAttribute('role')) mc.setAttribute('role', 'dialog');
if (!mc.hasAttribute('aria-modal')) mc.setAttribute('aria-modal', 'true');
var heading = headingSel && mc.querySelector(headingSel);
if (heading) {
if (!heading.id) heading.id = 'a11y-modal-title-' + (++titleSeq);
if (!mc.hasAttribute('aria-labelledby')) {
mc.setAttribute('aria-labelledby', heading.id);
}
// Modal titles sit one level below the page <h1>; normalize so heading
// order stays valid regardless of the tag the markup happens to use.
if (!heading.hasAttribute('aria-level')) heading.setAttribute('aria-level', '2');
}
}
function enhanceModals(root) {
var scope = root || document;
MODAL_KINDS.forEach(function (k) {
scope.querySelectorAll(k.sel).forEach(function (mc) { enhanceModal(mc, k.heading); });
});
}
function headingSelFor(el) {
for (var i = 0; i < MODAL_KINDS.length; i++) {
if (el.matches(MODAL_KINDS[i].sel)) return MODAL_KINDS[i].heading;
}
return null;
}
// Delegated keyboard activation. We only act when the focused element is
// itself an enhanced row (keydown targets the focused element), so a press
// on a nested native button is left to the browser's own handling.
document.addEventListener('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
var el = e.target;
if (!el || !el.matches || !el.matches('[data-a11y-activatable]')) return;
e.preventDefault(); // Space would otherwise scroll the page
el.click();
});
function init() {
enhanceAll(document);
enhanceModals(document);
// Sidebar content is re-rendered as the user navigates (session lists,
// tool sub-rows, etc.). Watch for new rows and enhance them too.
var sidebar = document.getElementById('sidebar');
if (sidebar && 'MutationObserver' in window) {
new MutationObserver(function (muts) {
for (var i = 0; i < muts.length; i++) {
var added = muts[i].addedNodes;
for (var j = 0; j < added.length; j++) {
var n = added[j];
if (n.nodeType !== 1) continue;
if (n.matches && n.matches(ROW_SELECTOR)) enhanceRow(n);
if (n.querySelectorAll) enhanceAll(n);
}
}
}).observe(sidebar, { childList: true, subtree: true });
}
// Some modals (Notes, Tasks, …) are injected at runtime, usually as
// direct children of <body>. Catch those without paying for a deep
// subtree observer over the whole document.
if ('MutationObserver' in window) {
new MutationObserver(function (muts) {
for (var i = 0; i < muts.length; i++) {
var added = muts[i].addedNodes;
for (var j = 0; j < added.length; j++) {
var n = added[j];
if (n.nodeType !== 1) continue;
if (n.matches && n.matches(MODAL_SEL)) enhanceModal(n, headingSelFor(n));
if (n.querySelector && n.querySelector(MODAL_SEL)) enhanceModals(n);
}
}
}).observe(document.body, { childList: true });
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
+340 -33
View File
@@ -5,6 +5,7 @@ import uiModule from './ui.js';
import settingsModule from './settings.js';
import { providerLogo } from './providers.js';
import { sortModelObjects } from './modelSort.js';
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
let initialized = false;
let modalEl = null;
@@ -87,8 +88,12 @@ async function loadUsers() {
<input type="number" min="0" value="${maxMsg}" data-priv="max_messages_per_day" data-user="${esc(u.username)}" style="width:70px;padding:4px 6px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-size:12px;text-align:center;">
</div>`;
// Allowed models — checkbox list
const allowedSet = new Set((u.privileges && u.privileges.allowed_models) || []);
const allEmpty = allowedSet.size === 0;
const allowedModels = Array.isArray(u.privileges && u.privileges.allowed_models)
? u.privileges.allowed_models
: [];
const allowedSet = new Set(allowedModels);
const modelsRestricted = !!(u.privileges && u.privileges.allowed_models_restricted);
const blockAllModels = !!(u.privileges && u.privileges.block_all_models);
html += `<div style="padding:4px 0;">
<div style="display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:12px;">Allowed models</span>
@@ -97,7 +102,7 @@ async function loadUsers() {
<a href="#" class="priv-models-none" data-user="${esc(u.username)}" style="font-size:10px;opacity:0.5;">None</a>
</div>
</div>
<div style="font-size:10px;opacity:0.4;margin-bottom:4px;">${allEmpty ? 'All models allowed (no restrictions)' : allowedSet.size + ' model(s) allowed'}</div>
<div style="font-size:10px;opacity:0.4;margin-bottom:4px;">${blockAllModels ? 'No models allowed' : (!modelsRestricted ? 'All models allowed (no restrictions)' : (allowedSet.size === 0 ? 'No models allowed' : allowedSet.size + ' model(s) allowed'))}</div>
<div class="priv-models-list" data-user="${esc(u.username)}">
<span style="opacity:0.4;font-size:11px;">Loading models...</span>
</div>
@@ -119,7 +124,7 @@ async function loadUsers() {
// Load models list on first expand
if (!_modelsLoaded && !privPanel.classList.contains('hidden')) {
_modelsLoaded = true;
_loadModelsForUser(u.username, allowedSet, privPanel);
_loadModelsForUser(u.username, allowedSet, modelsRestricted, blockAllModels, privPanel);
}
});
@@ -199,26 +204,32 @@ async function loadUsers() {
} catch (e) { list.innerHTML = '<div class="admin-error">Failed to load users</div>'; }
}
async function _loadModelsForUser(username, allowedSet, privPanel) {
async function _loadModelsForUser(username, allowedSet, modelsRestricted, blockAllModels, privPanel) {
const listEl = privPanel.querySelector(`.priv-models-list[data-user="${username}"]`);
if (!listEl) return;
try {
const res = await fetch('/api/models', { credentials: 'same-origin' });
// Use /api/model-endpoints rather than /api/models — the latter is
// backed by `cached_models`, so endpoints that haven't been probed yet
// (e.g. a freshly-added cloud API like DeepSeek) simply don't show up
// until some other endpoint happens to trigger a cache refresh. The
// endpoints listing always reflects every configured endpoint.
const res = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
const data = await res.json();
const allModels = [];
(data.items || []).forEach(item => {
if (item.offline) return;
(item.models || []).forEach(mid => {
allModels.push({ mid, epName: item.endpoint_name || '', display: mid.split('/').pop() });
(Array.isArray(data) ? data : []).forEach(ep => {
if (!ep.online) return;
(ep.models || []).forEach(mid => {
allModels.push({ mid, epName: ep.name || '', display: mid.split('/').pop() });
});
});
if (!allModels.length) {
listEl.innerHTML = '<span style="opacity:0.4;font-size:11px;">No models available</span>';
return;
}
const allEmpty = allowedSet.size === 0;
let restricted = modelsRestricted;
let blockAll = blockAllModels;
listEl.innerHTML = sortModelObjects(allModels).map(m => {
const checked = allEmpty || allowedSet.has(m.mid) ? 'checked' : '';
const checked = !blockAll && (!restricted || allowedSet.has(m.mid)) ? 'checked' : '';
return `<label>
<input type="checkbox" class="priv-model-cb" data-mid="${esc(m.mid)}" ${checked}>
<span>${esc(m.display)}</span>
@@ -232,14 +243,33 @@ async function _loadModelsForUser(username, allowedSet, privPanel) {
listEl.querySelectorAll('.priv-model-cb').forEach(cb => {
if (cb.checked) checked.push(cb.dataset.mid);
});
// If all are checked, send empty array (= no restrictions)
const value = checked.length === allModels.length ? [] : checked;
// Three distinct states the backend must be able to tell apart:
// - all checked -> no restriction (allowed_models: [], block_all_models: false)
// - none checked -> block everything (allowed_models: [], block_all_models: true)
// - some checked -> allowlist (allowed_models: checked, block_all_models: false)
let value, hintText;
if (checked.length === allModels.length) {
restricted = false;
blockAll = false;
value = [];
hintText = 'All models allowed (no restrictions)';
} else if (checked.length === 0) {
restricted = true;
blockAll = true;
value = [];
hintText = 'No models allowed';
} else {
restricted = true;
blockAll = false;
value = checked;
hintText = value.length + ' model(s) allowed';
}
const hint = privPanel.querySelector('.priv-models-list[data-user]')?.previousElementSibling?.querySelector('div[style*="opacity"]');
if (hint) hint.textContent = value.length === 0 ? 'All models allowed (no restrictions)' : value.length + ' model(s) allowed';
if (hint) hint.textContent = hintText;
fetch(`/api/auth/users/${encodeURIComponent(username)}/privileges`, {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ allowed_models: value }),
body: JSON.stringify({ allowed_models: value, allowed_models_restricted: restricted, block_all_models: blockAll }),
}).catch(() => {});
}
listEl.querySelectorAll('.priv-model-cb').forEach(cb => cb.addEventListener('change', _saveModels));
@@ -371,7 +401,7 @@ async function loadEndpoints() {
const listLegacy = el('adm-epList');
// Refresh model picker so new endpoints show up in chat
if (window.modelsModule && window.modelsModule.refreshModels) {
window.modelsModule.refreshModels(true);
window.modelsModule.refreshModels();
setTimeout(() => {
if (window.sessionModule && window.sessionModule.updateModelPicker) {
window.sessionModule.updateModelPicker();
@@ -411,12 +441,18 @@ async function loadEndpoints() {
? `<span class="admin-badge">${visibleCount}/${totalCount} models enabled</span>`
: '<span class="admin-badge admin-badge-off">offline</span>';
const justAddedClass = (_recentlyAddedEpId && String(ep.id) === _recentlyAddedEpId) ? ' adm-ep-just-added' : '';
const category = ep.category || (_isLocalEndpoint(ep.base_url) ? 'local' : 'api');
const kindLabel = ep.endpoint_kind && ep.endpoint_kind !== 'auto' ? ep.endpoint_kind.toUpperCase() : '';
const keyLabel = ep.has_key
? (ep.api_key_fingerprint ? ` (key ${esc(ep.api_key_fingerprint)})` : ' (key set)')
: '';
return `
<div class="admin-user-row${ep.is_enabled ? '' : ' admin-ep-disabled'}${justAddedClass}" data-adm-ep-id="${ep.id}">
<div style="display:flex;align-items:center;justify-content:space-between;${hasModels ? 'cursor:pointer;' : ''}padding:4px 0;" data-adm-ep-header="${ep.id}">
<div class="admin-user-info" style="flex:1;flex-wrap:wrap;gap:0.3rem;">
<span class="admin-user-name">${esc(ep.name)}</span>
${ep.model_type === 'image' ? '<span class="admin-badge" style="background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--accent);">Image</span>' : ''}
${kindLabel ? `<span class="admin-badge">${esc(kindLabel)}</span>` : ''}
${statusBadge}
${ep.is_enabled ? '' : '<span class="admin-badge admin-badge-off">disabled</span>'}
${hasModels ? '<span style="font-size:10px;opacity:0.4;">Click to manage models</span>' : ''}
@@ -427,7 +463,7 @@ async function loadEndpoints() {
${hasModels ? '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>' : ''}
</div>
</div>
<div class="admin-ep-detail">${esc(ep.base_url)}${_isLocalEndpoint(ep.base_url) ? `<button type="button" class="admin-ep-copy-btn" data-adm-copy-url="${esc(ep.base_url)}" title="Copy URL" aria-label="Copy URL" style="background:none;border:none;padding:0 2px;margin-left:6px;cursor:pointer;color:inherit;opacity:0.45;vertical-align:-2px;line-height:1;"><svg width="11" height="11" 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>` : ''}${ep.has_key ? ' (key set)' : ''}</div>
<div class="admin-ep-detail">${esc(ep.base_url)}${category === 'local' ? `<button type="button" class="admin-ep-copy-btn" data-adm-copy-url="${esc(ep.base_url)}" title="Copy URL" aria-label="Copy URL" style="background:none;border:none;padding:0 2px;margin-left:6px;cursor:pointer;color:inherit;opacity:0.45;vertical-align:-2px;line-height:1;"><svg width="11" height="11" 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>` : ''}${keyLabel}</div>
${hasModels ? `<div class="mcp-tools-panel hidden" data-adm-ep-models-panel="${ep.id}"></div>` : ''}
</div>`;
});
@@ -446,7 +482,7 @@ async function loadEndpoints() {
container.innerHTML = indices.map(i => rowHtml[i]).join('');
};
const localIdx = [], apiIdx = [];
data.forEach((ep, i) => (_isLocalEndpoint(ep.base_url) ? localIdx : apiIdx).push(i));
data.forEach((ep, i) => ((ep.category || (_isLocalEndpoint(ep.base_url) ? 'local' : 'api')) === 'local' ? localIdx : apiIdx).push(i));
// Sort each section: enabled endpoints first, disabled at the bottom.
// Preserve original order within each group via stable sort.
const _sortByEnabled = (a, b) => Number(!!data[b].is_enabled) - Number(!!data[a].is_enabled);
@@ -552,22 +588,48 @@ async function loadEndpoints() {
} catch (_) {}
panel.appendChild(_ld);
const _stopSpin = () => { try { _modelsSpin && _modelsSpin.stop(); } catch (_) {} };
try {
const res = await fetch(`/api/model-endpoints/${epId}/models`, { credentials: 'same-origin' });
const models = await res.json();
_stopSpin();
const _loadingHtml = (label) => `<span style="opacity:0.55;font-size:11px;display:inline-flex;align-items:center;gap:8px;">${esc(label)}</span>`;
const renderModels = (models, warning = '') => {
const sortedModels = sortModelObjects(models);
if (!sortedModels.length) { panel.innerHTML = '<span style="opacity:0.5;font-size:11px;">No models</span>'; return; }
const warningHtml = warning ? `<div class="admin-error" style="font-size:11px;margin:6px 0;">${esc(warning)}</div>` : '';
const attachRefresh = () => {
panel.querySelector(`[data-ep-refresh-models="${epId}"]`)?.addEventListener('click', async (e) => {
e.preventDefault();
panel.innerHTML = _loadingHtml('Refreshing models...');
try {
const res = await fetch(`/api/model-endpoints/${epId}/models?refresh=true&refresh_timeout=60`, { credentials: 'same-origin' });
const refreshWarning = res.headers.get('X-Model-Refresh-Warning') || '';
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const refreshedModels = await res.json();
renderModels(refreshedModels, refreshWarning);
if (refreshWarning && uiModule?.showToast) uiModule.showToast(refreshWarning, 6000);
} catch (_) {
renderModels(sortedModels, 'Model refresh failed; kept cached models.');
}
});
};
if (!sortedModels.length) {
panel.innerHTML = `<div class="mcp-tools-header">
<span>Models</span>
<span style="display:flex;gap:8px;align-items:center;">
<span class="mcp-tools-count">0/0 enabled</span>
<a href="#" data-ep-refresh-models="${epId}">Refresh</a>
</span>
</div>${warningHtml}<span style="opacity:0.5;font-size:11px;">No models</span>`;
attachRefresh();
return;
}
const hiddenSet = new Set(sortedModels.filter(m => m.is_hidden).map(m => m.id));
const showSearch = sortedModels.length >= 8;
panel.innerHTML = `<div class="mcp-tools-header">
<span>Models</span>
<span style="display:flex;gap:8px;align-items:center;">
<span class="mcp-tools-count">${sortedModels.length - hiddenSet.size}/${sortedModels.length} enabled</span>
<a href="#" data-ep-refresh-models="${epId}">Refresh</a>
<a href="#" data-ep-select-all="${epId}">All</a>
<a href="#" data-ep-select-none="${epId}">None</a>
</span>
</div>${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + sortedModels.map(m =>
</div>${warningHtml}${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + sortedModels.map(m =>
`<label title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row">
<input type="checkbox" class="adm-cb-hidden" data-ep-model-id="${esc(m.id)}" ${!m.is_hidden ? 'checked' : ''}>
<span class="adm-check-dot" aria-hidden="true"></span>
@@ -580,6 +642,7 @@ async function loadEndpoints() {
row.style.display = (!needle || row.dataset.search.includes(needle)) ? '' : 'none';
});
};
attachRefresh();
panel.querySelector(`[data-ep-search="${epId}"]`)?.addEventListener('input', (e) => filterRows(e.target.value));
panel.querySelector(`[data-ep-select-all="${epId}"]`)?.addEventListener('click', (e) => {
e.preventDefault();
@@ -598,6 +661,13 @@ async function loadEndpoints() {
panel.querySelectorAll('input[type=checkbox]').forEach(cb => {
cb.addEventListener('change', () => _saveEpModelState(epId, panel));
});
};
try {
const res = await fetch(`/api/model-endpoints/${epId}/models`, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const models = await res.json();
_stopSpin();
renderModels(models);
} catch (e) { _stopSpin(); panel.innerHTML = '<span class="admin-error" style="font-size:11px;">Failed to load models</span>'; }
}
});
@@ -637,6 +707,7 @@ async function _saveEpModelState(epId, panel) {
function initEndpointForm() {
const provider = el('adm-epProvider');
const urlInput = el('adm-epUrl');
const kindSel = el('adm-epKind');
// Custom provider picker — mirrors the (now hidden) <select id="adm-epProvider">
// so the rest of this function (which reads provider.value and dispatches
@@ -645,6 +716,80 @@ function initEndpointForm() {
const pickerBtn = el('adm-provider-btn');
const pickerMenu = el('adm-provider-menu');
const pickerCurrent = picker ? picker.querySelector('.adm-provider-current') : null;
const DEVICE_AUTH_PROVIDER_VALUES = new Set(Object.keys(PROVIDER_DEVICE_FLOWS));
let deviceAuthPolling = false;
function _selectedProviderOption() {
return provider && provider.selectedOptions ? provider.selectedOptions[0] : null;
}
function _selectedDeviceAuthProvider() {
const opt = _selectedProviderOption();
const flow = opt && opt.dataset ? opt.dataset.authFlow : '';
if (flow && DEVICE_AUTH_PROVIDER_VALUES.has(flow)) return flow;
return DEVICE_AUTH_PROVIDER_VALUES.has(provider.value) ? provider.value : '';
}
function _isDeviceAuthSelected() {
return !!_selectedDeviceAuthProvider();
}
function _setApiFormForProvider() {
const deviceAuthProvider = _selectedDeviceAuthProvider();
const deviceAuthConfig = PROVIDER_DEVICE_FLOWS[deviceAuthProvider] || null;
const apiKey = el('adm-epApiKey');
const testBtn = el('adm-epApiTestBtn');
const addBtn = el('adm-epAddBtn');
const status = el('adm-deviceAuthStatus');
const msg = _endpointMsg('api');
if (deviceAuthConfig) {
urlInput.value = '';
urlInput.placeholder = deviceAuthProvider === 'copilot'
? 'GitHub Copilot uses GitHub account sign-in'
: 'ChatGPT Subscription uses OpenAI account sign-in';
urlInput.readOnly = true;
if (apiKey) {
apiKey.value = '';
apiKey.placeholder = 'No API key needed';
apiKey.disabled = true;
}
if (testBtn) {
testBtn.disabled = true;
testBtn.style.opacity = '0.45';
testBtn.style.cursor = 'not-allowed';
}
if (addBtn) {
addBtn.disabled = false;
addBtn.textContent = 'Add';
addBtn.style.width = '55px';
addBtn.style.display = '';
}
if (kindSel) kindSel.value = 'api';
if (msg) {
msg.textContent = '';
msg.className = '';
}
} else {
urlInput.placeholder = 'Base URL or pick provider';
urlInput.readOnly = false;
if (apiKey) {
apiKey.placeholder = 'API key';
apiKey.disabled = false;
}
if (testBtn) {
testBtn.disabled = false;
testBtn.style.opacity = '';
testBtn.style.cursor = '';
}
if (addBtn) {
addBtn.disabled = false;
addBtn.textContent = 'Add';
addBtn.style.width = '55px';
addBtn.style.display = '';
}
if (msg) {
msg.textContent = '';
msg.className = '';
}
if (!deviceAuthPolling && status) status.textContent = '';
}
}
function _renderPickerMenu() {
if (!pickerMenu) return;
pickerMenu.innerHTML = Array.from(provider.options).map(o => {
@@ -686,16 +831,29 @@ function initEndpointForm() {
}
provider.addEventListener('change', () => {
if (_isDeviceAuthSelected()) {
_setApiFormForProvider();
_renderPickerMenu();
_syncPickerCurrent();
return;
}
if (provider.value) urlInput.value = provider.value;
else urlInput.value = '';
if (kindSel) kindSel.value = provider.value ? 'api' : 'proxy';
_setApiFormForProvider();
});
urlInput.addEventListener('input', () => {
if (provider.value && urlInput.value.trim() !== provider.value) {
provider.value = '';
if (kindSel) kindSel.value = 'api';
_renderPickerMenu();
_syncPickerCurrent();
}
});
if (kindSel) kindSel.value = kindSel.value || 'api';
function _apiEndpointKind() {
return (kindSel && kindSel.value) ? kindSel.value : 'api';
}
function _normalizeBaseUrl(raw) {
let u = raw.trim();
// Fix common protocol typos
@@ -722,7 +880,7 @@ function initEndpointForm() {
}
} catch(e) {}
// Ensure /v1 suffix for bare host:port URLs (not cloud providers)
if (!u.includes('api.') && !u.includes('openrouter') && !u.includes('ollama.com') && !u.endsWith('/v1')) {
if (!u.includes('api.') && !u.includes('openrouter') && !u.includes('opencode.ai') && !u.includes('ollama.com') && !u.endsWith('/v1')) {
try {
const parsed = new URL(u);
if (!parsed.pathname || parsed.pathname === '/') {
@@ -770,6 +928,12 @@ function initEndpointForm() {
const apiCancelTestBtn = el('adm-epApiCancelTestBtn');
if (apiTestBtn) {
apiTestBtn.addEventListener('click', async () => {
if (_isDeviceAuthSelected()) {
const msg = _endpointMsg('api');
msg.textContent = '';
msg.className = '';
return;
}
const msg = _endpointMsg('api');
msg.textContent = ''; msg.className = '';
const rawUrl = (urlInput.value || provider.value).trim();
@@ -784,6 +948,8 @@ function initEndpointForm() {
try {
const fd = new FormData();
fd.append('base_url', url);
fd.append('endpoint_kind', _apiEndpointKind());
fd.append('model_refresh_timeout', '30');
if (apiKey) fd.append('api_key', apiKey);
const res = await fetch('/api/model-endpoints/test', {
method: 'POST',
@@ -815,6 +981,11 @@ function initEndpointForm() {
}
el('adm-epAddBtn').addEventListener('click', async () => {
const deviceAuthProvider = _selectedDeviceAuthProvider();
if (deviceAuthProvider) {
await _startProviderDeviceAuth(deviceAuthProvider, el('adm-epAddBtn'));
return;
}
const msg = _endpointMsg('api');
msg.textContent = ''; msg.className = '';
const rawUrl = (urlInput.value || provider.value).trim();
@@ -828,6 +999,10 @@ function initEndpointForm() {
try {
const fd = new FormData();
fd.append('base_url', url);
const endpointKind = _apiEndpointKind();
fd.append('endpoint_kind', endpointKind);
fd.append('model_refresh_mode', endpointKind === 'proxy' ? 'manual' : 'auto');
fd.append('model_refresh_timeout', '30');
if (apiKey) fd.append('api_key', apiKey);
if (provider.value && provider.selectedOptions && provider.selectedOptions[0]) {
fd.append('name', provider.selectedOptions[0].textContent.trim());
@@ -842,6 +1017,7 @@ function initEndpointForm() {
const count = d.models ? d.models.length : 0;
urlInput.value = ''; urlInput.style.display = '';
el('adm-epApiKey').value = ''; provider.value = '';
if (kindSel) kindSel.value = 'proxy';
if (epType) epType.value = 'llm';
if (d.id) _recentlyAddedEpId = String(d.id);
await loadEndpoints();
@@ -861,6 +1037,118 @@ function initEndpointForm() {
btn.disabled = false; btn.textContent = 'Add';
});
async function _startProviderDeviceAuth(providerKey, triggerEl = null) {
if (deviceAuthPolling) return;
const config = PROVIDER_DEVICE_FLOWS[providerKey];
if (!config) return;
const status = el('adm-deviceAuthStatus') || _endpointMsg('api');
if (!status) return;
const triggerText = triggerEl ? triggerEl.textContent : '';
// Render an error with an inline "Try again" (the top button is hidden for
// device-auth providers, so retry lives here). Built with DOM methods, not
// innerHTML. Call reset() first so the deviceAuthPolling guard is cleared.
const showAuthError = (text) => {
status.className = 'admin-error';
status.textContent = text + ' ';
const retry = document.createElement('button');
retry.type = 'button';
retry.className = 'admin-btn-sm';
retry.textContent = 'Try again';
retry.addEventListener('click', () => { _startProviderDeviceAuth(providerKey, triggerEl); });
status.appendChild(retry);
};
const reset = () => {
if (triggerEl) {
triggerEl.disabled = false;
triggerEl.textContent = triggerText || 'Add';
}
deviceAuthPolling = false;
_setApiFormForProvider();
};
status.textContent = '';
status.className = 'adm-ep-inline-msg';
if (triggerEl) {
triggerEl.disabled = true;
triggerEl.textContent = 'Starting...';
}
deviceAuthPolling = true;
_setApiFormForProvider();
status.textContent = `Starting ${config.label} sign-in...`;
try {
const result = await runProviderDeviceFlow(providerKey, {
openWindow: () => {},
onStart: ({ start, authUrl }) => {
if (triggerEl) triggerEl.textContent = 'Waiting...';
status.className = '';
const authLabel = providerKey === 'copilot' ? 'Authorize on GitHub' : 'Authorize with OpenAI';
const waitLabel = providerKey === 'copilot' ? 'Waiting for GitHub authorization...' : 'Waiting for ChatGPT authorization...';
status.innerHTML =
'<div class="adm-copilot-panel">' +
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
'<span>' + esc(waitLabel) + '</span></div>' +
'<div class="adm-copilot-coderow">' +
'<span class="adm-copilot-code-label">Code</span>' +
'<code class="adm-copilot-code">' + esc(start.user_code) + '</code>' +
'<button type="button" class="admin-btn-sm adm-device-auth-copy">Copy</button>' +
'</div>' +
'<a class="admin-btn-add adm-copilot-auth" href="' + encodeURI(authUrl || '') + '" target="_blank" rel="noopener">' + esc(authLabel) + ' ↗</a>' +
'</div>';
const copyBtn = status.querySelector('.adm-device-auth-copy');
if (copyBtn) copyBtn.addEventListener('click', async () => {
const code = start.user_code || '';
let ok = false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(code);
ok = true;
}
} catch (e) {}
if (!ok) {
// navigator.clipboard is unavailable in non-secure contexts (HTTP
// self-host over a LAN IP), so fall back to execCommand('copy').
const ta = document.createElement('textarea');
ta.value = code;
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px;';
document.body.appendChild(ta);
ta.focus();
ta.select();
try { ta.setSelectionRange(0, code.length); } catch (e) {}
try { ok = document.execCommand('copy'); } catch (e) {}
ta.remove();
}
copyBtn.textContent = ok ? 'Copied' : 'Failed';
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500);
});
},
});
if (result.status === 'authorized') {
const endpoint = result.endpoint || {};
const n = ((endpoint && endpoint.models) || []).length;
status.className = 'admin-success';
status.textContent = 'Connected - ' + n + ' ' + config.label + ' model' + (n !== 1 ? 's' : '') + ' available.';
if (endpoint && endpoint.id) _recentlyAddedEpId = String(endpoint.id);
await loadEndpoints();
await _selectAddedModelInChat(endpoint || {});
reset();
return;
}
if (result.status === 'failed') {
reset();
showAuthError('Authorization failed (' + (result.error || 'denied') + ').');
return;
}
if (result.status === 'expired') {
reset();
showAuthError('Authorization expired.');
return;
}
} catch (e) {
reset();
showAuthError(formatDeviceFlowError(e));
}
}
// Local "Add" button — sibling form for self-hosted base URLs.
const localAddBtn = el('adm-epLocalAddBtn');
const localTestBtn = el('adm-epLocalTestBtn');
@@ -871,11 +1159,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 +1185,15 @@ 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);
fd.append('endpoint_kind', 'local');
fd.append('model_refresh_mode', 'auto');
const lt = el('adm-epLocalType');
if (lt) fd.append('model_type', lt.value);
fd.append('skip_probe', 'false');
@@ -905,6 +1201,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 +1265,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
@@ -979,6 +1276,8 @@ function initEndpointForm() {
const base = item.url.replace('/chat/completions', '').replace(/\/$/, '');
const fd = new FormData();
fd.append('base_url', base);
fd.append('endpoint_kind', 'local');
fd.append('model_refresh_mode', 'auto');
fd.append('skip_probe', 'false');
const r = await fetch('/api/model-endpoints', { method: 'POST', body: fd });
if (r.ok) {
@@ -1071,11 +1370,11 @@ const _GOOGLE_OAUTH_HELP = `To get Google OAuth credentials:
const MCP_PRESETS = [
{ name: "Gmail", command: "npx", args: ["-y", "@gongrzhe/server-gmail-autoauth-mcp"], env: { GOOGLE_CLIENT_ID: "", GOOGLE_CLIENT_SECRET: "" },
oauthFile: { dir: "~/.gmail-mcp", filename: "gcp-oauth.keys.json" },
oauthFile: { dir: "gmail", filename: "gcp-oauth.keys.json" },
oauth: {
provider: "google",
keys_file: "~/.gmail-mcp/gcp-oauth.keys.json",
token_file: "~/.gmail-mcp/credentials.json",
keys_file: "gmail/gcp-oauth.keys.json",
token_file: "gmail/credentials.json",
scopes: ["https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/gmail.settings.basic"],
},
help: `Setup:
@@ -1979,14 +2278,22 @@ function initBackup() {
const btn = el('adm-importDataBtn');
btn.disabled = true; btn.textContent = 'Importing...'; msg.textContent = '';
try {
const text = await file.text();
const data = JSON.parse(text);
const text = (await file.text()).replace(/^\uFEFF/, '').trim();
let data;
try {
data = JSON.parse(text);
} catch (e) {
throw new Error('Invalid backup file: ' + e.message);
}
const res = await fetch('/api/import', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const result = await res.json();
const result = await res.json().catch(() => null);
if (!result) {
throw new Error(`Import failed: server returned ${res.status}`);
}
if (res.ok && result.ok) {
msg.textContent = result.message || 'Import successful.'; msg.className = 'admin-success';
} else {
+3 -3
View File
@@ -180,7 +180,7 @@ function _renderSettingsBody(body, data, tzList) {
<div class="assistant-field">
<span style="display:flex;align-items:center;gap:8px;">Personality
<select id="assistant-character-pick" style="font-size:11px;padding:1px 6px;border:1px solid var(--border);border-radius:3px;background:var(--bg);color:var(--fg);max-width:180px;">
<option value="">-- pick from character --</option>
<option value="">-- pick from persona --</option>
</select>
</span>
<textarea id="assistant-personality" rows="6" placeholder="Describe the assistant's personality, tone, and behavior...">${_esc(crew.personality || '')}</textarea>
@@ -293,7 +293,7 @@ function _renderSettingsBody(body, data, tzList) {
allPresets.push(...presetsRaw);
}
const allTemplates = Array.isArray(templates) ? templates : [];
let opts = '<option value="">-- pick from character --</option>';
let opts = '<option value="">-- pick from persona --</option>';
if (allPresets.length) {
opts += '<optgroup label="Presets">';
for (const p of allPresets) {
@@ -304,7 +304,7 @@ function _renderSettingsBody(body, data, tzList) {
opts += '</optgroup>';
}
if (allTemplates.length) {
opts += '<optgroup label="Characters">';
opts += '<optgroup label="Personas">';
for (const t of allTemplates) {
if (!t.system_prompt && !t.personality) continue;
const name = t.character_name || t.name || 'Unnamed';
+155 -18
View File
@@ -7,11 +7,13 @@ import spinnerModule from './spinner.js';
import * as Modals from './modalManager.js';
import { makeWindowDraggable } from './windowDrag.js';
import { attachColorPicker } from './colorPicker.js';
import { bindMenuDismiss } from './escMenuStack.js';
import {
WEEKDAYS, MONTHS, MON_SHORT,
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';
@@ -297,13 +299,40 @@ async function _updateEvent(uid, data) {
}
async function _deleteEvent(uid) {
const backup = _allEvents[uid];
delete _allEvents[uid];
// Multiple "sibling" UIDs may need to vanish optimistically:
// 1. The exact uid the user clicked.
// 2. If the user clicked a RECURRING occurrence (uid contains "::"),
// the server deletes the master + every occurrence — so we strip
// the master uid AND every "master::*" expansion from the
// client-side caches too. Without this, deleting one day of a
// multi-day recurring task only removed THAT day visually; the
// other days kept rendering until the next full refresh.
// 3. If the user clicked the master, strip every "master::*"
// expansion (same prefix scan).
const masterUid = uid.includes('::') ? uid.split('::')[0] : uid;
const backups = {};
const _matches = (k) => k === uid || k === masterUid || k.startsWith(masterUid + '::');
for (const k of Object.keys(_allEvents)) {
if (_matches(k)) {
backups[k] = _allEvents[k];
delete _allEvents[k];
}
}
if (Array.isArray(_events)) {
_events = _events.filter(e => !(e && _matches(e.uid || '')));
}
if (_open) _render();
_updateBadge && _updateBadge();
const isRecurring = uid.includes('::');
fetch(`${API_BASE}/api/calendar/events/${encodeURIComponent(uid)}`, {
method: 'DELETE', credentials: 'same-origin',
}).then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
// 404 = the event was already deleted by another session/device. That's
// exactly the state we want, so treat it as success — don't restore the
// row, otherwise the user can never clear stale cached events that were
// deleted from desktop while mobile was open (and vice versa).
if (!r.ok && r.status !== 404) throw new Error('HTTP ' + r.status);
if (isRecurring) {
_fetchedRanges = [];
localStorage.removeItem(LS_KEY);
@@ -311,7 +340,11 @@ async function _deleteEvent(uid) {
_saveCache && _saveCache();
}
}).catch((e) => {
if (backup) _allEvents[uid] = backup;
// Server rejected — restore every uid we optimistically stripped.
for (const [k, ev] of Object.entries(backups)) {
_allEvents[k] = ev;
if (Array.isArray(_events)) _events.push(ev);
}
if (window.uiModule) window.uiModule.showError('Failed to delete event: ' + (e?.message || 'unknown'));
if (_open) _render();
});
@@ -370,6 +403,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) {
@@ -426,9 +463,10 @@ function _clampDropdown(dropdown, anchorRect) {
}
function _showEventMoreMenu(ev, anchor) {
document.querySelectorAll('.cal-event-dropdown').forEach(d => d.remove());
document.querySelectorAll('.cal-event-dropdown').forEach(d => { if (typeof d._dismiss === 'function') d._dismiss(); else d.remove(); });
const dropdown = document.createElement('div');
dropdown.className = 'cal-event-dropdown';
let closeMenu = () => dropdown.remove();
const rect = anchor.getBoundingClientRect();
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:180px;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:0px;visibility:hidden;`;
@@ -443,12 +481,12 @@ function _showEventMoreMenu(ev, anchor) {
const _editIcon = '<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>';
dropdown.appendChild(_item(_editIcon, 'Edit', () => {
dropdown.remove();
closeMenu();
_showEventForm(ev);
}));
dropdown.appendChild(_item(_trashIcon, 'Delete', async () => {
dropdown.remove();
closeMenu();
const name = ev.summary ? `"${ev.summary}"` : 'this event';
const ok = await uiModule.styledConfirm(`Delete ${name}?`, { confirmText: 'Delete', danger: true });
if (!ok) return;
@@ -459,14 +497,7 @@ function _showEventMoreMenu(ev, anchor) {
dropdown._anchorRect = rect;
_clampDropdown(dropdown, rect);
dropdown.style.visibility = '';
const close = (ev2) => {
if (!dropdown.contains(ev2.target) && ev2.target !== anchor) {
dropdown.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 10);
}
closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (ev2) => !dropdown.contains(ev2.target) && ev2.target !== anchor);}
async function _createEventReminder(ev, dueDate) {
// Store the reminder as an absolute UTC instant (with the Z suffix) so the
@@ -980,7 +1011,39 @@ 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>`;
// Proportional offsets for timed events that span across midnight
// (e.g. 8 PM Mon → 5 AM Tue). Without this, an overnight serve
// window visually fills the ENTIRE next day even when it only
// covers a few hours. All-day events keep the full-day shape.
// Bar visually spans from column (col+startFrac) to (col+span-1+endFrac),
// so a 8 PM→5 AM run shows ~17% of day 1 + ~21% of day 2, not 200%.
let startFrac = 0;
let endFrac = 1;
if (!md.all_day) {
try {
const sIso = md.dtstart || '';
const eIso = md.dtend || '';
const sDate = sIso ? new Date(sIso) : null;
const eDate = eIso ? new Date(eIso) : null;
// First-visible-day fraction (0 = midnight start). Clamp to 0
// when the event started before this row, so the bar still
// starts at the row's left edge.
if (sDate && !isNaN(sDate) && mdStart >= rowStart) {
const midnight = new Date(sDate); midnight.setHours(0, 0, 0, 0);
startFrac = Math.max(0, Math.min(1, (sDate - midnight) / 86400000));
}
if (eDate && !isNaN(eDate) && mdEnd <= rowEnd) {
const midnight = new Date(eDate); midnight.setHours(0, 0, 0, 0);
endFrac = Math.max(0, Math.min(1, (eDate - midnight) / 86400000));
// CalDAV end-times are exclusive: an event ending at exactly
// 00:00 on day N really ended at end-of-day N-1, so endFrac=0
// would visually paint a zero-width slice. Snap to a small
// visible minimum (5% of a day) so the bar still registers.
if (endFrac === 0) endFrac = 1;
}
} catch (_) { startFrac = 0; endFrac = 1; }
}
h += `<div class="cal-multiday" style="--col:${startColInt};--span:${span};--slot:${barSlot};--start-frac:${startFrac.toFixed(4)};--end-frac:${endFrac.toFixed(4)};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>';
@@ -1146,7 +1209,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
@@ -1876,11 +1939,12 @@ function _wireAll(body) {
}
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
const tzOffset = -new Date().getTimezoneOffset();
const res = await fetch(`${API_BASE}/api/calendar/quick-parse`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, tz }),
body: JSON.stringify({ text, tz, tz_offset: tzOffset }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) {
@@ -2687,6 +2751,28 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
<option value="FREQ=YEARLY" ${existing?.rrule === 'FREQ=YEARLY' ? 'selected' : ''}>Yearly</option>
</select>
<textarea id="cal-f-desc" placeholder="Description" class="cal-input" rows="2">${_e(existing?.description || '')}</textarea>
${(() => {
// Cookbook-task back-link. When the description carries a
// "cookbook_task_id: <id>" marker (set by cookbookSchedule.js
// when the user ticks "Create event in calendar"), render an
// Open-task button so the user can jump straight to the
// source task in the Tasks tab.
const _ct = (existing?.description || '').match(/cookbook_task_id:\s*([A-Za-z0-9_-]+)/);
if (!_ct) return '';
return `<div class="cal-form-row cal-form-cookbook-link" style="align-items:center;gap:8px;">
<button type="button" id="cal-f-open-task" data-task-id="${_e(_ct[1])}"
style="display:inline-flex;align-items:center;gap:6px;background:transparent;
color:var(--accent,var(--red));border:1px solid var(--border);
border-radius:6px;padding:5px 10px;font:inherit;font-size:12px;cursor:pointer;">
<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="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
<span>Open in Tasks</span>
</button>
<span style="font-size:11px;opacity:0.5;">Linked to a Cookbook scheduled task</span>
</div>`;
})()}
<div class="cal-form-row" style="align-items:center;gap:8px;">
<label style="font-size:11px;display:flex;align-items:center;gap:4px;"><svg class="cal-remind-bell" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--accent, var(--red))" 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><span style="opacity:0.5;">Reminder</span></label>
<select id="cal-f-remind" class="cal-input" style="flex:1;">
@@ -2736,6 +2822,19 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
document.getElementById('cal-f-allday')?.addEventListener('change', (e) => {
document.getElementById('cal-time-row').style.display = e.target.checked ? 'none' : '';
});
// Open-task back-link button — dynamically imports the tasks module
// so the linkage works even if the user is opening the calendar
// before they've touched the Tasks tab in this session.
document.getElementById('cal-f-open-task')?.addEventListener('click', async (e) => {
e.preventDefault();
const taskId = e.currentTarget?.dataset?.taskId || '';
try {
const m = await import('/static/js/tasks.js');
const openTasks = m.openTasks || m.default?.openTasks;
if (typeof openTasks === 'function') { openTasks(taskId); return; }
} catch (_) {}
document.getElementById('tool-tasks-btn')?.click();
});
// Keep end date >= start date
document.getElementById('cal-f-date')?.addEventListener('change', () => {
const s = document.getElementById('cal-f-date').value;
@@ -3340,6 +3439,44 @@ window.addEventListener('calendar-refresh', () => {
.catch(() => {});
});
// Cross-session catch-up: when the tab/app becomes visible again (you alt-tab
// back, the mobile app comes to the foreground, or you switch back from
// another browser session), drop the range cache and re-fetch. Without this,
// a delete or add on desktop never propagates to the still-open mobile tab
// until the user does a full reload — so stale events sit there undeletable
// (they 404 on the server). Triggers on every visibility change but the
// fetch is cheap and already de-duped by _fetchPromise on line ~120.
let _lastVisRefetchAt = 0;
const _VIS_REFETCH_MIN_MS = 10 * 1000; // throttle if user is rapidly tab-flipping
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return;
const now = Date.now();
if (now - _lastVisRefetchAt < _VIS_REFETCH_MIN_MS) return;
_lastVisRefetchAt = now;
_fetchedRanges = [];
const range = (_view === 'year')
? [`${_currentDate.getFullYear()}-01-01`, `${_currentDate.getFullYear() + 1}-01-01`]
: (_view === 'week') ? _weekRange(_currentDate) : _monthRange(_currentDate);
_fetchEvents(range[0], range[1], /*force*/ true)
.then(() => { if (_open) _render(); _updateBadge(); })
.catch(() => {});
});
// Same idea for window-level focus — covers desktop alt-tabbing back to a
// browser that already had the tab visible (visibilitychange won't fire).
window.addEventListener('focus', () => {
const now = Date.now();
if (now - _lastVisRefetchAt < _VIS_REFETCH_MIN_MS) return;
_lastVisRefetchAt = now;
_fetchedRanges = [];
const range = (_view === 'year')
? [`${_currentDate.getFullYear()}-01-01`, `${_currentDate.getFullYear() + 1}-01-01`]
: (_view === 'week') ? _weekRange(_currentDate) : _monthRange(_currentDate);
_fetchEvents(range[0], range[1], /*force*/ true)
.then(() => { if (_open) _render(); _updateBadge(); })
.catch(() => {});
});
// Calendar reminders are stored as Notes. The Notes reminder loop owns
// notification dispatch so calendar reminders do not fire twice.
+41 -1
View File
@@ -74,6 +74,42 @@ export function _calBgCss(c, fallback) {
return c || fallback || 'var(--accent)';
}
function _hexToRgb(c) {
if (typeof c !== 'string') return null;
const m = c.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (!m) return null;
const hex = m[1].length === 3
? m[1].split('').map(ch => ch + ch).join('')
: m[1];
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
};
}
function _relativeLuminance({ r, g, b }) {
return [r, g, b].map(v => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}).reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i], 0);
}
function _contrastRatio(a, b) {
const light = Math.max(a, b);
const dark = Math.min(a, b);
return (light + 0.05) / (dark + 0.05);
}
export function _calReadableTextColor(bg) {
const rgb = _hexToRgb(bg);
if (!rgb) return 'var(--fg)';
const lum = _relativeLuminance(rgb);
const white = _contrastRatio(lum, 1);
const ink = _contrastRatio(lum, 0.006);
return ink >= white ? '#111820' : '#ffffff';
}
// ── date helpers ──
// `YYYY-MM-DD` string from a Date.
@@ -82,13 +118,17 @@ export function _ds(d) {
}
export function _addDays(dateStr, n) {
if (typeof dateStr !== 'string' || !dateStr) return '';
const d = new Date(dateStr + 'T00:00:00');
if (isNaN(d)) return '';
d.setDate(d.getDate() + n);
return _ds(d);
}
export function _shiftDT(iso, days) {
if (typeof iso !== 'string' || !iso) return '';
const d = new Date(iso);
if (isNaN(d)) return '';
d.setDate(d.getDate() + days);
return _ds(d) + (iso.length > 10 ? 'T' + iso.slice(11) : '');
}
@@ -111,7 +151,7 @@ export function _tzOffset() {
// bucket by the USER's local date. Without this an event at
// "2026-05-13T22:00:00Z" (07:00 May 14 JST) would render on May 13.
export function _localDateOf(isoStr) {
if (!isoStr) return '';
if (typeof isoStr !== 'string' || !isoStr) return '';
if (isoStr.length === 10) return isoStr;
if (/[Zz]$|[+\-]\d{2}:?\d{2}$/.test(isoStr)) {
const d = new Date(isoStr);
+7 -1
View File
@@ -8,7 +8,13 @@
let _enabled = true;
let _observer = null;
const PREF_KEY = 'odysseus-sensitive-blur';
const _prefEnabled = () => localStorage.getItem(PREF_KEY) === 'on';
export const _prefEnabled = () => {
try {
return localStorage.getItem(PREF_KEY) === 'on';
} catch (_) {
return false;
}
};
// Patterns that indicate sensitive data
const PATTERNS = [
+711 -138
View File
File diff suppressed because it is too large Load Diff
+232 -96
View File
@@ -4,9 +4,11 @@
import uiModule from './ui.js';
import markdownModule from './markdown.js';
import { addAITTSButton } from './tts-ai.js';
import { providerLogo } from './providers.js';
import { providerLogo, providerLabel } from './providers.js';
import settingsModule from './settings.js';
import spinnerModule from './spinner.js';
import { bindMenuDismiss } from './escMenuStack.js';
import { matchModelKey } from './model/matchKey.js';
const SEARCH_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
const REPORT_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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>';
@@ -24,6 +26,29 @@ function _safeHref(url) {
return '#';
}
export function safeToolScreenshotSrc(raw) {
const src = String(raw || '').trim();
if (/^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(src)) {
return src;
}
return '';
}
export function safeDisplayImageSrc(raw) {
const src = String(raw || '').trim();
if (!src) return '';
if (/^data:image\/(?:png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(src)) {
return src;
}
try {
const parsed = new URL(src, window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.href;
}
} catch (_) {}
return '';
}
function _makeActionBtn(className, title, text, handler) {
const btn = document.createElement('button');
btn.className = className;
@@ -512,6 +537,39 @@ export function shortModel(name) {
return short;
}
function modelValue(name) {
if (name == null) return '';
return String(name).trim();
}
export function sameModelName(left, right) {
const a = modelValue(left);
const b = modelValue(right);
if (!a || !b) return false;
return a.toLowerCase() === b.toLowerCase()
|| shortModel(a).toLowerCase() === shortModel(b).toLowerCase();
}
export function modelRouteLabel(requestedModel, actualModel) {
const requested = modelValue(requestedModel);
const actual = modelValue(actualModel) || requested;
if (!requested || sameModelName(requested, actual)) return shortModel(actual || requested);
return shortModel(requested) + ' -> ' + shortModel(actual);
}
export function replyModelPair(modelName, metadata) {
const meta = metadata || {};
const actualFromMeta = modelValue(meta.model || meta.actual_model);
const requestedFromMeta = modelValue(meta.requested_model || meta.selected_model);
if (actualFromMeta || requestedFromMeta) {
const actual = actualFromMeta || requestedFromMeta || modelValue(modelName);
const requested = requestedFromMeta || actual;
return { requestedModel: requested, actualModel: actual };
}
const fallback = modelValue(modelName);
return { requestedModel: fallback, actualModel: fallback };
}
/**
* Generate a consistent HSL color for a model name.
* Returns an hsl() string. The hue is derived from a string hash,
@@ -531,11 +589,8 @@ export function modelColor(name) {
/** Look up model info (pricing + context) by substring match */
export function getModelInfo(modelName) {
if (!modelName) return null;
const name = modelName.toLowerCase();
for (const [key, info] of Object.entries(MODEL_INFO)) {
if (name.includes(key)) return { key, ...info };
}
return null;
const key = matchModelKey(modelName, Object.keys(MODEL_INFO));
return key ? { key, ...MODEL_INFO[key] } : null;
}
function _fmtCtx(n) {
@@ -555,7 +610,11 @@ export function applyModelColor(roleEl, modelName) {
}
// Replace generic dot with provider logo if available
const logo = providerLogo(modelName);
if (logo && !roleEl.querySelector('.role-provider-logo')) {
const existingLogo = roleEl.querySelector('.role-provider-logo');
if (!logo) {
if (existingLogo) existingLogo.remove();
roleEl.classList.remove('has-logo');
} else if (!existingLogo) {
const span = document.createElement('span');
span.className = 'role-provider-logo';
span.innerHTML = logo;
@@ -568,7 +627,7 @@ export function applyModelColor(roleEl, modelName) {
roleEl.style.cursor = 'pointer';
roleEl.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.ctx-popup').forEach(p => p.remove());
document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
const info = getModelInfo(modelName);
const short = shortModel(modelName);
const logoHtml = providerLogo(modelName);
@@ -578,6 +637,12 @@ export function applyModelColor(roleEl, modelName) {
if (logoHtml) html += '<span class="role-provider-logo" style="opacity:0.7">' + logoHtml + '</span>';
html += short + '</div>';
html += '<div><span class="ctx-label">Model</span> ' + modelName.split('/').pop() + '</div>';
// Provider = the serving endpoint, distinct from the model vendor/logo
// (e.g. the same model via OpenRouter vs Copilot vs Anthropic direct).
const _epUrl = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
? window.sessionModule.getCurrentEndpointUrl() : null;
const _provLabel = providerLabel(_epUrl);
if (_provLabel) html += '<div><span class="ctx-label">Provider</span> ' + uiModule.esc(_provLabel) + '</div>';
// Show static context initially, then fetch real from server
const _realCtx = window._realContextLengths && window._realContextLengths[modelName];
if (_realCtx) {
@@ -615,9 +680,11 @@ export function applyModelColor(roleEl, modelName) {
html += '<div><span class="ctx-label">Max tokens</span> ' + _mt.toLocaleString() + ' <span style="opacity:0.4">(configured)</span></div>';
}
}
if (info && info.input != null) html += '<div><span class="ctx-label">Input</span> $' + info.input.toFixed(2) + ' / 1M</div>';
if (info && info.output != null) html += '<div><span class="ctx-label">Output</span> $' + info.output.toFixed(2) + ' / 1M</div>';
if (!info) html += '<div style="opacity:0.4;font-size:0.85em;margin-top:4px;">No pricing data available</div>';
if (isCostTrackedEndpoint(_epUrl)) {
if (info && info.input != null) html += '<div><span class="ctx-label">Input</span> $' + info.input.toFixed(2) + ' / 1M</div>';
if (info && info.output != null) html += '<div><span class="ctx-label">Output</span> $' + info.output.toFixed(2) + ' / 1M</div>';
if (!info) html += '<div style="opacity:0.4;font-size:0.85em;margin-top:4px;">No pricing data available</div>';
}
popup.innerHTML = html;
const rect = roleEl.getBoundingClientRect();
popup.style.top = (rect.bottom + 4) + 'px';
@@ -626,23 +693,17 @@ export function applyModelColor(roleEl, modelName) {
const pr = popup.getBoundingClientRect();
if (pr.bottom > window.innerHeight - 8) popup.style.top = (rect.top - pr.height - 4) + 'px';
if (pr.right > window.innerWidth - 8) popup.style.left = (window.innerWidth - pr.width - 8) + 'px';
const closePopup = (ev) => {
if (!popup.contains(ev.target)) { popup.remove(); document.removeEventListener('click', closePopup, true); }
};
setTimeout(() => document.addEventListener('click', closePopup, true), 0);
bindMenuDismiss(popup, () => popup.remove());
});
}
}
export function getModelCost(modelName, inputTokens, outputTokens) {
if (!modelName) return null;
const name = modelName.toLowerCase();
for (const [key, price] of Object.entries(MODEL_PRICING)) {
if (name.includes(key)) {
return (inputTokens * price.input + outputTokens * price.output) / 1_000_000;
}
}
return null;
const key = matchModelKey(modelName, Object.keys(MODEL_PRICING));
if (!key) return null;
const price = MODEL_PRICING[key];
return (inputTokens * price.input + outputTokens * price.output) / 1_000_000;
}
/**
@@ -661,6 +722,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;
@@ -670,11 +737,31 @@ export function isLocalEndpoint(url) {
return false;
}
/** Cost for the current turn, returning null (free) for local endpoints. */
function _billableCost(model, inputTokens, outputTokens) {
const url = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
export function isSubscriptionEndpoint(url) {
if (!url) return false;
try {
const parsed = new URL(url);
const path = parsed.pathname.replace(/\/+$/, '');
return parsed.hostname === 'chatgpt.com'
&& (path === '/backend-api/codex' || path.startsWith('/backend-api/codex/'));
} catch (_e) {
return false;
}
}
function _currentEndpointUrl() {
return (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
? window.sessionModule.getCurrentEndpointUrl() : null;
if (isLocalEndpoint(url)) return null;
}
export function isCostTrackedEndpoint(url) {
return !isLocalEndpoint(url) && !isSubscriptionEndpoint(url);
}
/** Cost for the current turn, returning null for non-billable endpoints. */
function _billableCost(model, inputTokens, outputTokens) {
const url = _currentEndpointUrl();
if (!isCostTrackedEndpoint(url)) return null;
return getModelCost(model, inputTokens, outputTokens);
}
@@ -719,11 +806,10 @@ export function resetSessionCost(sessionId) {
export function updateSessionCostUI() {
const el = document.getElementById('session-cost-display');
if (!el) return;
// Local model? It's free — hide the badge and clear any stale cost that a
// previous (buggy) cloud-rate billing left in localStorage for this session.
const _url = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
? window.sessionModule.getCurrentEndpointUrl() : null;
if (isLocalEndpoint(_url)) {
// Non-billable endpoint? Hide the badge and clear stale cost that a previous
// cloud-rate calculation may have left in localStorage for this session.
const _url = _currentEndpointUrl();
if (!isCostTrackedEndpoint(_url)) {
const sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
if (sid && getSessionCost(sid) > 0) {
try {
@@ -977,7 +1063,12 @@ document.addEventListener('click', function(e) {
// matching module via a dynamic import (avoids circular deps —
// sessions.js itself imports chatRenderer.js).
document.addEventListener('click', function(e) {
const a = e.target && e.target.closest && e.target.closest('a[href]');
// Walk past Text nodes — clicking link text yields a Text node target
// whose .closest is undefined, so preventDefault never fires and the
// browser performs a default hash-navigation that resets the session.
let _t = e.target;
while (_t && _t.nodeType === Node.TEXT_NODE) _t = _t.parentElement;
const a = _t && _t.closest && _t.closest('a[href]');
if (!a) return;
const href = a.getAttribute('href') || '';
if (!href.startsWith('#')) return;
@@ -1053,12 +1144,19 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
const body = document.createElement('div');
body.className = 'body';
const safeImageUrl = safeDisplayImageSrc(imageUrl);
if (!safeImageUrl) {
body.textContent = '[Image unavailable]';
wrap.appendChild(body);
return wrap;
}
const img = document.createElement('img');
img.className = 'generated-image';
img.alt = prompt || 'Generated image';
img.title = prompt || 'Generated image';
img.src = imageUrl;
img.addEventListener('click', () => { window.open(img.src, '_blank'); });
img.src = safeImageUrl;
img.addEventListener('click', () => { window.open(safeImageUrl, '_blank', 'noopener,noreferrer'); });
body.appendChild(img);
if (prompt) {
@@ -1213,6 +1311,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.
@@ -1332,12 +1441,17 @@ export function createMsgFooter(msgElement) {
moreBtn.textContent = '\u00B7\u00B7\u00B7';
moreBtn.addEventListener('click', (e) => {
e.stopPropagation();
// Toggle overflow menu — close any existing one first
// Toggle overflow menu — close any existing one first (through its own
// dismiss so the Escape registry entry goes with it).
const existing = document.querySelector('.msg-overflow-menu');
if (existing) { existing.remove(); if (existing._trigger === moreBtn) return; }
if (existing) {
if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove();
if (existing._trigger === moreBtn) return;
}
const menu = document.createElement('div');
menu.className = 'msg-overflow-menu';
let closeMenu = () => menu.remove();
overflow.forEach(a => {
const item = document.createElement('button');
item.className = 'msg-overflow-item';
@@ -1347,7 +1461,7 @@ export function createMsgFooter(msgElement) {
item.addEventListener('click', (ev) => {
ev.stopPropagation();
_trackAction(a.id);
menu.remove();
closeMenu();
a.handler(ev);
});
menu.appendChild(item);
@@ -1363,15 +1477,9 @@ export function createMsgFooter(msgElement) {
// Keep within right edge
const mr = menu.getBoundingClientRect();
if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px';
// Close on outside click
const close = (ev) => {
if (!menu.contains(ev.target) && ev.target !== moreBtn) {
menu.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 0);
});
// Close on outside click or Escape. The trigger button is treated as
// "inside" so its own click toggles rather than double-fires.
closeMenu = bindMenuDismiss(menu, () => menu.remove(), (ev) => !menu.contains(ev.target) && ev.target !== moreBtn); });
actions.appendChild(moreBtn);
}
@@ -1392,9 +1500,14 @@ export function createMsgFooter(msgElement) {
pill.addEventListener('click', (e) => {
e.stopPropagation();
let detail = pill._openDetail || document.querySelector('.memory-used-detail');
if (detail) { detail.remove(); pill._openDetail = null; return; }
if (detail) {
if (typeof detail._dismiss === 'function') detail._dismiss();
else { detail.remove(); pill._openDetail = null; }
return;
}
detail = document.createElement('div');
detail.className = 'memory-used-detail';
let closeDetail = () => { detail.remove(); pill._openDetail = null; };
mems.forEach(m => {
const row = document.createElement('div');
row.className = 'memory-used-row';
@@ -1410,8 +1523,7 @@ export function createMsgFooter(msgElement) {
row.appendChild(text);
row.addEventListener('click', (ev) => {
ev.stopPropagation();
detail.remove();
pill._openDetail = null;
closeDetail();
const memModal = document.getElementById('memory-modal');
if (memModal) memModal.classList.remove('hidden');
});
@@ -1435,15 +1547,8 @@ export function createMsgFooter(msgElement) {
if (parseFloat(detail.style.left) < 8) detail.style.left = '8px';
detail.style.visibility = '';
pill._openDetail = detail;
const close = (ev) => {
if (!detail.contains(ev.target) && ev.target !== pill) {
detail.remove();
pill._openDetail = null;
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 0);
});
// Close on outside click or Escape (pill click toggles, so it's inside).
closeDetail = bindMenuDismiss(detail, () => { detail.remove(); pill._openDetail = null; }, (ev) => !detail.contains(ev.target) && ev.target !== pill); });
footer.appendChild(pill);
}
@@ -1528,10 +1633,14 @@ export function createUserMsgFooter(msgElement) {
moreBtn.addEventListener('click', (e) => {
e.stopPropagation();
const existing = document.querySelector('.msg-overflow-menu');
if (existing) { existing.remove(); if (existing._trigger === moreBtn) return; }
if (existing) {
if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove();
if (existing._trigger === moreBtn) return;
}
const menu = document.createElement('div');
menu.className = 'msg-overflow-menu';
let closeMenu = () => menu.remove();
overflow.forEach(a => {
const item = document.createElement('button');
item.className = 'msg-overflow-item';
@@ -1541,7 +1650,7 @@ export function createUserMsgFooter(msgElement) {
item.addEventListener('click', (ev) => {
ev.stopPropagation();
_trackUserAction(a.id);
menu.remove();
closeMenu();
a.handler(ev);
});
menu.appendChild(item);
@@ -1554,14 +1663,7 @@ export function createUserMsgFooter(msgElement) {
if (parseFloat(menu.style.top) < 8) menu.style.top = (btnRect.bottom + 4) + 'px';
const mr = menu.getBoundingClientRect();
if (mr.right > window.innerWidth - 8) menu.style.left = (window.innerWidth - mr.width - 8) + 'px';
const close = (ev) => {
if (!menu.contains(ev.target) && ev.target !== moreBtn) {
menu.remove();
document.removeEventListener('click', close, true);
}
};
setTimeout(() => document.addEventListener('click', close, true), 0);
});
closeMenu = bindMenuDismiss(menu, () => menu.remove(), (ev) => !menu.contains(ev.target) && ev.target !== moreBtn); });
actions.appendChild(moreBtn);
}
@@ -1625,9 +1727,10 @@ export function displayMetrics(messageElement, metrics) {
metricsDivider.style.pointerEvents = 'none';
metricsContainer.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.ctx-popup').forEach(p => p.remove());
document.querySelectorAll('.ctx-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
const costStr = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : 'n/a';
const costStr = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : '';
const costRows = costStr ? `<div><span class="ctx-label">Cost</span> ${costStr}</div>` : '';
const speedStr = tps != null && tps !== 'undefined' ? `${tps} tok/s` : 'n/a';
const totalTok = inputTokens + outputTokens;
const ctxColor = ctxPct >= 85 ? 'var(--red, #e06c75)' : ctxPct >= 70 ? '#ff9900' : 'var(--color-muted-alt, #6b7280)';
@@ -1641,7 +1744,7 @@ export function displayMetrics(messageElement, metrics) {
// Session total cost
let sessionCostStr = '';
const sc = getSessionCost();
if (sc > 0) {
if (costStr && sc > 0) {
sessionCostStr = `<div><span class="ctx-label">Session</span> $${sc < 0.01 ? sc.toFixed(4) : sc.toFixed(3)}</div>`;
}
@@ -1657,7 +1760,7 @@ export function displayMetrics(messageElement, metrics) {
<div><span class="ctx-label">Time</span> ${responseTime}s</div>
${prepTime != null ? `<div><span class="ctx-label">Prep</span> ${prepTime}s</div>` : ''}
${modelWaitTime != null ? `<div><span class="ctx-label">Model wait</span> ${modelWaitTime}s</div>` : ''}
<div><span class="ctx-label">Cost</span> ${costStr}</div>
${costRows}
${sessionCostStr}
${prepDetails ? `<div style="margin-top:6px;padding-top:6px;border-top:1px solid var(--border);font-size:0.85em;opacity:0.8;">
<div style="font-weight:600;margin-bottom:4px;color:var(--fg);">Agent prep</div>
@@ -1685,13 +1788,7 @@ export function displayMetrics(messageElement, metrics) {
if (parseFloat(popup.style.left) < 8) popup.style.left = '8px';
popup.style.visibility = '';
const closePopup = (ev) => {
if (!popup.contains(ev.target)) {
popup.remove();
document.removeEventListener('click', closePopup, true);
}
};
setTimeout(() => document.addEventListener('click', closePopup, true), 0);
bindMenuDismiss(popup, () => popup.remove());
});
// Store real context length for model info popup
@@ -1722,7 +1819,7 @@ export function displayMetrics(messageElement, metrics) {
ctxRing.addEventListener('click', (e) => {
e.stopPropagation();
document.querySelectorAll('.ctx-detail-popup').forEach(p => p.remove());
document.querySelectorAll('.ctx-detail-popup').forEach(p => { if (typeof p._dismiss === 'function') p._dismiss(); else p.remove(); });
const usedTokens = inputTokens || 0;
const totalCtx = ctxLen || 0;
@@ -1802,7 +1899,13 @@ export function displayMetrics(messageElement, metrics) {
}
}, 200);
} else {
compactBody.innerHTML = '<span style="color:var(--red);">Compaction failed. Try again later.</span>';
let detail = 'Compaction failed. Try again later.';
try {
const err = await res.json();
if (err.detail) detail = err.detail;
} catch {}
compactBody.textContent = detail;
compactBody.style.color = 'var(--red)';
}
} catch (err) {
clearInterval(waveInterval);
@@ -1826,13 +1929,7 @@ export function displayMetrics(messageElement, metrics) {
}
popup.style.visibility = '';
const closePopup = (ev) => {
if (!popup.contains(ev.target) && ev.target !== ctxRing && !ctxRing.contains(ev.target)) {
popup.remove();
document.removeEventListener('click', closePopup, true);
}
};
setTimeout(() => document.addEventListener('click', closePopup, true), 0);
bindMenuDismiss(popup, () => popup.remove(), (ev) => !popup.contains(ev.target) && ev.target !== ctxRing && !ctxRing.contains(ev.target));
});
}
@@ -1901,8 +1998,12 @@ export function addMessage(role, content, modelName, metadata) {
wrap.className = 'msg msg-ai' + (r > 0 ? ' msg-continuation' : '');
const roleEl = document.createElement('div');
roleEl.className = 'role';
const contModel = modelName || metadata?.model;
roleEl.textContent = shortModel(contModel);
const pair = replyModelPair(modelName, metadata);
const contModel = pair.actualModel || pair.requestedModel;
roleEl.textContent = modelRouteLabel(pair.requestedModel, contModel);
if (pair.requestedModel && contModel && !sameModelName(pair.requestedModel, contModel)) {
roleEl.title = pair.requestedModel + ' -> ' + contModel;
}
applyModelColor(roleEl, contModel);
if (r === 0) roleEl.appendChild(roleTimestamp(metadata?.timestamp));
wrap.appendChild(roleEl);
@@ -1956,13 +2057,37 @@ export function addMessage(role, content, modelName, metadata) {
if (ev.output && ev.output.trim()) {
outHtml = `<details class="agent-tool-output"><summary>Output</summary><pre>${esc(ev.output)}</pre></details>`;
}
if (ev.screenshot) {
outHtml += `<details class="agent-tool-output"><summary>Screenshot</summary><img src="${esc(ev.screenshot)}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" /></details>`;
const screenshotSrc = safeToolScreenshotSrc(ev.screenshot);
if (screenshotSrc) {
outHtml += `<details class="agent-tool-output"><summary>Screenshot</summary><img src="${esc(screenshotSrc)}" style="max-width:100%;border-radius:6px;margin-top:6px;border:1px solid var(--border)" /></details>`;
}
// File-write/edit diff (persisted in the tool event) \u2014 re-render it
// so it survives reload, matching the live stream.
let evDiffHtml = '';
if (ev.diff && ev.diff.text) {
const d = ev.diff;
const stat = [
d.new_file ? '<span class="diff-stat-new">new</span>' : '',
d.added ? `<span class="diff-stat-add">+${d.added}</span>` : '',
d.removed ? `<span class="diff-stat-del">\u2212${d.removed}</span>` : '',
].filter(Boolean).join(' ');
const rows = d.text.split('\n').map(line => {
let cls = 'diff-ctx', text = line;
if (line.startsWith('+++') || line.startsWith('---')) cls = 'diff-meta';
else if (line.startsWith('@@')) cls = 'diff-hunk';
// Drop the leading diff marker (+/-/space) — colour encodes add/del.
else if (line.startsWith('+')) { cls = 'diff-add'; text = line.slice(1); }
else if (line.startsWith('-')) { cls = 'diff-del'; text = line.slice(1); }
else if (line.startsWith(' ')) { text = line.slice(1); }
return `<span class="${cls}">${esc(text) || '&nbsp;'}</span>`;
}).join(''); // spans are display:block \u2014 a literal \n would double-space
evDiffHtml = `<details class="agent-tool-output agent-tool-diff"><summary><span class="diff-file">${esc(d.file || 'diff')}</span> <span class="diff-summary-stats">${stat}</span></summary><pre class="diff-pre">${rows}</pre></details>`;
}
const node = document.createElement('div');
node.className = 'agent-thread-node' + (ok ? '' : ' error');
const evCmdHtml = ev.command ? `<pre class="agent-thread-cmd">${esc(ev.command)}</pre>` : '';
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(ev.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${evCmdHtml}${outHtml}</div>`;
// Hide the raw JSON command when a diff says it better (same as live).
const evCmdHtml = (ev.command && !(ev.diff && ev.diff.text)) ? `<pre class="agent-thread-cmd">${esc(ev.command)}</pre>` : '';
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">${ok ? '\u2713' : '\u2717'}</span><span class="agent-thread-tool">${esc(ev.tool)}</span><span class="agent-thread-status">${ok ? 'done' : 'failed'}</span><span class="agent-thread-chevron">\u25B6</span></div><div class="agent-thread-content">${evCmdHtml}${outHtml}${evDiffHtml}</div>`;
// Click handling is delegated globally \u2014 see chat.js init.
threadWrap.appendChild(node);
}
@@ -2001,8 +2126,9 @@ export function addMessage(role, content, modelName, metadata) {
r.className = 'role';
const isSlash = metadata?.source === 'slash';
const isCompacted = metadata?.compacted;
const resolvedModel = modelName || metadata?.model;
var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : shortModel(resolvedModel);
const replyModels = replyModelPair(modelName, metadata);
const resolvedModel = replyModels.actualModel || replyModels.requestedModel;
var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : modelRouteLabel(replyModels.requestedModel, resolvedModel);
if (role === 'assistant' && (metadata?.research || metadata?.research_clarification)) {
_roleText += ' (Research)';
}
@@ -2013,6 +2139,9 @@ export function addMessage(role, content, modelName, metadata) {
}
r.textContent = _roleText;
if (role !== 'user') {
if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && !sameModelName(replyModels.requestedModel, resolvedModel)) {
r.title = replyModels.requestedModel + ' -> ' + resolvedModel;
}
if (!isSlash && !isCompacted) applyModelColor(r, resolvedModel);
r.appendChild(roleTimestamp(metadata?.timestamp));
}
@@ -2279,15 +2408,22 @@ export function addMessage(role, content, modelName, metadata) {
const chatRenderer = {
shortModel,
sameModelName,
modelRouteLabel,
replyModelPair,
modelColor,
applyModelColor,
getModelCost,
isCostTrackedEndpoint,
isSubscriptionEndpoint,
getImageCost,
getSessionCost,
resetSessionCost,
updateSessionCostUI,
roleTimestamp,
stripToolBlocks,
safeToolScreenshotSrc,
safeDisplayImageSrc,
buildSourcesBox,
buildFindingsBox,
appendReportButton,
+7 -2
View File
@@ -310,11 +310,15 @@ try {
*/
export async function runServer(code, panel, lang) {
showLoading(panel, 'Running on server...');
// Base64-encode the script so newlines survive the shell quoting intact.
// JSON.stringify turns \n into literal \\n which python3 -c sees as backslash-n;
// base64 avoids every quoting/escaping pitfall.
const b64 = btoa(unescape(encodeURIComponent(code)));
var command;
if (lang === 'python' || lang === 'py') {
command = 'python3 -c ' + JSON.stringify(code);
command = `python3 -c "import base64; exec(base64.b64decode('${b64}').decode('utf-8'))"`;
} else {
command = 'bash -c ' + JSON.stringify(code);
command = `python3 -c "import base64, subprocess, sys; sys.exit(subprocess.run(['bash','-c',base64.b64decode('${b64}').decode('utf-8')]).returncode)"`;
}
try {
var res = await fetch('/api/shell/exec', {
@@ -362,6 +366,7 @@ export function runHTML(code, panel) {
addCloseBtn(panel);
return;
}
try { win.opener = null; } catch (_) {}
win.document.open();
win.document.write(code);
win.document.close();
+14
View File
@@ -0,0 +1,14 @@
// static/js/color/hex.js
//
// Parse a CSS hex color into {r, g, b}. Pure — no DOM — so it can be reused
// across modules and unit-tested under node.
// Accepts "#rgb", "#rrggbb" (with or without the leading '#'). Returns null
// for anything that isn't a valid 3- or 6-digit hex color.
export function hexToRgb(hex) {
let h = String(hex || '').trim().replace(/^#/, '');
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
if (!/^[0-9a-fA-F]{6}$/.test(h)) return null;
const n = parseInt(h, 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
+8 -1
View File
@@ -92,7 +92,9 @@ async function toggleMode() {
deactivate(true);
return false;
}
if (state._openingSelector) return false;
state._openingSelector = true;
try {
const confirmed = await showModelSelector();
if (!confirmed) return false;
@@ -104,6 +106,8 @@ async function toggleMode() {
} catch (err) {
console.error('Compare toggleMode error:', err);
return false;
} finally {
state._openingSelector = false;
}
}
@@ -206,7 +210,9 @@ async function _buildCompareUI() {
for (let i = 0; i < n; i++) {
const m = state._selectedModels[i];
const fd = new FormData();
fd.append('name', '[CMP] ' + modelShorts[i]);
// Blind mode: name the session by its neutral slot so the sidebar /
// GET /api/sessions can't de-anonymize the comparison (issue #1285).
fd.append('name', '[CMP] ' + (state._blindMode ? 'Model ' + _slotChar(i) : modelShorts[i]));
fd.append('endpoint_url', m.endpoint || '');
fd.append('model', m.model || '');
if (m.endpointId) {
@@ -1084,6 +1090,7 @@ function _exportPrint() {
// the system print dialog — user can pick "Save as PDF" from there.
const w = window.open('', '_blank');
if (!w) return;
try { w.opener = null; } catch (_) {}
const escape = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const html = '<!doctype html><meta charset="utf-8"><title>Compare export</title>' +
'<style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;max-width:780px;margin:32px auto;padding:0 24px;line-height:1.55;color:#222}' +
+15 -24
View File
@@ -10,6 +10,7 @@ import { _clearProbeWaves } from './probe.js';
import Storage from '../storage.js';
import uiModule from '../ui.js';
import spinnerModule from '../spinner.js';
import { bindMenuDismiss } from '../escMenuStack.js';
var escapeHtml = uiModule.esc;
@@ -282,10 +283,11 @@ async function _addPane(anchorBtn) {
// Toggle existing dropdown
const existing = document.querySelector('.add-pane-dropdown');
if (existing) { existing.remove(); return; }
if (existing) { if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); return; }
const dropdown = document.createElement('div');
dropdown.className = 'add-pane-dropdown';
let closeMenu = () => dropdown.remove();
// Search input for large model lists
if (filtered.length >= 5) {
@@ -326,7 +328,7 @@ async function _addPane(anchorBtn) {
item.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.remove();
closeMenu();
await _createAndAppendPane(m);
});
dropdown.appendChild(item);
@@ -371,15 +373,8 @@ async function _addPane(anchorBtn) {
dropdown.style.bottom = 'auto';
dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px';
// Close on outside click
const close = (e) => {
if (!dropdown.contains(e.target) && e.target !== anchorBtn) {
dropdown.remove();
document.removeEventListener('click', close);
}
};
setTimeout(() => document.addEventListener('click', close), 0);
}
// Close on outside click or Escape (the latter via the registry).
closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (e) => !dropdown.contains(e.target) && e.target !== anchorBtn);}
/** Create a new pane for the given model and append it to the compare grid. */
async function _createAndAppendPane(m) {
@@ -387,7 +382,8 @@ async function _createAndAppendPane(m) {
// Create session
const fd = new FormData();
fd.append('name', '[CMP] ' + m.name);
// Blind mode: neutral slot name only — never leak the model (issue #1285).
fd.append('name', '[CMP] ' + (state._blindMode ? 'Model ' + _slotChar(i) : m.name));
fd.append('endpoint_url', m.url || '');
fd.append('model', m.id || '');
if (m.endpointId) {
@@ -551,7 +547,7 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
// Remove any existing dropdown
const existing = document.querySelector('.pane-model-dropdown');
if (existing) { existing.remove(); return; }
if (existing) { if (typeof existing._dismiss === 'function') existing._dismiss(); else existing.remove(); return; }
const _effectiveType = (state._compareMode === 'agent' || state._compareMode === 'research') ? 'chat' : state._compareMode;
const filtered = state._cachedModels.filter(m => m.type === _effectiveType);
@@ -559,6 +555,7 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
const dropdown = document.createElement('div');
dropdown.className = 'pane-model-dropdown';
let closeMenu = () => dropdown.remove();
filtered.forEach(m => {
const item = document.createElement('button');
@@ -573,7 +570,7 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
}
item.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.remove();
closeMenu();
// Update the model for this pane and persist
state._selectedModels[paneIdx] = {
@@ -588,7 +585,8 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
fetch(`${state.API_BASE}/api/session/${oldSid}`, { method: 'DELETE' }).catch(() => {});
}
const fd = new FormData();
fd.append('name', '[CMP] ' + m.name);
// Blind mode: neutral slot name only — never leak the model (issue #1285).
fd.append('name', '[CMP] ' + (state._blindMode ? 'Model ' + _slotChar(paneIdx) : m.name));
fd.append('endpoint_url', m.url || '');
fd.append('model', m.id || '');
if (m.endpointId) {
@@ -653,15 +651,8 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
dropdown.style.top = top + 'px';
dropdown.style.maxHeight = Math.min(ddH, vh - margin * 2) + 'px';
// Close on outside click
const close = (e) => {
if (!dropdown.contains(e.target) && e.target !== titleBtn) {
dropdown.remove();
document.removeEventListener('click', close);
}
};
setTimeout(() => document.addEventListener('click', close), 0);
}
// Close on outside click or Escape (the latter via the registry).
closeMenu = bindMenuDismiss(dropdown, () => dropdown.remove(), (e) => !dropdown.contains(e.target) && e.target !== titleBtn);}
// ── Shuffle / reset ──
+1 -1
View File
@@ -1195,7 +1195,7 @@ async function showModelSelector() {
const row = document.createElement('div');
row.className = 'compare-probe-row';
row.dataset.idx = 'p' + i;
row.innerHTML = `<span class="compare-probe-spinner">▁▂▃</span><span class="compare-probe-name">${p.label || p.id}</span><span class="compare-probe-status"></span>`;
row.innerHTML = `<span class="compare-probe-spinner">▁▂▃</span><span class="compare-probe-name">${escapeHtml(p.label || p.id)}</span><span class="compare-probe-status"></span>`;
const waveEl = row.querySelector('.compare-probe-spinner');
const waveFrames = WAVE_FRAMES;
let wIdx = 0;
+2
View File
@@ -2,6 +2,7 @@
const state = {
API_BASE: '',
isActive: false,
_openingSelector: false, // prevents duplicate compare modals on rapid re-clicks
_streaming: false,
_blindMode: true,
_saveOnClose: false,
@@ -36,6 +37,7 @@ const state = {
/** Reset transient state to defaults — useful for clean restarts. */
export function reset() {
state._openingSelector = false;
state._streaming = false;
state._finishOrder = 0;
state._paneElapsed = [];
+43 -25
View File
@@ -1,7 +1,7 @@
// compare/stream.js — SSE streaming to panes
import state from './state.js';
import { addFinishBadge } from './vote.js';
import { getModelCost } from '../chatRenderer.js';
import { getModelCost, safeDisplayImageSrc } from '../chatRenderer.js';
import markdownModule from '../markdown.js';
import spinnerModule from '../spinner.js';
import uiModule from '../ui.js';
@@ -11,6 +11,16 @@ var escapeHtml = uiModule.esc;
const WAVE_FRAMES = ['▁▂▃', '▂▃▄', '▃▄▅', '▄▅▆', '▅▆▇', '▆▅▄', '▅▄▃', '▄▃▂'];
function _safeHttpHref(raw) {
try {
const parsed = new URL(String(raw || '').trim(), window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.href;
}
} catch (_) {}
return '';
}
// ── Lazy-registered functions from compare.js (avoids circular deps) ──
let _rerollPane = null;
let _autoPreviewHtml = null;
@@ -36,9 +46,12 @@ function _renderSearchResults(data) {
const card = document.createElement('div');
card.className = 'compare-search-result';
const titleLink = document.createElement('a');
titleLink.href = r.url || '#';
titleLink.target = '_blank';
titleLink.rel = 'noopener';
const safeUrl = _safeHttpHref(r.url);
if (safeUrl) {
titleLink.href = safeUrl;
titleLink.target = '_blank';
titleLink.rel = 'noopener noreferrer';
}
titleLink.className = 'search-result-title';
titleLink.textContent = r.title || 'Untitled';
card.appendChild(titleLink);
@@ -344,7 +357,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
const cmdHtml = cmd ? `<pre class="agent-thread-cmd">${escapeHtml(cmd)}</pre>` : '';
const node = document.createElement('div');
node.className = 'agent-thread-node running';
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${toolLabel}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
node.innerHTML = `<div class="agent-thread-dot"></div><div class="agent-thread-header"><span class="agent-thread-icon">\u25B6</span><span class="agent-thread-tool">${escapeHtml(toolLabel)}</span><span class="agent-thread-wave">▁▂▃</span></div><div class="agent-thread-content">${cmdHtml}</div>`;
node.querySelector('.agent-thread-header').addEventListener('click', () => node.classList.toggle('open'));
// Animate wave
const waveEl = node.querySelector('.agent-thread-wave');
@@ -363,28 +376,33 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
if (json.image_url) {
// Stop image spinner and render generated image in pane
if (aiMsgEl._imgSpinner) { aiMsgEl._imgSpinner.destroy(); aiMsgEl._imgSpinner = null; }
const safeImageUrl = safeDisplayImageSrc(json.image_url);
aiBody.innerHTML = '';
const img = document.createElement('img');
img.className = 'compare-gen-image';
img.src = json.image_url;
img.alt = json.image_prompt || '';
img.title = json.image_prompt || '';
img.addEventListener('click', () => window.open(img.src, '_blank'));
aiBody.appendChild(img);
if (json.image_prompt) {
const caption = document.createElement('div');
caption.style.cssText = 'font-size:0.82em;color:color-mix(in srgb, var(--fg) 55%, transparent);margin-top:6px;line-height:1.4;';
caption.textContent = json.image_prompt;
aiBody.appendChild(caption);
if (!safeImageUrl) {
aiBody.textContent = '[Image unavailable]';
} else {
const img = document.createElement('img');
img.className = 'compare-gen-image';
img.src = safeImageUrl;
img.alt = json.image_prompt || '';
img.title = json.image_prompt || '';
img.addEventListener('click', () => window.open(safeImageUrl, '_blank', 'noopener,noreferrer'));
aiBody.appendChild(img);
if (json.image_prompt) {
const caption = document.createElement('div');
caption.style.cssText = 'font-size:0.82em;color:color-mix(in srgb, var(--fg) 55%, transparent);margin-top:6px;line-height:1.4;';
caption.textContent = json.image_prompt;
aiBody.appendChild(caption);
}
// Show model name below image (hidden in blind mode until vote)
if (json.image_model && !state._blindMode) {
const modelLabel = document.createElement('div');
modelLabel.style.cssText = 'font-size:0.75em;color:color-mix(in srgb, var(--fg) 40%, transparent);margin-top:4px;';
modelLabel.textContent = json.image_model;
aiBody.appendChild(modelLabel);
}
aiMsgEl._imageData = { url: safeImageUrl, prompt: json.image_prompt, model: json.image_model, size: json.image_size, quality: json.image_quality };
}
// Show model name below image (hidden in blind mode until vote)
if (json.image_model && !state._blindMode) {
const modelLabel = document.createElement('div');
modelLabel.style.cssText = 'font-size:0.75em;color:color-mix(in srgb, var(--fg) 40%, transparent);margin-top:4px;';
modelLabel.textContent = json.image_model;
aiBody.appendChild(modelLabel);
}
aiMsgEl._imageData = { url: json.image_url, prompt: json.image_prompt, model: json.image_model, size: json.image_size, quality: json.image_quality };
} else if (currentToolBlock) {
// Stop wave animation
if (currentToolBlock._waveInterval) { clearInterval(currentToolBlock._waveInterval); currentToolBlock._waveInterval = null; }
+61
View File
@@ -0,0 +1,61 @@
/**
* ArrowUp on an empty composer recalls the last user message (chat-app convention).
*/
/**
* Last user bubble in the active chat surface (#chat-history), using dataset.raw
* (same source as resend/regenerate in chat.js).
*
* @param {Document | Element} [root=document]
* @returns {string}
*/
export function getLastUserMessageFromChatHistory(root = document) {
const chatBox =
root && root.id === 'chat-history' && typeof root.querySelectorAll === 'function'
? root
: (root.getElementById ? root.getElementById('chat-history') : null);
if (!chatBox) return '';
const users = chatBox.querySelectorAll('.msg-user');
const last = users[users.length - 1];
if (!last) return '';
const bodyEl = last.querySelector('.body');
return last.dataset?.raw || (bodyEl ? bodyEl.textContent : '') || '';
}
/**
* @param {HTMLTextAreaElement} composer
* @param {() => string} getLastUserMessage
* @param {{ autoResize?: (el: HTMLTextAreaElement) => void }} [options]
* @returns {boolean} true when wired (or already wired)
*/
export function wireArrowUpRecall(composer, getLastUserMessage, options = {}) {
if (!composer) return false;
if (composer._arrowUpRecallWired) return true;
composer._arrowUpRecallWired = true;
const { autoResize } = options;
composer.addEventListener('keydown', (e) => {
// Only ArrowUp, no modifier keys, no IME composition
if (e.key !== 'ArrowUp') return;
if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return;
if (e.isComposing) return;
// Literal emptiness — intentional whitespace is not empty
if (composer.value !== '') return;
const recalled = getLastUserMessage();
if (!recalled) return;
e.preventDefault();
composer.value = recalled;
try {
composer.selectionStart = composer.selectionEnd = recalled.length;
} catch (_) {}
if (autoResize) autoResize(composer);
});
return true;
}
+294 -87
View File
@@ -23,10 +23,98 @@ import {
// browser loads it once. See cookbook-hwfit.js.
} from './cookbook.js';
import uiModule from './ui.js';
// Tiny HTML-escape — keeps the file standalone instead of leaning on a
// shared helper that may not be exported from this module's import surface.
function _diagEsc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// Pick an icon for a diagnosis-action button based on the label. The icon
// renders on the LEFT of the button text. Keeps the strokes consistent
// across the set so they read as one family.
function _diagFixIcon(label) {
const l = String(label || '').toLowerCase();
const _svg = (path) => `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" class="cookbook-diag-btn-ico" aria-hidden="true">${path}</svg>`;
if (l.startsWith('retry') || l.includes('relaunch') || l.includes('restart')) {
// Circular-arrow refresh
return _svg('<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>');
}
if (l.startsWith('copy')) {
return _svg('<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"/>');
}
if (l.startsWith('edit')) {
return _svg('<path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/>');
}
if (l.startsWith('open') || l.includes('dependencies')) {
return _svg('<path d="M14 3h7v7"/><path d="M21 3l-9 9"/><path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5"/>');
}
if (l.startsWith('install') || l.includes('upgrade')) {
return _svg('<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>');
}
if (l.startsWith('kill') || l.startsWith('stop')) {
return _svg('<rect x="6" y="6" width="12" height="12" rx="1"/>');
}
if (l.startsWith('switch') || l.includes('use ')) {
return _svg('<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>');
}
// Default: lightbulb (generic "suggestion")
return _svg('<path d="M9 21h6"/><path d="M12 17v4"/><path d="M12 3a6 6 0 0 0-4 10.5c1 1 1.5 2 1.5 3.5h5c0-1.5.5-2.5 1.5-3.5A6 6 0 0 0 12 3Z"/>');
}
import spinnerModule from './spinner.js';
// ── Error diagnosis ──
function _openCookbookDependencies(pkgName = '') {
const cookbook = window.cookbookModule;
if (cookbook && typeof cookbook.open === 'function') {
cookbook.open({ tab: 'Dependencies' });
} else {
document.getElementById('tool-cookbook-btn')?.click();
}
const wanted = String(pkgName || '').toLowerCase();
const tryHighlight = (attempt = 0) => {
const modal = document.getElementById('cookbook-modal');
const tab = modal?.querySelector('.cookbook-tab[data-backend="Dependencies"]');
if (tab && !tab.classList.contains('active')) tab.click();
const rows = [...document.querySelectorAll('#cookbook-deps-list [data-pkg-name]')];
if (!rows.length) {
if (attempt < 45) setTimeout(() => tryHighlight(attempt + 1), 100);
return;
}
if (!wanted) return;
const row = rows.find(r => {
const name = (r.dataset.pkgName || '').toLowerCase();
const pip = (r.dataset.depPip || '').toLowerCase();
return name === wanted || pip.includes(wanted) || wanted.includes(name);
});
if (row) {
row.scrollIntoView({ block: 'center' });
row.classList.add('cookbook-pkg-flash');
setTimeout(() => row.classList.remove('cookbook-pkg-flash'), 1800);
}
};
tryHighlight();
}
function _openServeEditFromDiagnosis(panel, fields = null) {
const task = panel?.closest?.('.cookbook-task');
if (!task) return;
task.dispatchEvent(new CustomEvent('cookbook:edit-serve', { bubbles: true, detail: { fields } }));
}
function _openCpuServeEdit(panel) {
_openServeEditFromDiagnosis(panel, {
backend: 'llamacpp',
gpus: '',
tp: '1',
gpu_mem: '0.80',
_forceBackend: true,
});
}
// Infer the gated base repo that single-file checkpoints need configs from
function _inferBaseRepo(text) {
if (!text) return null;
@@ -70,17 +158,24 @@ export const ERROR_PATTERNS = [
},
{
pattern: /not divisible by weight quantization|quantization block/i,
message: 'Model quantization format incompatible with this vLLM version. Try a different quant (AWQ) or update vLLM.',
message: 'FP8 MoE quantization is incompatible with this tensor-parallel split.',
suggestion: 'Suggested action: retry with a lower tensor-parallel size, such as TP=4 or TP=2. If it still fails, use a non-FP8/GGUF version of the model.',
fixes: [
{ label: 'Update vLLM on server', action: (panel) => {
const taskEl = panel.closest('.cookbook-task');
const task = taskEl ? _loadTasks().find(t => t.sessionId === taskEl.dataset.taskId) : null;
const host = task?.remoteHost || '';
const prefix = _buildEnvPrefix();
const pipCmd = prefix ? prefix + ' pip install -U vllm' : 'pip install -U vllm';
const cmd = host ? _sshCmd(host, pipCmd) : pipCmd;
_launchServeTask('update-vllm', 'pip-update', cmd);
}},
{ label: 'Retry with TP=4', action: (panel) => _serveAutoRetryReplace(panel, '--tensor-parallel-size', '4') },
{ label: 'Retry with TP=2', action: (panel) => _serveAutoRetryReplace(panel, '--tensor-parallel-size', '2') },
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
],
},
{
pattern: /There is no module or parameter named ['"]lm_head\.input_scale['"]|lm_head\.input_scale|weight_scale_2/i,
message: 'vLLM cannot load this ModelOpt LM-head quantized checkpoint with the current runtime.',
suggestion: 'Suggested action: upgrade vLLM through the environment that provides this CLI (package manager, venv, Docker image, or source checkout), or choose a compatible checkpoint.',
fixes: [
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('vllm') },
{
label: 'Copy upgrade hint',
action: () => _copyText('Upgrade the vLLM environment that provides the selected vllm CLI, or use a compatible checkpoint. Do not assume Odysseus owns PATH/system/source/Docker installs.'),
},
],
},
{
@@ -218,6 +313,7 @@ export const ERROR_PATTERNS = [
pattern: /vllm.*command not found|No module named vllm/i,
message: 'vLLM is not installed or not in PATH.',
fixes: [
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('vllm') },
{ label: 'Check environment is set', action: (panel) => {
const el = panel.querySelector('[data-field="env_type"]');
if (el) { el.focus(); el.style.borderColor = 'var(--red)'; }
@@ -226,11 +322,21 @@ export const ERROR_PATTERNS = [
},
{
pattern: /sglang.*command not found|No module named sglang|SGLang is not installed/i,
message: 'SGLang is not installed or not in PATH. Open Cookbook → Dependencies and install sglang on this server.',
message: 'SGLang is not installed or not in PATH.',
fixes: [
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') },
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install "sglang[all]"') },
],
},
{
pattern: /No accelerator \(CUDA, XPU, HPU, NPU, MUSA, MPS\) is available|Triton is not supported on current platform/i,
message: 'SGLang needs a visible GPU/accelerator on this server.',
suggestion: 'Suggested action: switch this serve config to llama.cpp for CPU/local serving, or choose a GPU server.',
fixes: [
{ label: 'Switch to llama.cpp', action: (panel) => _openCpuServeEdit(panel) },
{ label: 'Choose GPU server', action: (panel) => _openServeEditFromDiagnosis(panel) },
],
},
{
pattern: /flashinfer.*version.*does not match|flashinfer-cubin version/i,
message: 'FlashInfer version mismatch.',
@@ -241,8 +347,12 @@ export const ERROR_PATTERNS = [
},
{
pattern: /torch\.cuda\.is_available\(\).*False|No CUDA runtime/i,
message: 'CUDA not available in this environment.',
fixes: [],
message: 'vLLM needs a visible CUDA/ROCm GPU.',
suggestion: 'Suggested action: switch this serve config to llama.cpp for CPU/local serving, or choose a GPU server.',
fixes: [
{ label: 'Switch to llama.cpp', action: (panel) => _openCpuServeEdit(panel) },
{ label: 'Choose GPU server', action: (panel) => _openServeEditFromDiagnosis(panel) },
],
},
{
pattern: /Engine core initialization failed/i,
@@ -280,32 +390,25 @@ export const ERROR_PATTERNS = [
message: 'Model architecture too new for installed vLLM/transformers.',
fixes: [
{ label: 'Try --trust-remote-code', action: (panel) => _serveAutoRetry(panel, '--trust-remote-code'), autofix: true },
{ label: 'Update vLLM on server', action: (panel) => {
const taskEl = panel.closest('.cookbook-task');
const task = taskEl ? _loadTasks().find(t => t.sessionId === taskEl.dataset.taskId) : null;
const host = task?.remoteHost || '';
const prefix = _buildEnvPrefix();
const pipCmd = prefix ? prefix + ' pip install -U vllm transformers' : 'pip install -U vllm transformers';
const cmd = host ? _sshCmd(host, pipCmd) : pipCmd;
// Run in tmux so it doesn't timeout
const name = 'update-vllm';
_launchServeTask(name, 'pip-update', cmd);
{ label: 'Update vLLM on server', action: () => {
// Use the venv's python3 by absolute path when configured (SSH non-
// interactive sessions often pick user-site Python over the venv).
const _vp = (_envState.env === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
_launchServeTask('update-vllm', 'pip-update', `${_vp} -m pip install -U vllm transformers`);
}},
],
},
{
pattern: /Either a revision or a version must be specified|transformers\.integrations\.hub_kernels|kernels\/layer/i,
message: 'vLLM/Transformers kernel package mismatch.',
message: 'Transformers/kernels package mismatch.',
fixes: [
{ label: 'Update vLLM/Transformers/kernels', action: (panel) => {
const taskEl = panel.closest('.cookbook-task');
const task = taskEl ? _loadTasks().find(t => t.sessionId === taskEl.dataset.taskId) : null;
const host = task?.remoteHost || '';
const prefix = _buildEnvPrefix();
const pipCmd = prefix ? prefix + ' python3 -m pip install -U vllm transformers kernels' : 'python3 -m pip install -U vllm transformers kernels';
const cmd = host ? _sshCmd(host, pipCmd) : pipCmd;
_launchServeTask('update-vllm-stack', 'pip-update', cmd);
{ label: 'Repair kernel package', action: () => {
const _vp = (_envState.env === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
_launchServeTask('repair-kernels', 'pip-update', `${_vp} -m pip install --user --break-system-packages kernels<0.15`);
}},
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('sglang') },
],
},
{
@@ -319,13 +422,33 @@ export const ERROR_PATTERNS = [
pattern: /llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'/i,
message: 'llama-cpp-python server is not installed. Run: pip install "llama-cpp-python[server]"',
fixes: [
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('llama_cpp') },
{ label: 'Copy install command', action: () => _copyText('pip install "llama-cpp-python[server]"') },
],
},
{
pattern: /Windows Error 0xc000001d|Illegal instruction|0xc000001d/i,
message: 'AVX2 Instruction Set Mismatch: the precompiled llama-cpp-python wheel requires CPU features (AVX2/FMA) that your processor or virtual machine lacks.',
suggestion: 'Suggested action: switch this serve config to Ollama (highly recommended, has dynamic CPU fallbacks), or choose a remote Linux GPU server.',
fixes: [
{ label: 'Switch to Ollama', action: (panel) => _openServeEditFromDiagnosis(panel, { backend: 'ollama' }) },
{ label: 'Choose remote server', action: (panel) => _openServeEditFromDiagnosis(panel) },
],
},
{
pattern: /CUDA Toolkit not found|Unable to find cudart library|missing:\s*CUDA_CUDART/i,
message: 'llama.cpp found nvcc, but the CUDA runtime library is missing.',
suggestion: 'Suggested action: relaunch with the updated runner so llama.cpp builds CPU-only, or install a complete CUDA toolkit/runtime on this server for GPU llama.cpp.',
fixes: [
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('llama_cpp') },
],
},
{
pattern: /No module named ['"]?torch|No module named ['"]?diffusers|diffusers.*command not found/i,
message: 'Diffusion serving needs PyTorch and diffusers. Install diffusers from Cookbook → Dependencies.',
fixes: [
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('diffusers') },
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install "diffusers[torch]"') },
],
},
@@ -333,14 +456,10 @@ export const ERROR_PATTERNS = [
pattern: /Triton kernels.*Failed to import|cannot import name '\w+' from 'triton_kernels/i,
message: 'Triton kernels version mismatch. Non-fatal warning — model will still run, just without optimized MoE kernels.',
fixes: [
{ label: 'Update triton on server', action: (panel) => {
const taskEl = panel.closest('.cookbook-task');
const task = taskEl ? _loadTasks().find(t => t.sessionId === taskEl.dataset.taskId) : null;
const host = task?.remoteHost || '';
const prefix = _buildEnvPrefix();
const pipCmd = prefix ? prefix + ' pip install -U triton triton-kernels' : 'pip install -U triton triton-kernels';
const cmd = host ? _sshCmd(host, pipCmd) : pipCmd;
_launchServeTask('update-triton', 'pip-update', cmd);
{ label: 'Update triton on server', action: () => {
const _vp = (_envState.env === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
_launchServeTask('update-triton', 'pip-update', `${_vp} -m pip install -U triton triton-kernels`);
}},
],
},
@@ -362,14 +481,56 @@ export const ERROR_PATTERNS = [
pattern: /attention_sink|sliding.window.*not supported|sliding_window.*incompatible/i,
message: 'Model uses attention features unsupported in this vLLM version.',
fixes: [
{ label: 'Update vLLM on server', action: (panel) => {
const taskEl = panel.closest('.cookbook-task');
const task = taskEl ? _loadTasks().find(t => t.sessionId === taskEl.dataset.taskId) : null;
const host = task?.remoteHost || '';
const prefix = _buildEnvPrefix();
const pipCmd = prefix ? prefix + ' pip install -U vllm' : 'pip install -U vllm';
const cmd = host ? _sshCmd(host, pipCmd) : pipCmd;
_launchServeTask('update-vllm', 'pip-update', cmd);
{ label: 'Update vLLM on server', action: () => {
const _vp = (_envState.env === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
_launchServeTask('update-vllm', 'pip-update', `${_vp} -m pip install -U vllm`);
}},
],
},
{
// FlashInfer JIT-compiles attention kernels for the host GPU on first
// use. If the system /usr/bin/nvcc is older than CUDA 11.8 it can't
// target sm_89/sm_90 (Ada/Hopper), and the engine workers die before
// they can report a useful traceback. Two quick paths out: pick a
// non-flashinfer attention backend, or set CUDACXX to a newer nvcc
// (vLLM installs nvidia-cuda-nvcc into the venv — point at that).
pattern: /nvcc fatal\s+:\s+Unsupported gpu architecture 'compute_\d+'/i,
message: 'FlashInfer is JIT-compiling sampling kernels with an nvcc too old for this GPU (no sm_89 / sm_90 support — pre-CUDA 11.8). Changing the attention backend does not help — flashinfer JITs the SAMPLER too. The clean fix is to set VLLM_USE_FLASHINFER_SAMPLER=0 so vLLM uses its native sampler instead.',
suggestion: 'Suggested action: relaunch with VLLM_USE_FLASHINFER_SAMPLER=0 prepended. (Confirmed on the QuantTrio/Qwen3.5 model card as the canonical workaround.)',
fixes: [
{ label: 'Retry with VLLM_USE_FLASHINFER_SAMPLER=0', action: (panel) => _serveAutoRetryReplace(panel, '', 'VLLM_USE_FLASHINFER_SAMPLER=0 ', { prepend: true }) },
{ label: 'Uninstall flashinfer-python', action: () => {
// Hard fallback: vLLM 0.22 reaches into flashinfer for sampling kernels
// even with VLLM_USE_FLASHINFER_SAMPLER=0 in some configs. Removing
// the package forces it onto the native sampler.
const _vp = (_envState.env === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
_launchServeTask('uninstall-flashinfer', 'pip-update', `${_vp} -m pip uninstall flashinfer-python -y`);
}},
{ label: 'Edit serve', action: (panel) => _openServeEditFromDiagnosis(panel) },
],
},
{
// vLLM <-> torch ABI mismatch: vLLM imports torch.library helpers
// (`infer_schema`, `register_fake`, etc.) that only exist on newer torch
// versions. When the installed torch is older, the import fails before
// any server code runs. Fix is to reinstall vllm (which pulls a matching
// torch) or upgrade torch directly.
pattern: /ImportError: cannot import name '[^']+' from 'torch(\.\w+)+'/i,
message: 'vLLM was built against a newer torch than what is installed. Reinstall vLLM so pip pulls a compatible torch (or upgrade torch directly).',
fixes: [
{ label: 'Reinstall vLLM (pulls matching torch)', action: () => {
// Absolute path to the venv's python3 — bare `python3` lands in the
// wrong site-packages over SSH when ~/.local/bin precedes the venv.
const _vp = (_envState.env === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
_launchServeTask('reinstall-vllm', 'pip-reinstall', `${_vp} -m pip install --force-reinstall vllm`);
}},
{ label: 'Upgrade torch only', action: () => {
const _vp = (_envState.env === 'venv' && _envState.envPath)
? `${_envState.envPath.replace(/\/+$/, '')}/bin/python3` : 'python3';
_launchServeTask('upgrade-torch', 'pip-update', `${_vp} -m pip install -U torch`);
}},
],
},
@@ -402,10 +563,32 @@ export function _diagnose(text) {
return null;
}
function _diagnosisCopyBundle(task, diagnosis, sourceText, suggestionText) {
const lines = ['## Odysseus Cookbook troubleshooting'];
if (task) {
lines.push(
'',
'### Task',
`- ID: ${task.sessionId || task.id || 'unknown'}`,
`- Type: ${task.type || 'unknown'}`,
`- Status: ${task.status || 'unknown'}`,
`- Model: ${task.payload?.repo_id || task.name || 'unknown'}`,
`- Host: ${task.remoteHost || 'local'}${task.sshPort ? `:${task.sshPort}` : ''}`,
);
}
lines.push('', '### Diagnosis', diagnosis?.message || '(none)');
if (suggestionText) lines.push('', '### Suggested action', suggestionText.replace(/^Suggested action:\s*/i, ''));
const cmd = task?.payload?._cmd || '';
if (cmd) lines.push('', '### Launch command', '```bash', cmd, '```');
if (sourceText) lines.push('', '### Captured output', '```text', String(sourceText).trim(), '```');
return lines.join('\n');
}
export function _showDiagnosis(panel, diagnosis, sourceText) {
if (panel._lastDiagMsg === diagnosis.message) return;
if (panel._diagDismissed === diagnosis.message) return; // stay dismissed until new error
const wasCollapsed = panel._lastDiagMsg === diagnosis.message && panel._diagCollapsed;
if (panel._diagDismissed === diagnosis.message) return;
panel._lastDiagMsg = diagnosis.message;
panel._diagCollapsed = !!wasCollapsed;
let diag = panel.querySelector('.cookbook-diagnosis');
if (!diag) {
@@ -417,57 +600,81 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
}
diag.classList.remove('hidden');
diag.innerHTML = '';
const taskEl = panel?.closest?.('.cookbook-task');
const task = taskEl ? _loadTasks().find(t => t.sessionId === taskEl.dataset.taskId) : null;
const fixes = [...(diagnosis.fixes || [])];
if (task?.type === 'serve' && task.payload?._cmd && !fixes.some(f => f.label === 'Edit serve')) {
fixes.push({ label: 'Edit serve', action: (p) => _openServeEditFromDiagnosis(p) });
}
const suggestionText = diagnosis.suggestion || (fixes.length
? `Suggested action: ${fixes[0].label}.`
: 'Suggested action: copy the error and adjust the serve settings.');
const header = document.createElement('div');
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;';
// Simplified diagnosis card: just the error message + suggestion + fix
// button(s). Removed the fold toggle, copy button, and × dismiss — they
// made the card noisy without earning their keep. _diagCollapsed is kept
// as a stub so callers don't have to change.
panel._diagCollapsed = false;
const body = document.createElement('div');
body.className = 'cookbook-diag-body';
const msg = document.createElement('div');
msg.className = 'cookbook-diag-message';
msg.textContent = diagnosis.message;
header.appendChild(msg);
body.appendChild(msg);
const suggestion = document.createElement('div');
suggestion.className = 'cookbook-diag-suggestion';
suggestion.textContent = suggestionText;
body.appendChild(suggestion);
diag.appendChild(body);
const dismiss = document.createElement('button');
dismiss.className = 'close-btn';
dismiss.style.cssText = 'width:16px;height:16px;font-size:9px;flex-shrink:0;';
dismiss.textContent = '\u2715';
dismiss.addEventListener('click', () => { panel._diagDismissed = diagnosis.message; _clearDiagnosis(panel); });
header.appendChild(dismiss);
const runFix = async (fix, button, busyLabel = fix.label, onStart = null, onDone = null) => {
if (!fix || !button || button.dataset.busy) return;
button.dataset.busy = '1';
const _orig = button.textContent;
const wp = spinnerModule.createWhirlpool(12);
wp.element.style.cssText = 'display:inline-block;vertical-align:middle;width:12px;height:12px;margin-right:5px;';
button.textContent = '';
button.appendChild(wp.element);
const _lbl = document.createElement('span');
_lbl.textContent = busyLabel;
_lbl.style.verticalAlign = 'middle';
button.appendChild(_lbl);
try {
if (typeof onStart === 'function') onStart();
await fix.action(panel, sourceText);
} catch (err) {
console.error('[cookbook] diagnosis fix failed', err);
} finally {
if (button.isConnected) {
try { wp.destroy(); } catch {}
button.textContent = _orig;
delete button.dataset.busy;
}
if (typeof onDone === 'function') onDone();
}
};
diag.appendChild(header);
if (diagnosis.fixes && diagnosis.fixes.length) {
if (fixes.length) {
// Always render fixes as inline buttons. The old "Actions ▾" dropdown
// (for >3 fixes) was broken — the menu wouldn't open in some panels and
// hid useful actions behind a non-working affordance. Inline buttons wrap
// naturally in `.cookbook-diag-fixes` (flex-wrap) so a long list reflows
// onto multiple rows instead of getting collapsed.
const row = document.createElement('div');
row.className = 'cookbook-diag-fixes';
for (const fix of diagnosis.fixes) {
for (const fix of fixes) {
const btn = document.createElement('button');
btn.className = 'cookbook-btn cookbook-diag-btn';
btn.textContent = fix.label;
btn.addEventListener('click', async () => {
if (btn.dataset.busy) return;
btn.dataset.busy = '1';
// Spinner feedback while the fix runs (kill + relaunch takes a moment).
const _orig = btn.textContent;
const wp = spinnerModule.createWhirlpool(12);
wp.element.style.cssText = 'display:inline-block;vertical-align:middle;width:12px;height:12px;margin-right:5px;';
btn.textContent = '';
btn.appendChild(wp.element);
const _lbl = document.createElement('span');
_lbl.textContent = _orig;
_lbl.style.verticalAlign = 'middle';
btn.appendChild(_lbl);
try {
await fix.action(panel, sourceText);
} catch (e) {
console.error('[cookbook] diagnosis fix failed', e);
} finally {
// Retries animate the whole card away (button goes with it). For fixes
// that leave the card in place, restore the label.
if (btn.isConnected) { try { wp.destroy(); } catch {} btn.textContent = _orig; delete btn.dataset.busy; }
}
btn.type = 'button';
btn.innerHTML = _diagFixIcon(fix.label) + '<span class="cookbook-diag-btn-label">' + _diagEsc(fix.label) + '</span>';
btn.addEventListener('click', (e) => {
e.stopPropagation();
runFix(fix, btn);
});
row.appendChild(btn);
}
diag.appendChild(row);
body.appendChild(row);
}
}
+308 -38
View File
@@ -18,6 +18,8 @@ import {
_lastCacheHost,
_setLastCacheHost,
_serverByVal,
_serverKey,
_currentServerValue,
_shellQuote,
_MODELDIR_CHECK_ON,
_MODELDIR_CHECK_OFF,
@@ -48,6 +50,28 @@ let _removedHwChips = new Set();
export let _gpuToggleTotal = 0; // real GPU count from first scan, never overridden
function _firstGgufSource(model) {
const sources = Array.isArray(model?.gguf_sources) ? model.gguf_sources : [];
return sources.find(src => src && src.repo) || null;
}
function _looksLikeGgufRepo(model) {
const haystack = `${model?.quant_repo || ''} ${model?.repo_id || ''} ${model?.path || ''} ${model?.name || ''}`.toLowerCase();
return !!model?.is_gguf || haystack.includes('gguf') || haystack.includes('.gguf');
}
function _downloadSourceRepo(model, backend) {
if (backend === 'llamacpp') {
const ggufSource = _firstGgufSource(model);
if (ggufSource) return { repo: ggufSource.repo, kind: 'GGUF' };
if (_looksLikeGgufRepo(model)) {
const repo = model?.quant_repo || model?.repo_id || model?.name;
if (repo) return { repo, kind: 'GGUF' };
}
}
return { repo: model?.quant_repo || model?.name || '', kind: '' };
}
// Reset GPU-toggle state so the next scan re-renders the RAM/GPU buttons for a
// (possibly different) server, WITHOUT clearing the markup now — clearing it made
// the buttons flicker out and back in. The old buttons stay visible until the
@@ -131,14 +155,31 @@ export function _renderGpuToggles(system) {
}
const validCounts = _validTpCounts(poolSize);
const maxGpu = validCounts.length ? validCounts[validCounts.length - 1] : 0;
// Commit the data layer to maxGpu on initial render so it matches the
// visual highlight. Before this, _activeCount stayed undefined → no
// gpu_count param sent → backend's fallback could rank against RAM on
// mixed-resource boxes ("tightest" sorted by RAM instead of GPU).
if (container._activeCount === undefined && validCounts.length) {
container._activeCount = maxGpu;
}
html += '<button class="hwfit-gpu-btn" data-count="0" title="CPU / RAM only">RAM</button>';
const hasExplicitCount = typeof container._activeCount === 'number';
for (const n of validCounts) {
const text = n === 1 ? 'GPU' : n + ' GPU';
const isActive = hasExplicitCount ? (n === container._activeCount) : (container._activeCount === undefined && n === maxGpu);
const isActive = hasExplicitCount && n === container._activeCount;
html += `<button class="hwfit-gpu-btn${isActive ? ' active' : ''}" data-count="${n}" title="${n} GPU${n > 1 ? 's' : ''}">${text}</button>`;
}
// Also mark the RAM button active when the user explicitly chose RAM (0)
// — the loop above only handles GPU buttons.
if (container._activeCount === 0) {
const ramBtn = container.querySelector('.hwfit-gpu-btn[data-count="0"]');
// (we just set innerHTML so we re-mark below after assignment)
}
container.innerHTML = html;
if (container._activeCount === 0) {
const ramBtn = container.querySelector('.hwfit-gpu-btn[data-count="0"]');
if (ramBtn) ramBtn.classList.add('active');
}
// Pool dropdown: switch pools, reset the count to the new pool's max, rebuild.
const sel = container.querySelector('#hwfit-gpu-group');
@@ -166,11 +207,16 @@ export function _renderGpuToggles(system) {
} else {
btn.classList.add('active');
container._activeCount = count;
// Auto-set quant based on hardware selection
// Auto-suggest a quant based on hardware selection — but ONLY when the
// user has already picked a specific quant. When they're on "All"
// (value === ""), leave them on All: toggling a GPU shouldn't silently
// yank them out of the All view they wanted to see.
const quantSel = document.getElementById('hwfit-quant');
if (quantSel) {
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
}
@@ -189,9 +235,36 @@ export function _renderGpuToggles(system) {
// reload paints instantly, then we refresh in the background and swap.
const _SCAN_CACHE_KEY = 'hwfit_scan_cache_v1';
const _MANUAL_HW_KEY = 'hwfit_manual_hardware_v1';
const _CTX_KEY = 'hwfit_target_context_v1';
const _CTX_PRESETS = [8192, 16384, 32768, 50000, 131072, 0]; // 0 = model max
const _SCAN_CACHE_MAX = 12; // keep the newest N signatures
const _SCAN_CACHE_TTL = 6 * 3600 * 1000; // 6 h — hardware rarely changes
// Ctx slider helpers (ported from origin/main). The slider picks an INDEX into
// _CTX_PRESETS; _ctxValue() resolves it to a token count (0 = "Max"). The label
// next to the slider re-renders to "8k" / "16k" / … / "Max".
function _ctxLabel(value) {
const n = Number(value) || 0;
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');
if (!slider) return;
const saved = localStorage.getItem(_CTX_KEY);
const savedIdx = saved == null ? 3 : _CTX_PRESETS.indexOf(Number(saved));
slider.value = String(savedIdx >= 0 ? savedIdx : 3);
if (label) label.textContent = _ctxLabel(_ctxValue());
}
function _manualHwState() {
try {
const s = JSON.parse(localStorage.getItem(_MANUAL_HW_KEY) || '{}');
@@ -287,11 +360,13 @@ function _scanSig() {
const tc = document.getElementById('hwfit-gpu-toggles');
return JSON.stringify({
h: _envState.remoteHost || '',
hk: _currentServerValue(),
u: document.getElementById('hwfit-usecase')?.value || '',
s: document.getElementById('hwfit-search')?.value?.trim() || '',
o: sortEl?.value || '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(),
@@ -341,6 +416,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 || '';
@@ -360,7 +446,10 @@ export async function _hwfitFetch(fresh = false) {
if (_cached) {
_hwfitCache = _cached;
_hwfitRenderHw(hw, _cached.system);
_hwfitRenderList(list, _cached.models);
if (!remoteHost && _cached.system && _cached.system.platform) {
_envState.platform = _cached.system.platform;
}
_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).
@@ -381,15 +470,18 @@ export async function _hwfitFetch(fresh = false) {
_hwfitCache = null; // no instant paint — clear until the fetch returns
}
// Only fetch cached model IDs when server changes, not on every search/sort
if (!_cachedModelIds || _lastCacheHost() !== remoteHost) {
_setLastCacheHost(remoteHost);
const _cacheSrv = _envState.servers.find(s => s.host === remoteHost);
const remoteKey = _currentServerValue();
if (!_cachedModelIds || _lastCacheHost() !== remoteKey) {
_setLastCacheHost(remoteKey);
const _cacheSrv = _serverByVal(_envState.remoteServerKey || remoteHost);
const _cachePort = _cacheSrv?.port || '';
const _cacheParams = new URLSearchParams({ host: remoteHost }); if (_cachePort) _cacheParams.set('ssh_port', _cachePort); if (_cacheSrv?.platform) _cacheParams.set('platform', _cacheSrv.platform);
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;
@@ -405,6 +497,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 = '';
@@ -421,7 +514,7 @@ export async function _hwfitFetch(fresh = false) {
if (search) params.set('search', search);
if (remoteHost) {
params.set('host', remoteHost);
const _srv = _envState.servers.find(s => s.host === remoteHost);
const _srv = _serverByVal(_envState.remoteServerKey || remoteHost);
const _hp = _srv?.port || '';
if (_hp) params.set('ssh_port', _hp);
if (_srv?.platform) params.set('platform', _srv.platform);
@@ -440,6 +533,10 @@ 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));
// Fit-only filter — set by the dot in the Fit column header.
const _fitOnly = (() => { try { return localStorage.getItem('hwfit_fit_only_v1') === '1'; } catch { return false; } })();
if (_fitOnly) params.set('fit_only', '1');
}
const endpoint = isImageMode ? `/api/hwfit/image-models?${params}` : `/api/hwfit/models?${params}`;
const res = await fetch(endpoint);
@@ -488,6 +585,11 @@ export async function _hwfitFetch(fresh = false) {
}
_hwfitCache = data;
_hwfitRenderHw(hw, data.system);
// Propagate local platform from hardware probe so _isWindows(task) works
// for local tasks (menu items, shell commands, etc.).
if (!remoteHost && data.system && data.system.platform) {
_envState.platform = data.system.platform;
}
// Sort client-side by the active column so the highest↔lowest toggle is
// deterministic (the previous array .reverse() didn't reliably flip).
// 1st click on a column = highest first; clicking it again = lowest first.
@@ -495,13 +597,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)
@@ -547,8 +662,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')
? ''
@@ -694,8 +837,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' },
@@ -716,9 +873,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>`;
@@ -727,6 +885,13 @@ export function _hwfitRenderList(el, models) {
const sortSel = document.getElementById('hwfit-sort');
const currentSort = sortSel?.value || 'score';
const isReversed = sortSel?.dataset.reverse === '1';
// Active budget for the Fit column label \u2014 make it obvious whether the
// ranking is against GPU or RAM so "tightest" can't be ambiguous on a
// mixed-resource box.
const tc = document.getElementById('hwfit-gpu-toggles');
const _budget = (tc && typeof tc._activeCount === 'number')
? (tc._activeCount === 0 ? 'RAM' : (tc._activeCount === 1 ? 'GPU' : tc._activeCount + ' GPU'))
: null;
let html = '<div class="hwfit-row hwfit-header">';
for (const col of _hwfitColumns) {
const sortable = col.key ? ' hwfit-sortable' : '';
@@ -738,7 +903,16 @@ export function _hwfitRenderList(el, models) {
arrow = isReversed ? ' \u25B2' : ' \u25BC';
}
const dataAttr = col.key ? ` data-sort="${col.key}"` : '';
html += `<span class="hwfit-col ${col.cls}${sortable}${active}"${dataAttr}>${col.label}${arrow}</span>`;
// Fit column gets a small dot to its left that toggles "show only models
// that fit" — replaces the old Fits On/Off button next to the toolbar.
let label = col.label;
if (col.cls === 'hwfit-fit') {
const _fitOnly = (() => { try { return localStorage.getItem('hwfit_fit_only_v1') === '1'; } catch { return false; } })();
label = `<span class="hwfit-fit-dot${_fitOnly ? ' active' : ''}" title="${_fitOnly ? 'Showing only models that fit. Click to also show too-tight rows.' : 'Click to show only models that fit your hardware.'}" data-fit-dot>●</span>${col.label}`;
// (Budget tag removed — the GPU/RAM/N-GPU suffix next to "Fit" was noise;
// the toggle row already shows which budget is active.)
}
html += `<span class="hwfit-col ${col.cls}${sortable}${active}"${dataAttr}>${label}${arrow}</span>`;
}
html += '</div>';
for (const m of models) {
@@ -750,21 +924,43 @@ 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>' : '';
const dlDot = (_cachedModelIds && (_cachedModelIds.has(m.name) || [..._cachedModelIds].some(id => id === m.name?.split('/').pop()))) ? '<span class="hwfit-dl-dot" title="Downloaded">\u25CF</span>' : '';
html += `<div class="hwfit-row" data-model="${esc(m.name)}">`;
html += `<span class="hwfit-col hwfit-fit" style="color:${fitColor}">${esc(fitLabel)}</span>`;
html += `<span class="hwfit-col hwfit-name">${modelLogo(m.name)}${esc(m.name?.split('/').pop() || m.name)}${moeBadge}${imgBadge}${dlDot}</span>`;
// Append quant to the title when it's not already in the repo name. The
// suffix strips quant-parts the name already contains — e.g. for
// QuantTrio/MiniMax-M2-AWQ + quant=AWQ-4bit we just show "(4bit)", not
// "(AWQ-4bit)". DeepSeek-V4-Flash + FP4-MoE-Mixed keeps the full tag
// (none of those parts are in the repo id).
const _short = m.name?.split('/').pop() || m.name || '';
const _quantTag = (m.quant || '').trim();
const _lowerShort = _short.toLowerCase();
let _quantSuffix = '';
if (_quantTag) {
const _parts = _quantTag.split(/[-_]/).filter(Boolean);
const _remaining = _parts.filter(p => !_lowerShort.includes(p.toLowerCase()));
if (_remaining.length && _remaining.length < _parts.length + 1) { // at least one part is new
let _display = _remaining.join('-');
if (_display.length > 9) _display = _display.slice(0, 9) + '…';
_quantSuffix = ` <span class="hwfit-name-quant" title="${esc(_quantTag)} — full storage format">(${esc(_display)})</span>`;
}
}
html += `<span class="hwfit-col hwfit-name">${modelLogo(m.name)}${esc(_short)}${_quantSuffix}${moeBadge}${imgBadge}${dlDot}</span>`;
html += `<span class="hwfit-col hwfit-c-params">${esc(pcount)}</span>`;
html += `<span class="hwfit-col hwfit-c-quant">${esc(m.quant || '?')}</span>`;
// Truncate the Quant cell to 9 chars + ellipsis so long tags like
// "FP4-MoE-Mixed" don't push neighboring columns. Full tag stays in title.
const _qRaw = m.quant || '?';
const _qShort = _qRaw.length > 9 ? _qRaw.slice(0, 9) + '…' : _qRaw;
html += `<span class="hwfit-col hwfit-c-quant" title="${esc(_qRaw)}">${esc(_qShort)}</span>`;
html += `<span class="hwfit-col hwfit-c-vram">${vramLabel}</span>`;
html += `<span class="hwfit-col hwfit-c-ctx">${m.is_image_gen ? '\u2014' : ctx}</span>`;
html += `<span class="hwfit-col hwfit-c-speed">${m.is_image_gen ? '\u2014' : tps + ' t/s'}</span>`;
html += `<span class="hwfit-col hwfit-c-score">${score}</span>`;
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;
@@ -781,7 +977,26 @@ export function _hwfitRenderList(el, models) {
});
// Clickable header columns → sort (click again to toggle direction)
el.querySelectorAll('.hwfit-header .hwfit-sortable').forEach(col => {
col.addEventListener('click', () => {
col.addEventListener('click', (e) => {
// The little dot inside the Fit header is its own toggle (fit-only
// filter), don't let it fall through to a sort click.
if (e.target.closest('[data-fit-dot]')) {
const on = !e.target.classList.contains('active');
try { localStorage.setItem('hwfit_fit_only_v1', on ? '1' : '0'); } catch {}
// Un-toggling the fit filter (off → showing too-tight rows again) is
// typically because the user wants to see the LARGE models they can't
// run yet — re-sort by VRAM descending so the biggest surface first.
if (!on) {
const sortSel = document.getElementById('hwfit-sort');
if (sortSel) {
sortSel.value = 'vram';
sortSel.dataset.reverse = '0'; // descending (biggest first)
}
}
_hwfitCache = null;
_hwfitFetch();
return;
}
const sortKey = col.dataset.sort;
if (!sortKey) return;
const sel = document.getElementById('hwfit-sort');
@@ -813,11 +1028,13 @@ function _syncHostFromScanDropdown() {
let host = '';
if (ss.value === 'local') {
_envState.remoteHost = '';
_envState.remoteServerKey = '';
} else {
const s = _serverByVal(ss.value);
if (s) {
host = s.host;
_envState.remoteHost = s.host;
_envState.remoteServerKey = _serverKey(s);
_envState.env = s.env;
_envState.envPath = s.envPath;
_envState.platform = s.platform || '';
@@ -847,13 +1064,13 @@ export function _expandModelRow(row, modelData) {
const isLlamaCpp = backend === 'llamacpp';
const ctx = modelData.context || 8192;
const dlRepo = modelData.quant_repo || modelData.name;
const hfUrl = `https://huggingface.co/${dlRepo}`;
const dlSource = _downloadSourceRepo(modelData, backend);
const hfUrl = `https://huggingface.co/${dlSource.repo}`;
let html = `<div class="hwfit-action-panel" data-model-name="${esc(modelData.name)}">`;
html += `<div class="hwfit-panel-header">`;
html += `<span class="hwfit-panel-model">${esc(modelData.name)}${modelData.quant_repo ? ` <span style="opacity:0.5;font-size:10px;">(${esc(modelData.quant)})</span>` : ''}</span>`;
html += `<span class="hwfit-panel-model">${esc(modelData.name)}${dlSource.kind ? ` <span style="opacity:0.5;font-size:10px;">(${esc(dlSource.kind)} ${esc(modelData.quant || '')})</span>` : (modelData.quant_repo ? ` <span style="opacity:0.5;font-size:10px;">(${esc(modelData.quant)})</span>` : '')}</span>`;
html += `<span class="hwfit-panel-badge">${esc(label)}</span>`;
html += `<a href="${esc(hfUrl)}" target="_blank" rel="noopener" class="hwfit-panel-hf-link" title="View on HuggingFace">HF \u2197</a>`;
html += `<a href="${esc(hfUrl)}" target="_blank" rel="noopener" class="hwfit-panel-hf-link" title="View download source on HuggingFace">HF \u2197</a>`;
html += `</div>`;
html += `<div class="hwfit-panel-actions">`;
html += `<button class="cookbook-btn hwfit-dl-btn">Download</button>`;
@@ -864,6 +1081,17 @@ 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)) {
// Only show the "needs CUDA/ROCm" note when the host doesn't already have
// one. With a visible CUDA/ROCm accelerator the note is noise — the user
// can already serve the model and reading the warning on every row makes
// the panel feel like everything's broken.
const _sys = _hwfitCache?.system || {};
const _backend = (_sys.backend || '').toLowerCase();
const _hasGpuAccel = !!_sys.has_gpu && (_backend === 'cuda' || _backend === 'rocm');
if (!_hasGpuAccel) {
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>`;
@@ -987,7 +1215,7 @@ export function _expandModelRow(row, modelData) {
// Launch via serve API. Field names must match the backend ServeRequest
// schema (repo_id + cmd) — sending `command`/`model` failed Pydantic
// validation (422), which is why Run silently did nothing.
const _srv = (_envState.servers || []).find(s => s.host === host);
const _srv = _serverByVal(_envState.remoteServerKey || host);
const payload = {
repo_id: modelData.name,
cmd: cmd,
@@ -1060,11 +1288,51 @@ export function _hwfitInit() {
const uc = document.getElementById('hwfit-usecase');
const sort = document.getElementById('hwfit-sort');
const qpref = document.getElementById('hwfit-quant');
const ctx = document.getElementById('hwfit-context');
const ctxLabel = document.getElementById('hwfit-context-label');
const search = document.getElementById('hwfit-search');
const remote = document.getElementById('hwfit-host');
_syncCtxControl();
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', () => {
if (ctxLabel) ctxLabel.textContent = _ctxLabel(_ctxValue());
});
ctx.addEventListener('change', () => {
const targetCtx = _ctxValue();
try { localStorage.setItem(_CTX_KEY, String(targetCtx)); } catch {}
// Ctx drag affects sort mode: a specific ctx target (anything < Max)
// implies "what runs at this context length" — sort by VRAM ascending
// so the cheapest-fitting models surface first. Dragging back to Max
// releases the constraint → go back to the default score ranking.
const sortSel = document.getElementById('hwfit-sort');
if (sortSel) {
if (targetCtx) {
sortSel.value = 'vram';
sortSel.dataset.reverse = '1'; // ascending = smallest VRAM first
} else {
sortSel.value = 'score';
sortSel.dataset.reverse = '';
}
}
_hwfitCache = null;
_hwfitFetch();
});
}
// Rescan — force a fresh hardware probe (bypasses the per-host cache).
const rescan = document.getElementById('hwfit-rescan');
if (rescan && !rescan.dataset.bound) {
@@ -1166,7 +1434,7 @@ export function _hwfitInit() {
// dropdown still showed odysseus. The user's selection must only change via
// an explicit dropdown pick. Here we just refresh env/path if we can match
// the current host; otherwise leave remoteHost untouched.
const sel = _envState.servers.find(s => s.host === _envState.remoteHost);
const sel = _serverByVal(_envState.remoteServerKey || _envState.remoteHost);
if (sel) { _envState.env = sel.env; _envState.envPath = sel.envPath; }
_persistEnvState();
}
@@ -1342,15 +1610,16 @@ export function _hwfitInit() {
// (inline — _applyServerSelection lives in cookbook.js and isn't imported here).
const _dk = _envState.defaultServer;
if (_dk) {
if (_dk === 'local') { _envState.remoteHost = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = ''; }
else { const _s = (_envState.servers || []).find(x => x.host === _dk); if (_s) { _envState.remoteHost = _s.host; _envState.env = _s.env || 'none'; _envState.envPath = _s.envPath || ''; _envState.platform = _s.platform || ''; } }
if (_dk === 'local') { _envState.remoteHost = ''; _envState.remoteServerKey = ''; _envState.env = 'none'; _envState.envPath = ''; _envState.platform = ''; }
else { const _s = _serverByVal(_dk); if (_s) { _envState.remoteHost = _s.host; _envState.remoteServerKey = _serverKey(_s); _envState.env = _s.env || 'none'; _envState.envPath = _s.envPath || ''; _envState.platform = _s.platform || ''; } }
_persistEnvState();
document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => {
if (sel && sel.tagName === 'SELECT') sel.value = _envState.remoteHost || 'local';
if (sel && sel.tagName === 'SELECT') sel.value = _currentServerValue();
});
}
const defaultSrv = _serverByVal(_envState.defaultServer);
uiModule.showToast(_envState.defaultServer
? 'Default server: ' + (_envState.defaultServer === 'local' ? 'Local' : _envState.defaultServer)
? 'Default server: ' + (_envState.defaultServer === 'local' ? 'Local' : (defaultSrv?.name || defaultSrv?.host || 'selected server'))
: 'Default server cleared');
});
}
@@ -1604,12 +1873,14 @@ export function _hwfitInit() {
const val = serverSelect.value;
if (val === 'local') {
_envState.remoteHost = '';
_envState.remoteServerKey = '';
_envState.env = 'none';
_envState.envPath = '';
} else {
const s = _serverByVal(val);
if (s) {
_envState.remoteHost = s.host;
_envState.remoteServerKey = _serverKey(s);
_envState.env = s.env;
_envState.envPath = s.envPath;
}
@@ -1619,10 +1890,9 @@ export function _hwfitInit() {
// download-input button reads #hwfit-dl-server *directly*, so without this
// it kept its old value and downloads went to the wrong host even
// though the scan here correctly switched to the selected server.
// Option values are host strings now ('local' for the local box).
document.querySelectorAll('#hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => {
if (!sel || sel.tagName !== 'SELECT') return;
sel.value = _envState.remoteHost || 'local';
sel.value = _currentServerValue();
});
_hwfitCache = null;
// Reset GPU-toggle state (no flicker) so the new server's hardware re-renders.
+595 -155
View File
File diff suppressed because it is too large Load Diff
+132 -21
View File
@@ -12,6 +12,7 @@ let _envState;
let _sshCmd;
let _getPort;
let _getPlatform;
let _serverByVal;
let _isWindows;
let _buildEnvPrefix;
let _buildServeCmd;
@@ -57,21 +58,71 @@ export function _setPanelCheckbox(panel, field, checked) {
// ── Command builder: download ──
function _firstGgufSource(model) {
const sources = Array.isArray(model?.gguf_sources) ? model.gguf_sources : [];
return sources.find(src => src && src.repo) || null;
}
function _looksLikeGgufRepo(model) {
const haystack = `${model?.quant_repo || ''} ${model?.repo_id || ''} ${model?.path || ''} ${model?.name || ''}`.toLowerCase();
return !!model?.is_gguf || haystack.includes('gguf') || haystack.includes('.gguf');
}
function _ggufDownloadSource(model, backend) {
if (backend !== 'llamacpp') return null;
const source = _firstGgufSource(model);
if (source) return source;
if (_looksLikeGgufRepo(model)) {
const repo = model?.quant_repo || model?.repo_id || model?.name;
if (repo) return { repo };
}
return null;
}
function _ggufIncludePattern(model, source) {
if (source?.file) return source.file;
if (model?.quant) return `*${model.quant}*`;
return '*.gguf';
}
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.`;
}
function _bashQuote(value) {
return "'" + String(value ?? '').replace(/'/g, "'\\''") + "'";
}
function _missingGgufCommand(model) {
const msg = _missingGgufMessage(model);
if (_isWindows()) {
return `Write-Error ${JSON.stringify(msg)}; exit 1`;
}
return `printf '%s\\n' ${_bashQuote(msg)} >&2; exit 1`;
}
export function _buildDownloadCmd(model, backend) {
let cmd = '';
if (backend === 'ollama') {
cmd = `ollama pull ${model.name.split('/').pop().toLowerCase()}`;
} else {
const repo = (backend === 'llamacpp' && model.gguf_sources && model.gguf_sources.length)
? model.gguf_sources[0].repo : model.name;
const includeArg = (backend === 'llamacpp' && model.gguf_sources && model.gguf_sources.length)
? `, allow_patterns=["*${model.quant || ''}*"]` : '';
// Reflect the server's download target in the preview (matches the real
// download path built server-side). '' = default HF cache.
const _dlDir = (_envState.servers.find(s => s.host === (_envState.remoteHost || '')) || {}).downloadDir || '';
const _localDirArg = _dlDir ? `, local_dir=os.path.expanduser('${_dlDir.replace(/\/$/, '')}/${repo.split('/').pop()}')` : '';
const _py = _isWindows() ? 'python' : 'python3';
cmd = `${_py} -u -c "
const ggufSource = _ggufDownloadSource(model, backend);
if (backend === 'llamacpp' && !ggufSource) {
cmd = _missingGgufCommand(model);
} else {
const repo = ggufSource?.repo || model.name;
const includePattern = backend === 'llamacpp' ? _ggufIncludePattern(model, ggufSource) : null;
const includeArg = includePattern ? `, allow_patterns=["${includePattern.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"]` : '';
// Reflect the server's download target in the preview (matches the real
// download path built server-side). '' = default HF cache.
const _dlDir = (_serverByVal?.(_envState.remoteServerKey || _envState.remoteHost || '') || {}).downloadDir || '';
const _localDirArg = _dlDir ? `, local_dir=os.path.expanduser('${_dlDir.replace(/\/$/, '')}/${repo.split('/').pop()}')` : '';
const _py = _isWindows() ? 'python' : 'python3';
cmd = `${_py} -u -c "
import sys, time, os
os.environ['HF_HUB_DISABLE_PROGRESS_BARS']='0'
os.environ['TQDM_DISABLE']='0'
@@ -125,6 +176,7 @@ try:
except Exception as e:
print(f'ERROR {e}',file=sys.stderr,flush=True);sys.exit(1)
"`;
}
}
const prefix = _buildEnvPrefix();
let full = prefix ? prefix + ' ' + cmd : cmd;
@@ -402,10 +454,13 @@ export async function _runPanelCmd(panel, cmd, opts = {}) {
// ── Model download (dedicated endpoint, tmux-backed) ──
export async function _runModelDownload(panel, model, backend, hostOverride) {
const repo = (backend === 'llamacpp' && model.gguf_sources && model.gguf_sources.length)
? model.gguf_sources[0].repo : (model.quant_repo || model.name);
const include = (backend === 'llamacpp' && model.gguf_sources && model.gguf_sources.length)
? `*${model.quant || ''}*` : null;
const ggufSource = _ggufDownloadSource(model, backend);
if (backend === 'llamacpp' && !ggufSource) {
uiModule.showToast(_missingGgufMessage(model));
return;
}
const repo = ggufSource?.repo || model.quant_repo || model.name;
const include = backend === 'llamacpp' ? _ggufIncludePattern(model, ggufSource) : null;
_syncEnvFromPanel(panel);
@@ -421,10 +476,10 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
// No explicit host passed: resolve from the visible server dropdown rather
// than _envState.remoteHost (unreliable — multiple state copies disagree).
const ssEl = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server');
// Dropdown values are host strings now ('local' for local); resolve by host
// (numeric fallback for any stale value).
// Dropdown values are profile keys now ('local' for local); stale host
// strings and numeric indices still resolve for backwards compatibility.
const _ssv = ssEl ? ssEl.value : null;
const _dsrv = (_ssv && _ssv !== 'local') ? (_envState.servers.find(s => s.host === _ssv) || _envState.servers[parseInt(_ssv)]) : null;
const _dsrv = (_ssv && _ssv !== 'local') ? (_serverByVal?.(_ssv) || _envState.servers[parseInt(_ssv)]) : null;
if (_dsrv) {
host = _dsrv.host;
} else if (ssEl && ssEl.value === 'local') {
@@ -433,7 +488,7 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
host = _envState.remoteHost || '';
}
}
const srv = _envState.servers.find(s => s.host === host) || {};
const srv = _serverByVal?.(_envState.remoteServerKey || host) || {};
const env = host ? (srv.env || 'none') : (_envState.env || 'none');
const envPath = host ? (srv.envPath || '') : (_envState.envPath || '');
const platform = host ? (srv.platform || '') : (_envState.platform || '');
@@ -441,6 +496,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;
@@ -465,6 +524,55 @@ 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;
}
// Also catch zombie "done" tasks — the cookbook may have lost track of a
// download (server restart, stale state) while its tmux session is still
// alive on the host. Probe it; if alive, flip back to running + treat as
// duplicate so we don't kick off a second concurrent download writing to
// the same target dir.
const zombieCandidate = tasks.find(t => sameDownload(t)
&& ['done', 'error', 'crashed', 'stopped'].includes(t.status)
&& t.sessionId && !String(t.sessionId).startsWith('queue-'));
if (zombieCandidate) {
try {
const _zh = zombieCandidate.remoteHost || '';
const _zPort = (_serverByVal?.(_envState.remoteServerKey || _zh)
|| (_envState.servers || []).find(s => s.host === _zh) || {}).port;
const _sshPf = _zh ? `ssh ${_zPort && _zPort !== '22' ? `-p ${_zPort} ` : ''}${_zh} '` : '';
const _sshSf = _zh ? `'` : '';
const _probeCmd = `${_sshPf}tmux has-session -t ${zombieCandidate.sessionId} 2>/dev/null${_sshSf}`;
const _r = await fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: _probeCmd, timeout: 5 }),
});
const _d = await _r.json();
if (_d.exit_code === 0) {
// tmux still alive → not actually done. Revive + tell the user.
const _fresh = _loadTasks();
const _ft = _fresh.find(t => t.sessionId === zombieCandidate.sessionId);
if (_ft) {
_ft.status = 'running';
_ft._selfHealed = true;
_saveTasks(_fresh);
}
_renderRunningTab();
uiModule.showToast(`${shortName} is still downloading (was marked finished after a restart — revived)`);
return;
}
} catch { /* probe failed — fall through and let the user launch */ }
}
const activeOnHost = tasks.find(t => t.type === 'download' && (t.status === 'running' || t.status === 'queued') && (t.remoteHost || 'local') === targetHost);
if (activeOnHost) {
@@ -485,18 +593,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);
}
}
@@ -507,6 +617,7 @@ export function initDownload(shared) {
_sshCmd = shared._sshCmd;
_getPort = shared._getPort;
_getPlatform = shared._getPlatform;
_serverByVal = shared._serverByVal;
_isWindows = shared._isWindows;
_buildEnvPrefix = shared._buildEnvPrefix;
_buildServeCmd = shared._buildServeCmd;
+29
View File
@@ -0,0 +1,29 @@
// static/js/cookbookProgressSignal.js
/**
* Liveness signal for a running cookbook download/install. The watchdog treats a
* task as stalled when this signal stays unchanged for too long, so it must move
* whenever the task is genuinely making progress.
*
* During a model DOWNLOAD the honest signal is the downloaded-byte counter
* ("1.81G" from "1.81G/2.49G"): it climbs while transferring and freezes when
* stuck and unlike a % bar or speed/ETA it doesn't keep animating on a frozen
* frame. That path is kept exactly as-is.
*
* But a dependency install (e.g. vllm) spends long stretches with NO byte
* counter pip dependency resolution and the native CUDA build/compile. A
* byte-only signal freezes there, so the watchdog falsely declares the install
* stale and restarts it mid-build, looping forever (#1568). When there's no byte
* counter, fall back to a fingerprint of the output tail: resolver/compile lines
* keep changing while the process is alive, and only a truly hung process leaves
* the tail frozen.
*
* Pure (string in, string out) so it's unit-testable; cookbookRunning.js pulls
* in browser-only modules and can't load under node.
*/
export function computeProgressSignal(bytes, dlAgg, lastPct, snapshot) {
if (bytes) return bytes;
const base = dlAgg != null ? String(dlAgg) : (lastPct || '0');
// No byte counter → use the output tail so a build/resolve phase that emits new
// lines counts as progress instead of a false stall (#1568).
return base + '|' + String(snapshot || '').slice(-300);
}
+1183 -142
View File
File diff suppressed because it is too large Load Diff
+386
View File
@@ -0,0 +1,386 @@
// Cookbook Schedule — opens a small inline form (styled with the app's
// existing .cookbook-* classes) that creates a ScheduledTask with
// action=cookbook_serve. Mounted from two places:
//
// 1. The ^ button next to Launch in a serve panel.
// 2. The "Schedule…" entry in the cached-model ⋯ dropdown menu (which
// programmatically clicks the ^ button so this module owns the
// single source of truth).
//
// Feedback uses uiModule.showToast() — the same toast the rest of the
// app uses for "Saved", "Favorited", etc. — so the success message
// doesn't introduce a parallel notification style.
//
// To remove: delete this file + the <script> tag in index.html + the
// ^ button in cookbookServe.js + the "cookbook_serve" entry in
// BUILTIN_ACTIONS + src/cookbook_serve_lifecycle.py + its
// registration line in app.py.
try { (function () {
function _safe(fn) {
return function () {
try { return fn.apply(this, arguments); }
catch (e) { try { console.warn("[cookbookSchedule]", e); } catch (_) {} }
};
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
// Cached handle to the ui.js showToast function. Bound lazily on
// first use because ui.js is an ES module — it's not on `window`
// unless something else has explicitly exposed it.
let _toastFn = null;
async function _getToast() {
if (_toastFn) return _toastFn;
try {
const m = await import("/static/js/ui.js");
_toastFn = m.default?.showToast || m.showToast || null;
} catch (_) { _toastFn = null; }
return _toastFn;
}
// Optional opts: {action, onAction, duration, leadingIcon}
async function toast(msg, opts) {
const fn = await _getToast();
if (fn) {
try { fn(msg, opts); return; } catch (_) {}
}
try { console.log("[toast]", msg); } catch (_) {}
}
// Cached handle to the tasks module so the success toast's "Open"
// action can jump straight to the new task in the Tasks tab.
let _tasksMod = null;
async function _getTasksMod() {
if (_tasksMod) return _tasksMod;
try { _tasksMod = await import("/static/js/tasks.js"); } catch (_) {}
return _tasksMod;
}
async function openTaskInTasksTab(taskId) {
const m = await _getTasksMod();
if (m && typeof m.openTasks === "function") {
try { m.openTasks(taskId); return; } catch (_) {}
}
// Last-resort fallback: click the sidebar Tasks button.
document.getElementById("tool-tasks-btn")?.click();
}
const DAYS = [
{ k: "MO", l: "Mon", idx: 0 },
{ k: "TU", l: "Tue", idx: 1 },
{ k: "WE", l: "Wed", idx: 2 },
{ k: "TH", l: "Thu", idx: 3 },
{ k: "FR", l: "Fri", idx: 4 },
{ k: "SA", l: "Sat", idx: 5 },
{ k: "SU", l: "Sun", idx: 6 },
];
const WEEKDAYS = new Set(["MO","TU","WE","TH","FR"]);
// Resolve the model identity from the closest .memory-item card —
// that's the canonical container the cookbook serve UI uses, with
// the model repo on data-repo. We do NOT grab the title via
// textContent, because the title row also contains inline status
// pills ("running", "downloading") and an "HF ↗" link — pulling all
// of it in turns a clean preset name like "Qwen3.5-397B-A17B-AWQ"
// into "Qwen3.5-397B-A17B-AWQ running HF ↗", which then fails the
// preset lookup in action_cookbook_serve.
function readPanelConfig(arrowBtn) {
const item = arrowBtn.closest(".memory-item") || arrowBtn.closest(".hwfit-cached-item");
const panel = arrowBtn.closest(".hwfit-serve-panel");
const repo = item?.dataset?.repo
|| arrowBtn.closest(".hwfit-serve-panel")?.dataset?.repo
|| "";
// Title = last segment of the repo (after the final /), which is
// exactly what the cookbook UI renders in the card title and what
// the preset registry uses as its short name. e.g.
// cyankiwi/Qwen3.5-397B-A17B-AWQ → Qwen3.5-397B-A17B-AWQ
// Falls back to data-modelName or the bare repo for ollama-style
// entries that don't have a slash.
let title = "";
if (repo) {
title = repo.includes("/") ? repo.split("/").pop() : repo;
}
if (!title) {
title = item?.dataset?.modelName || "model";
}
return { panel, item, title, repo_id: repo, host: item?.dataset?.host || "" };
}
function buildFormHtml(cfg) {
return `
<div class="hwfit-schedule-form cookbook-panel">
<div class="hwfit-schedule-title">
<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="3" y="4" width="18" height="18" rx="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
<span class="hwfit-schedule-title-text">Schedule serve: <strong>${esc(cfg.title)}</strong></span>
<span class="hwfit-schedule-title-spacer"></span>
<label class="hwfit-schedule-mirror-toggle" title="Also create a calendar event on the Cookbook calendar">
<span class="hwfit-schedule-mirror-label">Create event in calendar</span>
<span class="admin-switch hwfit-schedule-mirror-switch">
<input type="checkbox" class="hwfit-sched-calendar-mirror" />
<span class="admin-slider"></span>
</span>
</label>
</div>
<div class="hwfit-schedule-row">
<label class="hwfit-schedule-field">
<span>From</span>
<input type="time" class="hwfit-sched-start cookbook-field-input" value="09:00" />
</label>
<label class="hwfit-schedule-field">
<span>Until</span>
<input type="time" class="hwfit-sched-end cookbook-field-input" value="17:00" />
</label>
</div>
<div class="hwfit-schedule-row hwfit-schedule-days-row">
<span class="hwfit-schedule-label">Days</span>
<div class="hwfit-sched-days">
${DAYS.map(d => `
<button type="button" class="hwfit-sched-day-chip${WEEKDAYS.has(d.k) ? " is-on" : ""}" data-day="${d.k}">${d.l}</button>
`).join("")}
</div>
<span class="hwfit-schedule-actions-spacer"></span>
<button type="button" class="cookbook-btn hwfit-sched-cancel" title="Cancel">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;flex-shrink:0;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
<span>Cancel</span>
</button>
<button type="button" class="cookbook-btn hwfit-sched-save" title="Save schedule" aria-label="Save schedule">
<svg 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:-1px;margin-right:5px;flex-shrink:0;"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<span>Save</span>
</button>
</div>
<div class="hwfit-sched-err"></div>
</div>`;
}
function openForm(arrowBtn) {
const cfg = readPanelConfig(arrowBtn);
const anchor = cfg.panel
|| cfg.item
|| arrowBtn.closest(".cookbook-saved-item")
|| arrowBtn.parentElement?.parentElement
|| arrowBtn.parentElement;
if (!anchor) {
toast("Couldn't find a panel to mount the schedule form");
return;
}
// Toggle.
const existing = anchor.querySelector(".hwfit-schedule-form");
if (existing) { existing.remove(); return; }
const tmp = document.createElement("div");
tmp.innerHTML = buildFormHtml(cfg);
const form = tmp.firstElementChild;
anchor.appendChild(form);
setTimeout(() => {
try { form.scrollIntoView({ behavior: "smooth", block: "nearest" }); } catch (_) {}
}, 50);
wireForm(form, cfg);
}
function wireForm(form, cfg) {
form.querySelectorAll(".hwfit-sched-day-chip").forEach(chip => {
chip.addEventListener("click", () => chip.classList.toggle("is-on"));
});
form.querySelector(".hwfit-sched-cancel").addEventListener("click", () => form.remove());
form.querySelector(".hwfit-sched-save").addEventListener("click", _safe(async () => {
const startTime = form.querySelector(".hwfit-sched-start").value;
const endTime = form.querySelector(".hwfit-sched-end").value;
const days = Array.from(form.querySelectorAll(".hwfit-sched-day-chip.is-on")).map(c => c.dataset.day);
const mirrorToCalendar = !!form.querySelector(".hwfit-sched-calendar-mirror")?.checked;
const errEl = form.querySelector(".hwfit-sched-err");
errEl.textContent = "";
errEl.classList.remove("is-visible");
function fail(msg) {
errEl.textContent = msg;
errEl.classList.add("is-visible");
}
if (!/^\d\d:\d\d$/.test(startTime) || !/^\d\d:\d\d$/.test(endTime)) {
return fail("Start and end must be HH:MM");
}
if (!days.length) {
return fail("Pick at least one day");
}
const [sh, sm] = startTime.split(":").map(Number);
const [eh, em] = endTime.split(":").map(Number);
let dur = (eh * 60 + em) - (sh * 60 + sm);
if (dur <= 0) dur += 24 * 60;
// The backend stores scheduled_time as UTC. The user picks
// wall-clock LOCAL time. Without converting, "09:55" in a UTC+9
// timezone gets stored as 09:55 UTC = 18:55 local → next-run
// shows ~9 hours later instead of "in 5 min". Mirror what
// tasks.js does via its _localTimeToUtc helper.
const _localHHMMToUtc = (hhmm) => {
const [h, m] = hhmm.split(":").map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
};
const startUtc = _localHHMMToUtc(startTime);
const [shUtc, smUtc] = startUtc.split(":").map(Number);
const allDays = days.length === 7;
const weekdaysOnly = days.length === 5 && ["MO","TU","WE","TH","FR"].every(d => days.includes(d));
const sched = {};
if (allDays) {
sched.schedule = "daily";
sched.scheduled_time = startUtc;
} else if (weekdaysOnly) {
sched.schedule = "cron";
sched.cron_expression = `${smUtc} ${shUtc} * * 1-5`;
} else if (days.length === 1) {
const dayIdx = DAYS.find(d => d.k === days[0]).idx;
sched.schedule = "weekly";
sched.scheduled_time = startUtc;
sched.scheduled_day = dayIdx;
} else {
const dayNum = days.map(k => {
const i = DAYS.find(d => d.k === k).idx;
return i === 6 ? 0 : i + 1;
});
sched.schedule = "cron";
sched.cron_expression = `${smUtc} ${shUtc} * * ${dayNum.join(",")}`;
}
// Name: "Serve: <full model name>" — pulled from .memory-item-title
// so it's the user's display name (e.g. "Qwen3.5-397B-A17B-AWQ")
// not a placeholder like "model".
const fullName = (cfg.title || cfg.repo_id || "").trim() || "model";
const payload = {
name: `Serve: ${fullName}`,
task_type: "action",
action: "cookbook_serve",
trigger_type: "schedule",
prompt: JSON.stringify({
preset: fullName,
repo_id: cfg.repo_id || "",
host: cfg.host || "",
end_after_min: dur,
}),
...sched,
};
const saveBtn = form.querySelector(".hwfit-sched-save");
saveBtn.disabled = true;
saveBtn.textContent = "Saving…";
try {
const r = await fetch("/api/tasks", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await r.json();
if (!r.ok || data.error) {
fail(data.error || data.detail || `HTTP ${r.status}`);
saveBtn.disabled = false;
saveBtn.textContent = "Save schedule";
toast(`Schedule save failed: ${data.error || data.detail || r.status}`);
return;
}
if (mirrorToCalendar) {
// Mirror onto a dedicated "Cookbook" calendar so the user can
// toggle the whole set on/off as a unit in the calendar UI.
// Best-effort: if anything here fails, we still consider the
// task creation a success (the task itself works regardless).
try {
const calsRes = await fetch("/api/calendar/calendars", { credentials: "same-origin" });
const calsBody = calsRes.ok ? await calsRes.json() : {};
let cookbookCal = (calsBody.calendars || []).find(c => (c.name || "").toLowerCase() === "cookbook");
if (!cookbookCal) {
const mk = await fetch("/api/calendar/calendars?name=Cookbook&color=%233b82f6", {
method: "POST", credentials: "same-origin",
});
if (mk.ok) {
const mkData = await mk.json();
// The create endpoint returns {ok, id, name, color}; the
// list endpoint returns {href, name, color}. The two map
// 1:1 (href === id) so we synthesize the same shape.
cookbookCal = { href: mkData.id, name: mkData.name, color: mkData.color };
}
}
// The `cookbook_task_id:` marker on its own line lets
// calendar.js's event-form code detect that this event was
// created from a Cookbook schedule and render an
// "Open task" button alongside the description, so the user
// can jump straight to the source task from the calendar UI.
const evBody = {
summary: payload.name,
dtstart: new Date().toISOString(),
dtend: new Date(Date.now() + dur * 60 * 1000).toISOString(),
all_day: false,
description: `Auto-mirrored from Cookbook schedule task ${data.id || ""}.\n`
+ `Edit/delete the task in the Tasks tab — this event will follow.\n`
+ `cookbook_task_id: ${data.id || ""}`,
rrule: weekdaysOnly
? "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"
: (sched.schedule === "weekly" ? `FREQ=WEEKLY;BYDAY=${days.join(",")}`
: (sched.schedule === "daily" ? "FREQ=DAILY" : "FREQ=WEEKLY")),
color: "#3b82f6",
};
if (cookbookCal?.href) evBody.calendar_href = cookbookCal.href;
const evRes = await fetch("/api/calendar/events", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(evBody),
});
const evData = evRes.ok ? await evRes.json() : null;
// Stash the event uid + calendar href on the task's prompt
// JSON so the task-delete hook can cascade the calendar
// cleanup. PATCH the task with an updated prompt.
if (evData && (evData.uid || evData.id)) {
const eventUid = evData.uid || evData.id;
try {
const updatedPrompt = JSON.stringify({
...JSON.parse(payload.prompt),
cookbook_event_uid: eventUid,
cookbook_event_calendar: cookbookCal?.href || "",
});
// /api/tasks/{id} accepts PUT, not PATCH — sending PATCH
// here silently failed (no such method on that route), so
// the task never got the cookbook_event_uid marker and the
// server-side delete-cascade had nothing to follow when the
// user later deleted the task.
await fetch(`/api/tasks/${encodeURIComponent(data.id)}`, {
method: "PUT", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: updatedPrompt }),
});
} catch (_) {}
}
} catch (_) {}
}
form.remove();
const newTaskId = data.id || data.task_id || "";
toast(`Created task: Serve: ${fullName}`, {
leadingIcon: "check",
action: "Open",
duration: 5000,
onAction: () => openTaskInTasksTab(newTaskId),
});
} catch (e) {
fail(String(e));
saveBtn.disabled = false;
saveBtn.textContent = "Save schedule";
toast(`Schedule save failed: ${e}`);
}
}));
}
document.addEventListener("click", _safe((e) => {
const arrow = e.target.closest && e.target.closest(".hwfit-serve-schedule-arrow");
if (!arrow) return;
e.preventDefault();
e.stopPropagation();
openForm(arrow);
}));
})(); } catch (e) { try { console.warn("[cookbookSchedule] top-level error:", e); } catch (_) {} }
File diff suppressed because it is too large Load Diff
+533 -73
View File
@@ -29,6 +29,7 @@ import * as Modals from './modalManager.js';
let _htmlPreviewActive = false; // true when inline HTML preview iframe is showing
let _emailAccountsCache = null;
let _emailAccountsCacheAt = 0;
let _emailHeaderManualExpandUntil = 0;
// Diff mode state
let _diffModeActive = false;
@@ -152,6 +153,8 @@ import * as Modals from './modalManager.js';
addDocToTabs,
syncDocIndicator: _syncDocIndicator,
});
_maybeOpenDocFromHash();
window.addEventListener('hashchange', _maybeOpenDocFromHash);
}
/** Update overflow-doc-btn accent indicator, toolbar indicator, and session list icon */
@@ -2243,7 +2246,9 @@ import * as Modals from './modalManager.js';
// WYSIWYG body — use it verbatim. (Checking a leading '<' isn't enough: a
// rich body often starts with plain text, e.g. "Hi <b>there</b>".)
if (/<\/?(b|i|u|s|strong|em|del|strike|a|p|div|br|ul|ol|li|h[1-3]|blockquote|span|code|pre)\b[^>]*>/i.test(t)) return t;
try { return markdownModule.mdToHtml(text); }
// Email body: keep author-typed `:shortcode:` text literal. Issue #345
// (shortcode → emoji) is scoped to chat; do not rewrite colons in mail.
try { return markdownModule.mdToHtml(text, { shortcodes: false }); }
catch (_) {
const d = document.createElement('div'); d.textContent = text;
return d.innerHTML.replace(/\n/g, '<br>');
@@ -2306,6 +2311,95 @@ 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 };
const lines = original.split('\n');
const quoteIdx = lines.findIndex(line =>
/^-{5,}\s*Previous message\s*-{5,}$/i.test(line.trim())
|| /^On .+ wrote:\s*$/i.test(line.trim())
);
if (quoteIdx <= 0) return { body: original.trim(), stripped: false };
const body = lines.slice(0, quoteIdx).join('\n').trim();
return { body, stripped: !!body };
}
function _emailReplyOwnText(text) {
return _stripEmailReplyQuoteText(text).body;
}
function _setEmailBodyText(textarea, value) {
if (!textarea) return;
textarea.value = value || '';
syncHighlighting();
const rich = _emailRichbodyActive();
if (rich) rich.innerHTML = _emailBodyToHtml(textarea.value);
}
async function _streamEmailBodyText(textarea, value) {
if (!textarea) return;
const finalText = String(value || '');
const maxFrames = 90;
const chunk = Math.max(8, Math.ceil(finalText.length / maxFrames));
textarea.value = '';
const rich = _emailRichbodyActive();
if (rich) rich.innerHTML = '';
for (let i = 0; i < finalText.length; i += chunk) {
const next = finalText.slice(0, i + chunk);
textarea.value = next;
if (rich) rich.innerHTML = _emailBodyToHtml(next);
await new Promise(resolve => requestAnimationFrame(resolve));
}
_setEmailBodyText(textarea, finalText);
}
function _focusEmailBodyEnd() {
const target = _emailRichbodyActive() || document.getElementById('doc-editor-textarea');
if (!target) return;
@@ -2325,6 +2419,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');
@@ -2363,6 +2499,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) => {
@@ -2504,6 +2641,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) {
@@ -2795,10 +2933,12 @@ import * as Modals from './modalManager.js';
const references = document.getElementById('doc-email-references')?.value?.trim();
const sourceUid = document.getElementById('doc-email-source-uid')?.value?.trim();
const sourceFolder = document.getElementById('doc-email-source-folder')?.value?.trim() || 'INBOX';
const body = document.getElementById('doc-editor-textarea')?.value?.trim();
// WYSIWYG: the rich body's HTML becomes the email's HTML part (server
// sanitizes it). `body` (plain text mirror) stays the text/plain fallback.
const _rich = _emailRichbodyActive();
if (_rich) _syncEmailRichbody(_rich);
const textarea = document.getElementById('doc-editor-textarea');
const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
const bodyHtml = _rich ? _rich.innerHTML : null;
const doc = docs.get(activeDocId);
const attachments = (doc?._composeAtts || []).map(a => a.token);
@@ -2806,6 +2946,10 @@ import * as Modals from './modalManager.js';
if (uiModule) uiModule.showError('To and body are required');
return;
}
if (inReplyTo && !_emailReplyOwnText(body)) {
if (uiModule) uiModule.showError('Reply body is empty');
return;
}
// Warn if body mentions attachments but none are actually attached
if (attachments.length === 0 && _bodyMentionsAttachment(body)) {
const proceed = await _confirmMissingAttachment();
@@ -2829,12 +2973,13 @@ import * as Modals from './modalManager.js';
let canceled = false;
if (uiModule) {
uiModule.showToast('Sending', {
duration: 1200,
duration: 3200,
leadingIcon: 'spinner',
action: 'Cancel',
onAction: () => { canceled = true; },
});
}
await _sleep(1000);
await _sleep(3000);
if (!canceled) detachedEmailDoc = _detachActiveEmailForBackground(sendDocId);
await _sleep(200);
if (canceled) {
@@ -2844,28 +2989,10 @@ import * as Modals from './modalManager.js';
return;
}
let undone = false;
if (uiModule) {
uiModule.showToast('Message sent', {
duration: 2200,
leadingIcon: 'check',
action: 'Undo',
actionHint: 'undo send',
onAction: () => { undone = true; },
});
}
await _sleep(2200);
if (undone) {
_restoreDetachedEmailDoc(detachedEmailDoc);
detachedEmailDoc = null;
if (uiModule) uiModule.showToast('Send undone');
return;
}
if (uiModule) uiModule.showToast('Sending...', 2000);
const activeAccountId = await _resolveComposeSendAccountId();
const res = await fetch(`${API_BASE}/api/email/send`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to, cc: cc || null, bcc: bcc || null, subject, body, body_html: bodyHtml,
@@ -2875,7 +3002,13 @@ import * as Modals from './modalManager.js';
wait_for_delivery: true,
}),
});
const data = await res.json();
let data = null;
try {
data = await res.json();
} catch (_) {
data = { success: false, error: `Send failed (${res.status})` };
}
if (!res.ok && data && !data.error) data.error = `Send failed (${res.status})`;
if (data.success) {
if (uiModule) {
uiModule.showToast('Message sent', {
@@ -2961,8 +3094,10 @@ import * as Modals from './modalManager.js';
const subject = document.getElementById('doc-email-subject')?.value?.trim();
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim();
const references = document.getElementById('doc-email-references')?.value?.trim();
const body = document.getElementById('doc-editor-textarea')?.value?.trim();
const _rich = _emailRichbodyActive();
if (_rich) _syncEmailRichbody(_rich);
const textarea = document.getElementById('doc-editor-textarea');
const body = (_rich ? (_rich.innerText || _rich.textContent || '') : (textarea?.value || '')).trim();
const bodyHtml = _rich ? _rich.innerHTML : null;
const btn = document.getElementById('doc-email-draft-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Saving...'; }
@@ -3021,19 +3156,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();
@@ -3074,6 +3212,32 @@ import * as Modals from './modalManager.js';
const textarea = document.getElementById('doc-editor-textarea');
if (!textarea) return;
const currentBody = textarea.value || '';
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim() || '';
const sourceUid = document.getElementById('doc-email-source-uid')?.value?.trim() || '';
const sourceFolder = document.getElementById('doc-email-source-folder')?.value?.trim() || 'INBOX';
const cleanAiReplyText = (text) => {
if (!text) return '';
let t = String(text);
const open = /<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/i;
const close = /<<<\s*END\s*>>+/i;
const m = open.exec(t);
if (m) {
const rest = t.slice(m.index + m[0].length);
const c = close.exec(rest);
t = c ? rest.slice(0, c.index) : rest;
}
return t
.replace(/<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/gi, '')
.replace(/<<<\s*END\s*>>+/gi, '')
.trim();
};
const shouldUseFastAiReply = () => {
const text = `${subject}\n${currentBody}`.toLowerCase();
if (/\b(attach(?:ed|ment)?|pdf|document|contract|invoice|receipt|quote|estimate|proposal|question|questions|details|schedule|booking|reservation|meeting|calendar|availability|confirm|confirmation|review|sign|signature)\b/.test(text)) {
return false;
}
return currentBody.length < 2500;
};
// Use the current chat model
let currentModel = '';
@@ -3096,22 +3260,24 @@ import * as Modals from './modalManager.js';
original_body: currentBody,
model: currentModel,
session_id: currentSessionId,
message_id: inReplyTo,
uid: sourceUid,
folder: sourceFolder,
fast: shouldUseFastAiReply(),
}),
});
const data = await res.json();
if (data.success && data.reply) {
const cleanReply = cleanAiReplyText(data.reply);
const lines = currentBody.split('\n');
const quoteIdx = lines.findIndex(l => l.startsWith('On ') && l.includes(' wrote:'));
let newBody = '';
if (quoteIdx > 0) {
const newBody = data.reply + '\n\n' + lines.slice(quoteIdx).join('\n');
textarea.value = newBody;
newBody = cleanReply + '\n\n' + lines.slice(quoteIdx).join('\n');
} else {
textarea.value = data.reply + (currentBody ? '\n\n' + currentBody : '');
newBody = cleanReply + (currentBody ? '\n\n' + currentBody : '');
}
syncHighlighting();
// Mirror into the WYSIWYG rich body if it's the active editor.
const _rb = _emailRichbodyActive();
if (_rb) _rb.innerHTML = _emailBodyToHtml(textarea.value);
await _streamEmailBodyText(textarea, newBody);
if (uiModule) uiModule.showToast(`AI draft inserted (${data.model_used || 'AI'})`);
} else {
if (uiModule) uiModule.showError(data.error || 'Failed to generate reply');
@@ -3130,7 +3296,12 @@ import * as Modals from './modalManager.js';
const subject = document.getElementById('doc-email-subject')?.value?.trim();
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim();
const references = document.getElementById('doc-email-references')?.value?.trim();
const body = document.getElementById('doc-editor-textarea')?.value?.trim();
const _rich = _emailRichbodyActive();
if (_rich) _syncEmailRichbody(_rich);
const body = (_rich
? (_rich.innerText || _rich.textContent || '')
: (document.getElementById('doc-editor-textarea')?.value || '')
).trim();
const doc = docs.get(activeDocId);
const attachments = (doc?._composeAtts || []).map(a => a.token);
@@ -3138,6 +3309,10 @@ import * as Modals from './modalManager.js';
if (uiModule) uiModule.showError('To and body are required');
return;
}
if (inReplyTo && !_emailReplyOwnText(body)) {
if (uiModule) uiModule.showError('Reply body is empty');
return;
}
if (attachments.length === 0 && _bodyMentionsAttachment(body)) {
const proceed = await _confirmMissingAttachment();
if (!proceed) return;
@@ -3553,6 +3728,9 @@ import * as Modals from './modalManager.js';
_minimizedDocId = null;
Modals.unregister('doc-panel');
}
const container = document.getElementById('chat-container');
if (!container) return;
isOpen = true;
// Doc was opened last → it goes in front of the email windows (clears the
// email-front flag; the doc/email z-index alternation lives in CSS).
@@ -3560,9 +3738,6 @@ import * as Modals from './modalManager.js';
_ensureAgentMode();
_markDocVisibleState(_lastSessionId, 'open');
const container = document.getElementById('chat-container');
if (!container) return;
document.body.classList.add('doc-view');
// Sync toggle button state
@@ -3670,25 +3845,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" />
@@ -4230,6 +4411,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();
@@ -4272,11 +4480,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
@@ -5680,6 +5890,41 @@ import * as Modals from './modalManager.js';
}));
}
export async function replaceEmailReplyBody(docId, replyText) {
const doc = docs.get(docId);
if (!doc) return;
const fields = _parseEmailHeader(doc.content || '');
const lines = String(fields.body || '').split('\n');
const quoteIdx = lines.findIndex(line =>
/^-{5,}\s*Previous message\s*-{5,}$/i.test(line.trim())
|| /^On .+ wrote:\s*$/i.test(line.trim())
);
const quote = quoteIdx >= 0 ? lines.slice(quoteIdx).join('\n') : '';
const ownText = _emailReplyOwnText(fields.body || '');
if (ownText && !/^(\[AI reply draft will appear here\]|Drafting AI reply)/i.test(ownText)) {
if (uiModule) uiModule.showToast('AI reply ready, but draft was edited');
return;
}
const body = String(replyText || '').trim() + (quote ? `\n\n${quote}` : '');
doc.content = _buildEmailContent(
fields.to,
fields.subject,
fields.inReplyTo,
fields.references,
body,
fields.sourceUid,
fields.sourceFolder,
fields.cc,
fields.bcc,
);
if (activeDocId === docId) {
const textarea = document.getElementById('doc-editor-textarea');
if (textarea) await _streamEmailBodyText(textarea, body);
}
clearTimeout(_autoSaveDebounce);
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
}
// Force the panel into a genuinely-open state. `isOpen` can be true while the
// pane was torn down by another full-screen view (e.g. opening a doc from the
// email modal): in that case openPanel() early-returns and nothing mounts, so
@@ -5700,16 +5945,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
@@ -6064,13 +6324,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 */
@@ -6078,7 +6495,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)`;
}
}
@@ -7971,7 +8388,7 @@ import * as Modals from './modalManager.js';
const text = textarea.value || '';
let body;
if (lang === 'markdown' && markdownModule?.mdToHtml) {
body = markdownModule.mdToHtml(text);
body = markdownModule.mdToHtml(text, { shortcodes: false }); // export: keep :shortcodes: literal
} else {
body = '<pre style="white-space:pre-wrap;font-size:12px;font-family:monospace;">' +
text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</pre>';
@@ -8002,7 +8419,7 @@ import * as Modals from './modalManager.js';
// Render content as HTML for PDF
let html;
if (lang === 'markdown' && markdownModule?.mdToHtml) {
html = markdownModule.mdToHtml(text);
html = markdownModule.mdToHtml(text, { shortcodes: false }); // export: keep :shortcodes: literal
} else {
html = '<pre style="white-space:pre-wrap;font-size:11px;font-family:monospace;color:#000;background:#fff;">' +
text.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</pre>';
@@ -8132,13 +8549,16 @@ import * as Modals from './modalManager.js';
if (active) {
const md = textarea.value || '';
if (markdownModule && markdownModule.mdToHtml) {
preview.innerHTML = markdownModule.mdToHtml(md);
preview.innerHTML = markdownModule.mdToHtml(md, { shortcodes: false }); // doc preview: keep :shortcodes: literal
} else {
preview.innerHTML = md.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\n/g, '<br>');
}
if (window.hljs) {
preview.querySelectorAll('pre code').forEach(b => window.hljs.highlightElement(b));
}
if (markdownModule && markdownModule.renderMermaid) {
markdownModule.renderMermaid(preview);
}
preview.style.display = '';
wrap.style.display = 'none';
} else {
@@ -8558,6 +8978,14 @@ import * as Modals from './modalManager.js';
/** Open the document panel immediately for a doc being streamed in */
export function streamDocOpen(title, language) {
// Discard any pending AI-edit diff before this stream changes the active
// document. When the AI streams a NEW document while an unapproved diff is
// open on the current one, streamDocOpen reassigns activeDocId below; if the
// stale diff isn't cleared first, a later exitDiffMode applies the old doc's
// content to the new one and overwrites it (issue #2467). activeDocId still
// points at the previously-active doc here, so exitDiffMode(true) restores
// and saves THAT doc — same guard handleDocUpdate/switchToDoc use.
if (_diffModeActive) exitDiffMode(true);
// If already streaming a doc, reuse it (don't create a second temp doc)
if (_streamDocId && docs.has(_streamDocId)) {
const existing = docs.get(_streamDocId);
@@ -8776,9 +9204,36 @@ import * as Modals from './modalManager.js';
return oldId;
}
function _isMarkdownPreviewVisible() {
const preview = document.getElementById('doc-md-preview');
return !!(preview && preview.style.display !== 'none');
}
function _refreshMarkdownPreviewIfVisible(docId, content) {
if (!_isMarkdownPreviewVisible()) return false;
const doc = docs.get(docId);
const lang = ((doc && doc.language) || document.getElementById('doc-language-select')?.value || '').toLowerCase();
if (lang !== 'markdown') return false;
const textarea = document.getElementById('doc-editor-textarea');
if (textarea) textarea.value = content;
syncHighlighting();
_setMarkdownPreviewActive(true, { remember: false });
return true;
}
/** Handle SSE doc_update event from AI */
export function handleDocUpdate(data) {
const streamingId = streamDocFinalize();
// Discard any pending AI-edit diff before this update changes the active
// document. The diff state (_diffModeActive/_diffOldContent/...) is a
// module-global singleton bound to whatever doc was active when the diff
// opened; if we switch documents without clearing it, a later tab switch or
// Accept/Reject-All flushes the stale diff's content into the now-active
// doc and silently overwrites it (issue #2467). activeDocId still points at
// the previously-active doc here, so exitDiffMode(true) restores and saves
// THAT doc before we reassign activeDocId below — mirroring switchToDoc()
// and enterDiffMode().
if (_diffModeActive) exitDiffMode(true);
let docId = data.doc_id;
const newContent = data.content || '';
@@ -8885,6 +9340,7 @@ import * as Modals from './modalManager.js';
if (docLang && langSelect) langSelect.value = docLang;
if (!docLang) attemptAutoDetect();
const isEmailUpdate = (docLang || '').toLowerCase() === 'email';
const markdownPreviewWasVisible = _isMarkdownPreviewVisible();
// Animate content update for edits; apply directly for creates/streaming
const isEdit = !isEmailUpdate && isExistingDoc && oldContent && oldContent !== newContent && !streamingId;
@@ -8898,7 +9354,10 @@ import * as Modals from './modalManager.js';
if (oldLines[li] !== newLines[li]) changedLines++;
}
if (changedLines >= DIFF_MODE_THRESHOLD) {
if (markdownPreviewWasVisible) _setMarkdownPreviewActive(false, { remember: false });
enterDiffMode(oldContent, newContent);
} else if (markdownPreviewWasVisible && _refreshMarkdownPreviewIfVisible(docId, newContent)) {
// Preview is the visible surface, so refresh it instead of animating a hidden editor.
} else {
_animateDocEdit(textarea, newContent);
}
@@ -8912,6 +9371,7 @@ import * as Modals from './modalManager.js';
} else {
if (textarea) textarea.value = newContent;
syncHighlighting();
_refreshMarkdownPreviewIfVisible(docId, newContent);
}
}
+139 -43
View File
@@ -10,6 +10,7 @@ import spinnerModule from './spinner.js';
import markdownModule from './markdown.js';
import { makeWindowDraggable } from './windowDrag.js';
import { langIcon } from './langIcons.js';
import { registerMenuDismiss, dismissOrRemove } from './escMenuStack.js';
// ── Injected references from documentModule ──
let API_BASE = '';
@@ -75,6 +76,15 @@ function _hlSearch(text) {
'<mark class="doclib-search-hl">$1</mark>');
} catch { return esc; }
}
function _safeResearchHref(raw) {
try {
const parsed = new URL(String(raw || '').trim(), window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return _esc(parsed.href);
} catch {}
return '';
}
let _libraryEscHandler = null;
let _librarySelectMode = false;
let _librarySelectedIds = new Set();
@@ -184,7 +194,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
function _showLibDropdown(anchor, items, opts) {
opts = opts || {};
document.querySelectorAll('._lib-dd').forEach(d => d.remove());
document.querySelectorAll('._lib-dd').forEach(dismissOrRemove);
const dd = document.createElement('div');
dd.className = 'dropdown session-dropdown-menu _lib-dd';
for (const item of items) {
@@ -193,7 +203,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
const iconKey = item.icon || item.label.toLowerCase();
const iconSvg = _LIB_DD_ICONS[iconKey] || '';
row.innerHTML = (iconSvg ? '<span class="dropdown-icon">' + iconSvg + '</span>' : '') + '<span>' + item.label + '</span>';
row.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); item.action(); });
row.addEventListener('click', (e) => { e.stopPropagation(); teardown(); item.action(); });
dd.appendChild(row);
}
if (typeof opts.onSelect === 'function') {
@@ -202,7 +212,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
sel.innerHTML =
'<span class="dropdown-icon"><span style="font-size:16px;line-height:1;position:relative;top:-2px;">●</span></span>'
+ '<span>Select</span>';
sel.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); opts.onSelect(); });
sel.addEventListener('click', (e) => { e.stopPropagation(); teardown(); opts.onSelect(); });
dd.appendChild(sel);
}
const cancel = document.createElement('div');
@@ -210,7 +220,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
cancel.innerHTML =
'<span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>'
+ '<span>Cancel</span>';
cancel.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); if (typeof opts.onCancel === 'function') opts.onCancel(); });
cancel.addEventListener('click', (e) => { e.stopPropagation(); teardown(); if (typeof opts.onCancel === 'function') opts.onCancel(); });
dd.appendChild(cancel);
document.body.appendChild(dd);
const rect = anchor.getBoundingClientRect();
@@ -225,8 +235,18 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
}
if (mr.left < 8) { dd.style.left = '8px'; dd.style.right = 'auto'; }
});
const close = (e) => { if (!dd.contains(e.target)) { dd.remove(); document.removeEventListener('click', close); } };
// Single idempotent teardown shared by every dismissal path (item click,
// outside click, swipe, and the Escape arbiter via registerMenuDismiss).
let _unreg = () => {};
const teardown = () => {
_unreg(); _unreg = () => {};
document.removeEventListener('click', close);
dd.remove();
};
const close = (e) => { if (!dd.contains(e.target)) teardown(); };
setTimeout(() => document.addEventListener('click', close), 0);
_unreg = registerMenuDismiss(teardown);
dd._dismiss = teardown; // let bulk removers (reopen sweep) tear down cleanly
// Swipe-down-to-dismiss (mobile). Mirrors the bottom-sheet feel — drag the
// popup down and release past the threshold to close. Below threshold,
@@ -257,8 +277,11 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
dd.style.transition = 'transform 0.15s ease, opacity 0.15s ease';
dd.style.transform = 'translateY(120px)';
dd.style.opacity = '0';
setTimeout(() => dd.remove(), 160);
// Unregister + drop the outside-click listener now; defer the DOM
// removal so the slide-out animation can play.
_unreg(); _unreg = () => {};
document.removeEventListener('click', close);
setTimeout(() => dd.remove(), 160);
} else {
dd.style.transition = 'transform 0.18s ease, opacity 0.18s ease';
dd.style.transform = '';
@@ -377,9 +400,34 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
}
}
function libraryRemoveDocumentFromState(docId) {
const removed = _libraryDocs.find(d => String(d.id) === String(docId));
_libraryDocs = _libraryDocs.filter(d => String(d.id) !== String(docId));
_librarySelectedIds.delete(docId);
_libraryTotal = Math.max(0, _libraryTotal - 1);
const lang = removed && (removed.language || 'text');
if (lang && Object.prototype.hasOwnProperty.call(_libraryLanguages, lang)) {
const next = Math.max(0, Number(_libraryLanguages[lang] || 0) - 1);
if (next > 0) {
_libraryLanguages[lang] = next;
} else {
delete _libraryLanguages[lang];
}
}
libraryRenderStats();
libraryRenderLangChips();
libraryUpdateBulkCount();
}
function libraryRenderGrid() {
const grid = document.getElementById('doclib-grid');
if (!grid) return;
// An open card menu is mounted on <body> (to escape overflow clipping), so
// clearing the grid would orphan it; dismiss it first so its listener +
// Escape-stack entry go too.
document.querySelectorAll('.doclib-card-dropdown').forEach(dismissOrRemove);
grid.innerHTML = '';
// Drop any previous inline load-more — regenerated below alongside the list.
if (grid.parentElement) grid.parentElement.querySelectorAll(':scope > .doclib-inline-load-more').forEach(b => b.remove());
@@ -576,8 +624,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
if (dropdown) {
const isOpen = dropdown.style.display !== 'none' && dropdown.parentElement === document.body;
if (isOpen) {
dropdown.style.display = 'none';
menuWrap.appendChild(dropdown);
hideCardDropdown();
} else {
// Position fixed on body to escape overflow clipping
const rect = menuBtn.getBoundingClientRect();
@@ -593,15 +640,12 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
if (mr.bottom > window.innerHeight - 8) dropdown.style.top = (rect.top - mr.height - 4) + 'px';
if (mr.left < 8) { dropdown.style.left = '8px'; dropdown.style.right = 'auto'; }
});
// Close on outside click
const close = (ev) => {
if (!dropdown.contains(ev.target) && !menuWrap.contains(ev.target)) {
dropdown.style.display = 'none';
menuWrap.appendChild(dropdown);
document.removeEventListener('click', close, true);
}
// Close on outside click or Escape (the latter via the registry).
_cardDocClick = (ev) => {
if (!dropdown.contains(ev.target) && !menuWrap.contains(ev.target)) hideCardDropdown();
};
setTimeout(() => document.addEventListener('click', close, true), 0);
setTimeout(() => document.addEventListener('click', _cardDocClick, true), 0);
_cardUnreg = registerMenuDismiss(hideCardDropdown);
}
}
});
@@ -612,6 +656,21 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
dropdown.className = 'doclib-card-dropdown';
dropdown.style.cssText = 'display:none;position:absolute;top:100%;right:0;z-index:1000;min-width:0;width:max-content;padding:4px;background:var(--panel);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);backdrop-filter:blur(12px);font-size:12px;';
// Single close path for the card action dropdown, shared by the toggle
// button, the outside-click listener, every menu item, and the Escape
// arbiter (via registerMenuDismiss). Hides the menu, returns it to its
// wrapper, drops the outside-click listener, and unregisters from the
// Escape stack. Idempotent — safe to call from whichever path fires first.
let _cardUnreg = () => {};
let _cardDocClick = null;
function hideCardDropdown() {
_cardUnreg(); _cardUnreg = () => {};
if (_cardDocClick) { document.removeEventListener('click', _cardDocClick, true); _cardDocClick = null; }
dropdown.style.display = 'none';
if (dropdown.parentElement === document.body) menuWrap.appendChild(dropdown);
}
dropdown._dismiss = hideCardDropdown; // bulk removers tear down through this
const _di = (svg) => `<span class="dropdown-icon">${svg}</span>`;
const _openIco = '<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>';
@@ -621,11 +680,12 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
openItem.style.cssText = 'background:none;border:none;width:100%;';
openItem.innerHTML = _di(_openIco) + '<span>Open</span>';
if (doc.session_id) {
openItem.addEventListener('click', (e) => { e.stopPropagation(); dropdown.style.display = 'none'; libraryOpenInSession(doc); });
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);
@@ -636,7 +696,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
cloneItem.style.cssText = 'background:none;border:none;width:100%;';
cloneItem.innerHTML = _di(_cloneIco) + '<span>Clone</span>';
cloneItem.title = 'Clone to active session';
cloneItem.addEventListener('click', (e) => { e.stopPropagation(); dropdown.style.display = 'none'; libraryImportDocument(doc); });
cloneItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryImportDocument(doc); });
dropdown.appendChild(cloneItem);
// Export
@@ -647,7 +707,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
exportItem.innerHTML = _di(_exportIco) + '<span>Export</span>';
exportItem.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.style.display = 'none';
hideCardDropdown();
try {
const res = await fetch(`${API_BASE}/api/document/${doc.id}`);
if (!res.ok) throw new Error('Failed');
@@ -673,14 +733,13 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
archiveItem.title = _libraryArchivedView ? 'Restore to active documents' : 'Archive (hide from the main list)';
archiveItem.addEventListener('click', async (e) => {
e.stopPropagation();
dropdown.style.display = 'none';
hideCardDropdown();
const toArchived = !_libraryArchivedView;
try {
const res = await fetch(`${API_BASE}/api/document/${doc.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' });
if (!res.ok) throw new Error('failed');
// Drop it from the current view (it no longer belongs here) and refresh.
_libraryDocs = _libraryDocs.filter(d => d.id !== doc.id);
_libraryTotal = Math.max(0, _libraryTotal - 1);
libraryRemoveDocumentFromState(doc.id);
libraryRenderGrid();
if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored');
} catch { if (uiModule) uiModule.showError('Failed to ' + (toArchived ? 'archive' : 'restore')); }
@@ -693,7 +752,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
deleteItem.className = 'dropdown-item-compact dropdown-item-danger';
deleteItem.style.cssText = 'background:none;border:none;width:100%;';
deleteItem.innerHTML = _di(_deleteIco) + '<span>Delete</span>';
deleteItem.addEventListener('click', (e) => { e.stopPropagation(); dropdown.style.display = 'none'; libraryDeleteSingle(doc.id, card); });
deleteItem.addEventListener('click', (e) => { e.stopPropagation(); hideCardDropdown(); libraryDeleteSingle(doc.id, card); });
dropdown.appendChild(deleteItem);
menuWrap.appendChild(dropdown);
@@ -743,10 +802,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');
@@ -772,8 +831,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
try {
const res = await fetch(`${API_BASE}/api/document/${doc.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' });
if (!res.ok) throw new Error('failed');
_libraryDocs = _libraryDocs.filter(d => d.id !== doc.id);
_libraryTotal = Math.max(0, _libraryTotal - 1);
libraryRemoveDocumentFromState(doc.id);
libraryRenderGrid();
if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored');
} catch { if (uiModule) uiModule.showError('Failed to ' + (toArchived ? 'archive' : 'restore')); }
@@ -1140,9 +1198,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
card.addEventListener('transitionend', () => card.remove(), { once: true });
setTimeout(() => { if (card.parentElement) card.remove(); }, 400);
}
_libraryDocs = _libraryDocs.filter(d => d.id !== docId);
_libraryTotal = Math.max(0, _libraryTotal - 1);
libraryRenderStats();
libraryRemoveDocumentFromState(docId);
if (uiModule) uiModule.showToast('Document deleted');
} catch (e) {
if (uiModule) uiModule.showError(`Failed to delete document: ${e.message || e}`);
@@ -1542,7 +1598,11 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
modal.innerHTML = `
<div class="modal-content doclib-modal-content" style="width:min(640px, 92vw);max-height:85vh;background:var(--bg);">
<div class="modal-header">
<h4><svg width="16" height="16" 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;"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="8" y1="7" x2="16" y2="7"/><line x1="8" y1="11" x2="14" y2="11"/></svg>Library</h4>
<!-- Header title + icon mirror the currently-active sub-tab (Chats /
Documents / Research / Archive) so the user sees ONE icon at
the top representing the section they're in, with the tab
strip below as sub-navigation. _switchLibTab() updates this. -->
<h4 id="doclib-header-title"><span id="doclib-header-icon" style="vertical-align:-2px;margin-right:4px;display:inline-flex;"></span><span id="doclib-header-text">Library</span></h4>
<button class="close-btn" id="doclib-close">\u2716</button>
</div>
<div class="lib-tabs" id="doclib-lib-tabs" style="padding:0 10px;">
@@ -1775,6 +1835,27 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
grid.parentElement.appendChild(btn);
}
// SVG markup + label for each tab — used to keep the modal header
// in sync with whichever sub-tab the user is on.
const _TAB_HEADERS = {
chats: {
label: 'Chats',
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
},
documents: {
label: 'Documents',
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>',
},
research: {
label: 'Research',
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>',
},
archive: {
label: 'Archive',
svg: '<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="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/></svg>',
},
};
function _switchLibTab(tab) {
_activeLibTab = tab;
_tabBtns.forEach(b => b.classList.toggle('active', b.dataset.doclibTab === tab));
@@ -1785,6 +1866,14 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
p.style.display = 'none';
}
});
// Sync the modal header icon + label to match the active sub-tab.
const hdr = _TAB_HEADERS[tab];
if (hdr) {
const ico = document.getElementById('doclib-header-icon');
const txt = document.getElementById('doclib-header-text');
if (ico) ico.innerHTML = hdr.svg;
if (txt) txt.textContent = hdr.label;
}
if (tab === 'chats') _renderLibChats();
else if (tab === 'archive') _renderLibArchive();
else if (tab === 'research') _renderLibResearch();
@@ -2030,6 +2119,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');
@@ -2383,7 +2473,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);
@@ -2597,7 +2691,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
const data = await res.json();
_researchItems = data.research || data || [];
} catch (e) {
grid.innerHTML = `<div class="hwfit-loading">Failed to load: ${e.message}</div>`;
grid.innerHTML = `<div class="hwfit-loading">Failed to load: ${_esc(e.message)}</div>`;
return;
}
_renderResearchGrid();
@@ -2639,9 +2733,9 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
const sources = Array.isArray(detail.sources) ? detail.sources : [];
const sourcesList = sources.slice(0, 12).map((src, i) => {
const title = _esc(src.title || src.url || `Source ${i + 1}`);
const url = src.url || '';
const url = _safeResearchHref(src.url);
return url
? `<li><a href="${_esc(url)}" target="_blank" rel="noopener">${title}</a></li>`
? `<li><a href="${url}" target="_blank" rel="noopener">${title}</a></li>`
: `<li>${title}</li>`;
}).join('');
const sourcesHtml = sources.length
@@ -3060,8 +3154,10 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
return new Date(iso).toLocaleDateString();
}
// Switch to initial tab if not documents
if (_activeLibTab !== 'documents') _switchLibTab(_activeLibTab);
// Switch to the initial tab. Always call this — even when the
// default ('documents') matches — so the modal header's icon + label
// sync from "Library" to the active sub-tab on first open.
_switchLibTab(_activeLibTab);
const searchInput = document.getElementById('doclib-search');
searchInput.addEventListener('input', () => {
@@ -3101,7 +3197,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
importFileBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', async () => {
if (fileInput.files.length === 0) return;
const files = fileInput.files;
const files = Array.from(fileInput.files);
fileInput.value = '';
// Swap the import icon for a whirlpool while files upload.
const _orig = importFileBtn.innerHTML;
+6 -1
View File
@@ -50,6 +50,7 @@
* }} deps
*/
import { state } from './state.js';
import { isAltGrEvent } from '../platform.js';
export function wireKeyboardShortcuts(deps) {
const {
@@ -79,7 +80,11 @@ export function wireKeyboardShortcuts(deps) {
return;
}
if (e.key === 'Escape') return;
if (e.ctrlKey || e.metaKey) {
// Skip the Ctrl+Alt editor chords for an AltGr keystroke (see platform.js);
// only the chord block is skipped, so the layout-character handlers below
// still act — AltGr+5 / AltGr+8 stay as the [ ] brush-size shortcut on
// AZERTY / QWERTZ.
if ((e.ctrlKey || e.metaKey) && !isAltGrEvent(e)) {
if (e.key === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); }
// Ctrl+Shift+D = Deselect: clears the wand selection (and
// lasso if active) without affecting layers.
+2 -1
View File
@@ -37,7 +37,8 @@ export function computeSnap(layer, nx, ny, ctx) {
{ y: ch, label: 'canvas-b' },
{ y: ch / 2, label: 'canvas-cy' },
];
for (const other of ctx.otherLayers) {
const otherLayers = Array.isArray(ctx.otherLayers) ? ctx.otherLayers : [];
for (const other of otherLayers) {
if (!other.visible || other.id === layer.id) continue;
const o = other.offset || { x: 0, y: 0 };
const ow = other.canvas.width, oh = other.canvas.height;
+89 -51
View File
@@ -26,6 +26,36 @@ const _starIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" s
const _starFilledIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></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 _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
const _replySeparator = '---------- Previous message ----------';
function _cleanAiReplyText(text) {
if (!text) return '';
let t = String(text);
const open = /<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/i;
const close = /<<<\s*END\s*>>+/i;
const m = open.exec(t);
if (m) {
const rest = t.slice(m.index + m[0].length);
const c = close.exec(rest);
t = c ? rest.slice(0, c.index) : rest;
}
return t
.replace(/<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/gi, '')
.replace(/<<<\s*END\s*>>+/gi, '')
.trim();
}
function _shouldUseFastAiReply(data) {
const body = String(data?.body || data?.body_html || '');
const subject = String(data?.subject || '');
const atts = Array.isArray(data?.attachments) ? data.attachments : [];
if (atts.length > 0) return false;
const text = `${subject}\n${body}`.toLowerCase();
if (/\b(attach(?:ed|ment)?|pdf|document|contract|invoice|receipt|quote|estimate|proposal|question|questions|details|schedule|booking|reservation|meeting|calendar|availability|confirm|confirmation|review|sign|signature)\b/.test(text)) {
return false;
}
return body.length < 2500;
}
let _emails = [];
let _currentFolder = 'INBOX';
@@ -609,52 +639,10 @@ function _createEmailItem(em) {
}
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') {
// If AI Reply mode: use cached reply if available, otherwise generate
const aiReplyMode = mode === 'ai-reply-fast' ? 'fast' : (mode === 'ai-reply-full' ? 'full' : '');
const wantsAiReply = mode === 'ai-reply' || !!aiReplyMode;
let aiSuggestedBody = null;
if (mode === 'ai-reply' && preloadedData) {
const data = preloadedData;
// Check for pre-generated cached reply first (instant!)
if (data.cached_ai_reply) {
aiSuggestedBody = data.cached_ai_reply;
} else {
// No cache — generate on demand
try {
let currentModel = '';
let currentSessionId = '';
try {
currentModel = sessionModule?.getCurrentModel() || '';
currentSessionId = sessionModule?.getCurrentSessionId() || '';
} catch (_) {}
const res = await fetch(`${API_BASE}/api/email/ai-reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: data.from_address,
subject: `Re: ${data.subject}`,
original_body: data.body,
model: currentModel,
session_id: currentSessionId,
message_id: data.message_id || '',
uid: String(em.uid || ''),
folder: _currentFolder,
}),
});
const result = await res.json();
if (result.success && result.reply) {
aiSuggestedBody = result.reply;
} else {
// Don't silently open a blank draft — tell the user it failed so a
// model/endpoint problem (e.g. empty response) is visible.
// uiModule isn't statically imported here; use the dynamic pattern.
const _msg = result.error || 'AI reply could not be generated';
console.error('AI reply generation failed:', _msg);
import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + _msg)).catch(() => {});
}
} catch (e) {
console.error('AI reply generation failed:', e);
import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + (e.message || e))).catch(() => {});
}
}
if (wantsAiReply) {
// Fall through to reply-all (not plain reply) so the generated AI
// draft addresses everyone on the original thread. On single-
// recipient emails this collapses to a regular reply since there's
@@ -682,14 +670,64 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') {
console.error('Failed to read email:', data.error);
return;
}
if (wantsAiReply) {
if (data.cached_ai_reply) {
aiSuggestedBody = _cleanAiReplyText(data.cached_ai_reply);
} else {
let draftToastTimer = null;
draftToastTimer = setTimeout(() => {
import('./ui.js').then(m => m.showToast && m.showToast('Drafting AI reply', { duration: 3000, leadingIcon: 'spinner' })).catch(() => {});
}, 450);
try {
let currentModel = '';
let currentSessionId = '';
try {
currentModel = sessionModule?.getCurrentModel() || '';
currentSessionId = sessionModule?.getCurrentSessionId() || '';
} catch (_) {}
const res = await fetch(`${API_BASE}/api/email/ai-reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: data.from_address,
subject: `Re: ${data.subject}`,
original_body: data.body,
model: currentModel,
session_id: currentSessionId,
message_id: data.message_id || '',
uid: String(em.uid || ''),
folder: _currentFolder,
fast: aiReplyMode ? aiReplyMode === 'fast' : _shouldUseFastAiReply(data),
}),
});
const result = await res.json();
if (draftToastTimer) clearTimeout(draftToastTimer);
if (result.success && result.reply) {
aiSuggestedBody = _cleanAiReplyText(result.reply);
} else {
const _msg = result.error || 'AI reply could not be generated';
console.error('AI reply generation failed:', _msg);
import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + _msg)).catch(() => {});
return;
}
} catch (e) {
if (draftToastTimer) clearTimeout(draftToastTimer);
console.error('AI reply generation failed:', e);
import('./ui.js').then(m => m.showError && m.showError('AI reply failed: ' + (e.message || e))).catch(() => {});
return;
}
}
}
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 = '';
@@ -697,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: ';
@@ -772,7 +810,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply') {
} else {
content += '\n\n';
}
content += `On ${niceDate}, ${data.from_name} <${data.from_address}> wrote:\n${quotedBody}`;
content += `${_replySeparator}\nOn ${niceDate}, ${data.from_name} <${data.from_address}> wrote:\n${quotedBody}`;
}
if (_docModule) {
+598 -153
View File
File diff suppressed because it is too large Load Diff
+10 -8
View File
@@ -12,14 +12,16 @@ export function extractEmail(addr) {
// Reply-all CC = everyone on the original To + Cc, minus ourselves, with the
// original "Name <email>" form preserved.
//
// `myAddress` empty/unknown ⇒ no exclusion. Comparing by exact extracted email
// (not a substring `includes`) is what fixes issue #360: an empty self address
// made `"...".includes("")` true for every recipient, so reply-all dropped the
// entire Cc list and kept only the original sender.
export function buildReplyAllCc(data, myAddress) {
const me = (myAddress || '').toLowerCase();
const split = (s) => (s || '').split(',').map((x) => x.trim()).filter(Boolean);
// `mine` is a single address or a list of the user's own addresses (a
// multi-account user has more than one). Empty/unknown ⇒ no exclusion.
// Comparing by exact extracted email (not a substring `includes`) is what
// fixes issue #360: an empty self address made `"...".includes("")` true for
// every recipient, so reply-all dropped the entire Cc list.
export function buildReplyAllCc(data, mine) {
const list = Array.isArray(mine) ? mine : [mine];
const me = new Set(list.map((a) => (a || '').toLowerCase()).filter(Boolean));
const split = (s) => (typeof s === 'string' ? s : '').split(',').map((x) => x.trim()).filter(Boolean);
return [...split(data && data.to), ...split(data && data.cc)]
.filter((addr) => !me || extractEmail(addr) !== me)
.filter((addr) => !me.has(extractEmail(addr)))
.join(', ');
}
+20 -8
View File
@@ -110,13 +110,18 @@ export function _foldSummary(label, iconSvg, meta) {
subMeta = '';
}
}
// `meta` is derived from _extractQuoteMeta, which strips tags but then
// un-escapes entities (to recover `<foo@bar.com>` for bubble alignment) —
// so it can carry attacker-controlled angle brackets from a quoted block.
// This summary is built into innerHTML, so escape both parts to stop a
// crafted quote (e.g. `From: <img src=x onerror=...>`) from running script.
const metaSpan = subMeta
? `<span class="email-fold-summary-meta">${subMeta}</span>`
? `<span class="email-fold-summary-meta">${_esc(subMeta)}</span>`
: '';
return (
'<summary class="email-fold-summary">'
+ iconSvg
+ `<span class="email-fold-summary-name">${primary}</span>`
+ `<span class="email-fold-summary-name">${_esc(primary)}</span>`
+ metaSpan
+ '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
+ '</summary>'
@@ -128,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, ' ')
@@ -149,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(); }
}
@@ -158,9 +167,12 @@ export function _extractQuoteMeta(html) {
if (from.length > 60) from = from.slice(0, 57) + '…';
if (date.length > 28) date = date.slice(0, 25) + '…';
if (from && date) return `${_esc(from)} · ${_esc(date)}`;
if (from) return _esc(from);
if (date) return _esc(date);
// Return the raw sender/date text; `_foldSummary` is the single sink that
// builds these into HTML, so it owns escaping. Escaping here too would
// double-encode (e.g. "Ben & Jerry" -> "Ben &amp;amp; Jerry").
if (from && date) return `${from} · ${date}`;
if (from) return from;
if (date) return date;
return '';
}
@@ -290,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]);
+40 -13
View File
@@ -15,7 +15,7 @@ export const _TALON_FROM = '(?:From|Från|Von|De|Da|От|Od|Van|差出人|发件
export const _TALON_SENT = '(?:Sent|Skickat|Gesendet|Envoy[ée]|Inviato|Enviado|Verzonden|Отправлено|Wysłane|Date|送信日時|发送时间|寄件日期|Sendt|Lähetetty|Tarih|Datum|Data|Datum)';
export const _TALON_SUBJ = '(?:Subject|Ämne|Betreff|Objet|Oggetto|Asunto|Onderwerp|Тема|Temat|件名|主题|主旨|Emne|Aihe|Onderwerp|Konu)';
export const _TALON_TO = '(?:To|Till|An|À|A|Voor|Para|Naar|Кому|Do|宛先|收件人|Emri|Komu)';
export const _TALON_ORIG_RE = /(?:^|\n)[\s>]*[-_=]{3,}\s*(?:Original\s+Message|Ursprüngliche\s+Nachricht|Mensaje\s+original|Messaggio\s+originale|Message\s+d[']origine|Oorspronkelijk\s+bericht|Original\s+meddelande|Vor[ ]asal[a]\s+meddelande|原文|原始邮件|転送)\s*[-_=]{3,}/i;
export const _TALON_ORIG_RE = /(?:^|\n)[\s>]*[-_=]{3,}\s*(?:Original\s+Message|Forwarded\s+message|Ursprüngliche\s+Nachricht|Mensaje\s+original|Messaggio\s+originale|Message\s+d[']origine|Oorspronkelijk\s+bericht|Original\s+meddelande|Vor[ ]asal[a]\s+meddelande|原文|原始邮件|転送)\s*[-_=]{3,}/i;
// Minimum plain-text length of a "signature" before we bother folding it.
// Short closings ("Cheers, John") stay inline — folding them would add
@@ -30,6 +30,28 @@ export function _esc(text) {
return div.innerHTML;
}
function _attrEsc(text) {
return String(text ?? '')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/`/g, '&#96;');
}
function _compactUrlSchemeValue(value) {
return String(value || '').replace(/[\u0000-\u0020\u007f-\u009f]+/g, '').toLowerCase();
}
function _isDangerousUrl(value) {
const compact = _compactUrlSchemeValue(value);
return compact.startsWith('javascript:') || compact.startsWith('vbscript:') || compact.startsWith('data:');
}
function _isDangerousSrcset(value) {
return String(value || '').split(',').some(candidate => _isDangerousUrl(candidate));
}
// Escape + linkify URLs and email addresses. Returns innerHTML-safe markup.
export function _escLinkify(text) {
const escaped = _esc(text);
@@ -39,9 +61,9 @@ export function _escLinkify(text) {
return escaped
.replace(urlRe, (m) => {
const href = m.startsWith('www.') ? `https://${m}` : m;
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${m}</a>`;
return `<a href="${_attrEsc(href)}" target="_blank" rel="noopener noreferrer">${m}</a>`;
})
.replace(mailRe, (m) => `<a href="mailto:${m}">${m}</a>`);
.replace(mailRe, (m) => `<a href="${_attrEsc(`mailto:${m}`)}">${m}</a>`);
}
// Pull display name out of "Name <email@x>"; fallback to local-part of
@@ -133,19 +155,14 @@ export function _initials(s) {
// `data:` URLs on every known URL attribute, scrubs inline colour/font/
// position styles so the theme can take over, and wraps highlight-bearing
// inline tags in <mark> so they render legibly across themes.
export function _sanitizeHtml(html) {
function _sanitizeHtmlOnce(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
doc.querySelectorAll(
'script, iframe, object, embed, form, style, link, ' +
'svg, math, base, meta, noscript, frame, frameset, applet, portal'
).forEach(el => el.remove());
const URL_ATTRS = ['href', 'src', 'srcset', 'action', 'formaction', 'background', 'poster', 'data'];
const isDangerousUrl = (val) => {
if (!val) return false;
const v = val.trim().toLowerCase();
return v.startsWith('javascript:') || v.startsWith('vbscript:') || v.startsWith('data:');
};
const URL_ATTRS = ['href', 'src', 'xlink:href', 'srcset', 'action', 'formaction', 'background', 'poster', 'data'];
const STRIP_CSS_PROPS = ['color', 'background', 'background-color',
'font-family', 'font', '-webkit-text-fill-color',
@@ -160,7 +177,7 @@ export function _sanitizeHtml(html) {
const name = attr.name.toLowerCase();
if (name.startsWith('on')) { el.removeAttribute(attr.name); continue; }
if (name === 'srcdoc') { el.removeAttribute(attr.name); continue; }
if (URL_ATTRS.includes(name) && isDangerousUrl(attr.value)) {
if (URL_ATTRS.includes(name) && (name === 'srcset' ? _isDangerousSrcset(attr.value) : _isDangerousUrl(attr.value))) {
el.removeAttribute(attr.name);
continue;
}
@@ -177,8 +194,8 @@ export function _sanitizeHtml(html) {
if (style) {
const kept = style.split(';').map(s => s.trim()).filter(decl => {
if (!decl) return false;
const lower = decl.toLowerCase();
if (lower.includes('javascript:') || lower.includes('expression(')) return false;
const lower = _compactUrlSchemeValue(decl);
if (lower.includes('javascript:') || lower.includes('vbscript:') || lower.includes('data:') || lower.includes('expression(')) return false;
const prop = decl.split(':', 1)[0].trim().toLowerCase();
return !STRIP_CSS_PROPS.includes(prop);
});
@@ -200,3 +217,13 @@ export function _sanitizeHtml(html) {
return doc.body.innerHTML;
}
export function _sanitizeHtml(html) {
let out = String(html ?? '');
for (let i = 0; i < 4; i++) {
const next = _sanitizeHtmlOnce(out);
if (next === out) break;
out = next;
}
return out;
}
+458
View File
@@ -0,0 +1,458 @@
// static/js/emojiShortcodes.js
//
// Emoji shortcode → Unicode conversion (issue #345).
//
// Chat models frequently emit GitHub/Slack-style `:shortcode:` text — e.g.
// `:blush:`, `:fire:`, `:microphone:` — instead of the actual emoji character.
// Nothing in the render pipeline used to translate these, so they showed up as
// literal `:blush:` text in the chat bubble.
//
// This module turns the common shortcode set into the real Unicode emoji. The
// chat renderer (markdown.js → svgifyEmoji) runs this BEFORE its existing
// Unicode-emoji → monochrome-SVG pass, so a converted `:blush:` renders as the
// same theme-tinted single-color line icon as any other emoji (project rule:
// never colorful emoji), not as a colored system glyph.
//
// Pure and browser-free on purpose: no DOM, no imports, so it can be unit
// tested with plain `node` (see tests/test_emoji_shortcodes_js.py).
// Canonical map of common shortcode → Unicode emoji. Names follow the GitHub
// convention (lowercase, underscore-separated). A handful of well-known aliases
// (`+1`, `thumbsup`, `grinning_face`, …) point at the same glyph so the most
// frequent model spellings all resolve.
export const EMOJI_SHORTCODES = {
// ── Smileys & emotion ──
grinning: '😀', grinning_face: '😀',
smiley: '😃', smiley_face: '😃',
smile: '😄',
grin: '😁',
laughing: '😆', satisfied: '😆',
sweat_smile: '😅',
rofl: '🤣', rolling_on_the_floor_laughing: '🤣',
joy: '😂',
slightly_smiling_face: '🙂', slight_smile: '🙂',
upside_down_face: '🙃', upside_down: '🙃',
wink: '😉', winking_face: '😉',
blush: '😊', smiling_face_with_smiling_eyes: '😊',
innocent: '😇',
smiling_face_with_three_hearts: '🥰',
heart_eyes: '😍', heart_eyes_face: '😍',
star_struck: '🤩',
kissing_heart: '😘',
kissing: '😗',
kissing_closed_eyes: '😚',
kissing_smiling_eyes: '😙',
yum: '😋',
stuck_out_tongue: '😛',
stuck_out_tongue_winking_eye: '😜',
zany_face: '🤪',
stuck_out_tongue_closed_eyes: '😝',
money_mouth_face: '🤑',
hugs: '🤗', hugging_face: '🤗',
hand_over_mouth: '🤭',
shushing_face: '🤫',
thinking: '🤔', thinking_face: '🤔',
zipper_mouth_face: '🤐',
raised_eyebrow: '🤨',
neutral_face: '😐',
expressionless: '😑',
no_mouth: '😶',
smirk: '😏', smirk_face: '😏',
unamused: '😒',
roll_eyes: '🙄', face_with_rolling_eyes: '🙄',
grimacing: '😬',
lying_face: '🤥',
relieved: '😌',
pensive: '😔',
sleepy: '😪',
drooling_face: '🤤',
sleeping: '😴',
mask: '😷',
face_with_thermometer: '🤒',
face_with_head_bandage: '🤕',
nauseated_face: '🤢',
vomiting_face: '🤮',
sneezing_face: '🤧',
hot_face: '🥵',
cold_face: '🥶',
woozy_face: '🥴',
dizzy_face: '😵',
exploding_head: '🤯',
cowboy_hat_face: '🤠',
partying_face: '🥳',
sunglasses: '😎',
nerd_face: '🤓',
monocle_face: '🧐',
confused: '😕',
worried: '😟',
slightly_frowning_face: '🙁',
frowning_face: '☹️',
open_mouth: '😮',
hushed: '😯',
astonished: '😲',
flushed: '😳',
pleading_face: '🥺',
frowning: '😦',
anguished: '😧',
fearful: '😨',
cold_sweat: '😰',
disappointed_relieved: '😥',
cry: '😢',
sob: '😭',
scream: '😱',
confounded: '😖',
persevere: '😣',
disappointed: '😞',
sweat: '😓',
weary: '😩',
tired_face: '😫',
yawning_face: '🥱',
triumph: '😤',
rage: '😡', pout: '😡', pouting_face: '😡',
angry: '😠',
cursing_face: '🤬',
smiling_imp: '😈',
imp: '👿',
skull: '💀',
skull_and_crossbones: '☠️',
hankey: '💩', poop: '💩', shit: '💩',
clown_face: '🤡',
japanese_ogre: '👹',
japanese_goblin: '👺',
ghost: '👻',
alien: '👽',
space_invader: '👾',
robot: '🤖', robot_face: '🤖',
// ── Cats ──
smiley_cat: '😺',
smile_cat: '😸',
joy_cat: '😹',
heart_eyes_cat: '😻',
smirk_cat: '😼',
kissing_cat: '😽',
scream_cat: '🙀',
crying_cat_face: '😿',
pouting_cat: '😾',
see_no_evil: '🙈',
hear_no_evil: '🙉',
speak_no_evil: '🙊',
// ── Hands & body ──
wave: '👋', wave_hand: '👋',
raised_back_of_hand: '🤚',
raised_hand_with_fingers_splayed: '🖐️',
hand: '✋', raised_hand: '✋',
vulcan_salute: '🖖',
ok_hand: '👌',
pinched_fingers: '🤌',
pinching_hand: '🤏',
v: '✌️', victory_hand: '✌️',
crossed_fingers: '🤞',
love_you_gesture: '🤟',
metal: '🤘',
call_me_hand: '🤙',
point_left: '👈',
point_right: '👉',
point_up_2: '👆',
middle_finger: '🖕', fu: '🖕',
point_down: '👇',
point_up: '☝️',
'+1': '👍', thumbsup: '👍', thumbup: '👍', thumbs_up: '👍',
'-1': '👎', thumbsdown: '👎', thumbdown: '👎', thumbs_down: '👎',
fist_raised: '✊', fist: '✊',
fist_oncoming: '👊', facepunch: '👊', punch: '👊',
fist_left: '🤛',
fist_right: '🤜',
clap: '👏', clapping_hands: '👏',
raised_hands: '🙌',
open_hands: '👐',
palms_up_together: '🤲',
handshake: '🤝',
pray: '🙏', folded_hands: '🙏',
writing_hand: '✍️',
nail_care: '💅',
selfie: '🤳',
muscle: '💪', flexed_biceps: '💪',
// ── Hearts & symbols of feeling ──
heart: '❤️', red_heart: '❤️',
orange_heart: '🧡',
yellow_heart: '💛',
green_heart: '💚',
blue_heart: '💙',
purple_heart: '💜',
black_heart: '🖤',
white_heart: '🤍',
brown_heart: '🤎',
broken_heart: '💔',
heart_on_fire: '❤️‍🔥',
two_hearts: '💕',
revolving_hearts: '💞',
heartbeat: '💓',
heartpulse: '💗',
sparkling_heart: '💖',
cupid: '💘',
gift_heart: '💝',
heart_decoration: '💟',
heavy_heart_exclamation: '❣️',
// ── Celebration & misc objects ──
fire: '🔥', flame: '🔥',
'100': '💯', hundred: '💯',
sparkles: '✨',
star: '⭐',
star2: '🌟', glowing_star: '🌟',
dizzy: '💫',
boom: '💥', collision: '💥',
anger: '💢',
sweat_drops: '💦',
dash: '💨',
zzz: '💤',
tada: '🎉', party_popper: '🎉',
confetti_ball: '🎊',
balloon: '🎈',
gift: '🎁',
trophy: '🏆',
'1st_place_medal': '🥇',
'2nd_place_medal': '🥈',
'3rd_place_medal': '🥉',
medal_sports: '🏅',
zap: '⚡', lightning: '⚡',
bulb: '💡', light_bulb: '💡',
key: '🔑',
lock: '🔒',
unlock: '🔓',
bell: '🔔',
no_bell: '🔕',
loudspeaker: '📢',
mega: '📣', megaphone: '📣',
speech_balloon: '💬',
thought_balloon: '💭',
white_check_mark: '✅',
heavy_check_mark: '✔️', check_mark: '✔️',
ballot_box_with_check: '☑️',
x: '❌', cross_mark: '❌',
negative_squared_cross_mark: '❎',
question: '❓',
grey_question: '❔',
exclamation: '❗', heavy_exclamation_mark: '❗',
grey_exclamation: '❕',
warning: '⚠️',
no_entry: '⛔',
no_entry_sign: '🚫',
red_circle: '🔴',
green_circle: '🟢',
large_blue_circle: '🔵',
yellow_circle: '🟡',
white_circle: '⚪',
black_circle: '⚫',
orange_circle: '🟠',
purple_circle: '🟣',
brown_circle: '🟤',
// ── Tech, work, study ──
rocket: '🚀',
eyes: '👀',
eye: '👁️',
brain: '🧠',
books: '📚',
book: '📖', open_book: '📖',
memo: '📝', pencil: '📝',
pencil2: '✏️',
page_facing_up: '📄',
paperclip: '📎',
pushpin: '📌',
round_pushpin: '📍',
link: '🔗',
bar_chart: '📊',
chart_with_upwards_trend: '📈',
chart_with_downwards_trend: '📉',
mag: '🔍',
mag_right: '🔎',
globe_with_meridians: '🌐',
earth_africa: '🌍',
earth_americas: '🌎',
earth_asia: '🌏',
alarm_clock: '⏰',
hourglass_flowing_sand: '⏳',
hourglass: '⌛',
microphone: '🎤', mic: '🎤',
musical_note: '🎵',
notes: '🎶', musical_notes: '🎶',
headphones: '🎧',
camera: '📷',
camera_flash: '📸',
clapper: '🎬',
tv: '📺',
computer: '💻', laptop: '💻',
desktop_computer: '🖥️',
iphone: '📱', mobile_phone: '📱',
telephone: '☎️',
wrench: '🔧',
hammer: '🔨',
gear: '⚙️',
nut_and_bolt: '🔩',
magnet: '🧲',
test_tube: '🧪',
microscope: '🔬',
dart: '🎯', bullseye: '🎯',
game_die: '🎲',
jigsaw: '🧩',
// ── Food & drink ──
pizza: '🍕',
hamburger: '🍔',
fries: '🍟',
taco: '🌮',
sushi: '🍣',
doughnut: '🍩', donut: '🍩',
coffee: '☕',
beer: '🍺',
wine_glass: '🍷',
// ── Animals & nature ──
dog: '🐶',
cat: '🐱',
mouse: '🐭',
hamster: '🐹',
rabbit: '🐰',
fox_face: '🦊',
bear: '🐻',
panda_face: '🐼',
koala: '🐨',
tiger: '🐯',
lion: '🦁',
cow: '🐮',
pig: '🐷',
frog: '🐸',
monkey_face: '🐵',
chicken: '🐔',
penguin: '🐧',
bird: '🐦',
eagle: '🦅',
duck: '🦆',
owl: '🦉',
wolf: '🐺',
horse: '🐴',
unicorn: '🦄',
bee: '🐝', honeybee: '🐝',
bug: '🐛',
butterfly: '🦋',
snail: '🐌',
lady_beetle: '🐞',
snake: '🐍',
turtle: '🐢',
octopus: '🐙',
crab: '🦀',
tropical_fish: '🐠',
whale: '🐳',
shark: '🦈',
cherry_blossom: '🌸',
rose: '🌹',
sunflower: '🌻',
hibiscus: '🌺',
tulip: '🌷',
seedling: '🌱',
evergreen_tree: '🌲',
deciduous_tree: '🌳',
four_leaf_clover: '🍀',
apple: '🍎',
green_apple: '🍏',
pear: '🍐',
tangerine: '🍊',
lemon: '🍋',
banana: '🍌',
watermelon: '🍉',
grapes: '🍇',
strawberry: '🍓',
blueberries: '🫐',
peach: '🍑',
rainbow: '🌈',
sunny: '☀️', sun: '☀️',
partly_sunny: '⛅',
cloud: '☁️',
snowflake: '❄️',
ocean: '🌊',
// ── Arrows & signs ──
arrow_right: '➡️',
arrow_left: '⬅️',
arrow_up: '⬆️',
arrow_down: '⬇️',
arrow_upper_right: '↗️',
arrow_lower_right: '↘️',
arrow_lower_left: '↙️',
arrow_upper_left: '↖️',
leftwards_arrow_with_hook: '↩️',
arrow_right_hook: '↪️',
arrows_counterclockwise: '🔄',
arrows_clockwise: '🔃',
heavy_plus_sign: '',
heavy_minus_sign: '',
heavy_division_sign: '➗',
heavy_multiplication_x: '✖️',
infinity: '♾️',
copyright: '©️',
registered: '®️',
tm: '™️',
recycle: '♻️',
checkered_flag: '🏁',
triangular_flag_on_post: '🚩',
white_flag: '🏳️',
black_flag: '🏴',
// ── People & wearables ──
baby: '👶',
boy: '👦',
girl: '👧',
man: '👨',
woman: '👩',
older_man: '👴',
older_woman: '👵',
crown: '👑',
gem: '💎',
graduation_cap: '🎓', mortar_board: '🎓',
};
// `:name:` where name is letters/digits/`_`/`+`/`-`. Length ≥1 so `:+1:` and
// `:-1:` match. Global + case-insensitive for replace; a separate non-global
// literal is used for the cheap presence check so there's no shared lastIndex
// state to reset.
const SHORTCODE_RE = /:([a-z0-9_+-]{1,40}):/gi;
/**
* Cheap test for whether `text` could contain any emoji shortcode at all.
* Lets callers skip the replace pass entirely on the common no-shortcode path.
*/
export function hasEmojiShortcode(text) {
return !!text && text.indexOf(':') !== -1 && /:[a-z0-9_+-]{1,40}:/i.test(text);
}
// A shortcode must stand on its own — flanked by whitespace, punctuation, a
// string edge, or markup, never glued to an ASCII word character. Without this
// guard, real `:name:` shortcodes that happen to sit inside a longer run of
// digits/letters get converted by mistake and mangle perfectly literal text:
// "1:100:2" → the `:100:` would become 💯 ("1💯2")
// "host:fire:port", URL authorities, `key:value:` pairs, etc.
// Chat models always emit shortcodes delimited by spaces/punctuation (":fire:",
// "**:microphone:**", "nice :tada:!"), so requiring a boundary keeps every real
// shortcode working while leaving embedded colon runs untouched. `_` counts as a
// word char too (identifier-like), but `+`/`-` do not, so "C++ :fire:" still works.
const _WORDISH = /[A-Za-z0-9_]/;
function _boundedOnBothSides(str, start, end) {
const before = start > 0 ? str[start - 1] : '';
const after = end < str.length ? str[end] : '';
return !_WORDISH.test(before) && !_WORDISH.test(after);
}
/**
* Replace every known `:shortcode:` in `text` with its Unicode emoji. Unknown
* shortcodes (`:definitely_not_emoji:`), colon runs that don't form a shortcode
* (`10:30:45`, `16:9`), and known shortcodes embedded mid-token (`1:100:2`) are
* all left exactly as-is.
*/
export function replaceEmojiShortcodes(text) {
if (!text || text.indexOf(':') === -1) return text;
return text.replace(SHORTCODE_RE, (whole, name, offset, str) => {
const key = name.toLowerCase();
if (!Object.prototype.hasOwnProperty.call(EMOJI_SHORTCODES, key)) return whole;
// Only convert when the `:shortcode:` is a standalone token, not glued to a
// surrounding word/number (which would mean it's literal text, not an emoji).
if (!_boundedOnBothSides(str, offset, offset + whole.length)) return whole;
return EMOJI_SHORTCODES[key];
});
}
export default { EMOJI_SHORTCODES, replaceEmojiShortcodes, hasEmojiShortcode };
+102
View File
@@ -0,0 +1,102 @@
// static/js/escMenuStack.js
//
// Dismissal registry for transient, ad-hoc overlays — dropdown menus and
// context popups that are built on the fly and appended to <body>, living
// OUTSIDE the .modal system. The global Escape arbiter in ui.js can find
// modals but not these, so each menu registers a dismiss callback here while
// it is open and unregisters when it closes.
//
// The stack is LIFO: dismissTopMenu() closes the most-recently-opened menu
// first, so a dropdown opened on top of a modal closes before the modal does.
// Deliberately DOM-free so it can be unit-tested under plain node (see
// tests/test_esc_menu_stack_js.py).
const _stack = [];
/**
* Register a menu's dismiss callback. Returns an unregister function that the
* menu MUST call from its own teardown (outside-click close, item click, etc.)
* so the stack never holds a stale entry. Calling the returned function more
* than once, or after the menu was already dismissed via Escape, is safe.
*/
export function registerMenuDismiss(dismissFn) {
if (typeof dismissFn !== 'function') return () => {};
const entry = { dismissFn };
_stack.push(entry);
return () => {
const i = _stack.indexOf(entry);
if (i !== -1) _stack.splice(i, 1);
};
}
/**
* Dismiss the most-recently-registered menu, if any. Returns true when a menu
* was dismissed (so the caller can swallow the Escape key), false when nothing
* was open. The entry is popped BEFORE its callback runs, so even if a
* dismissFn forgets to unregister or throws, a single Escape closes exactly
* one menu and the stack never gets stuck.
*/
export function dismissTopMenu() {
const entry = _stack.pop();
if (!entry) return false;
try { entry.dismissFn(); } catch {}
return true;
}
/** Test/debug helper: number of currently-registered menus. */
export function _openMenuCount() {
return _stack.length;
}
/**
* Tear a transient menu down through its registered dismiss callback if it has
* one (releasing its Escape-stack entry and any listeners), else fall back to a
* plain node removal. Use this anywhere menus are cleared in bulk scroll /
* swipe / modal-dismiss cleanup, or a "close the previous one" reopen sweep
* instead of a raw `el.remove()`, which would strand the stack entry.
*/
export function dismissOrRemove(el) {
if (!el) return;
if (typeof el._dismiss === 'function') el._dismiss();
else el.remove();
}
// ── DOM convenience wrapper ──────────────────────────────────────────────
// The registry above is intentionally DOM-free (and unit-tested as such).
// bindMenuDismiss is the thin DOM layer most callers actually want: it wires
// the ubiquitous "overlay appended to <body>, closes on an outside click"
// idiom to BOTH the outside-click listener AND the Escape stack in one call,
// so a menu only has to describe how to tear itself down once.
//
// const close = bindMenuDismiss(popup, () => popup.remove());
// // outside-click and Escape now both call close(); call it yourself from
// // item handlers too.
//
// `onClose` runs exactly once (idempotent) and owns the actual teardown
// (removing/hiding the node, clearing anchor state, …). `isOutside(ev)`
// defaults to "the click landed outside `el`"; override it when extra anchors
// should count as inside the menu. The returned idempotent close() is also
// stashed on `el._dismiss`, so bulk removers (see dismissOrRemove) can tear the
// menu down through its real teardown rather than orphaning its stack entry.
export function bindMenuDismiss(el, onClose, isOutside) {
let done = false;
let unreg = () => {};
const onDocClick = (ev) => {
const outside = typeof isOutside === 'function' ? isOutside(ev) : !el.contains(ev.target);
if (outside) close();
};
function close() {
if (done) return;
done = true;
unreg(); unreg = () => {};
document.removeEventListener('click', onDocClick, true);
try { if (typeof onClose === 'function') onClose(); } catch {}
}
// Defer attaching the outside-click listener so the opening click doesn't
// immediately close the menu. Skip the attach if close() already ran in the
// same tick (e.g. an instant Escape) so we never leave a dangling listener.
setTimeout(() => { if (!done) document.addEventListener('click', onDocClick, true); }, 0);
unreg = registerMenuDismiss(close);
el._dismiss = close;
return close;
}
+17 -10
View File
@@ -17,6 +17,10 @@ let API_BASE = '';
let _uploadSpinners = [];
const _previewUrls = new WeakMap();
const MAX_FILES = 10;
const MAX_VISIBLE = 3;
let _expanded = false;
function _getPreviewUrl(f) {
if (!f) return '';
let url = _previewUrls.get(f);
@@ -49,10 +53,6 @@ export function openPicker() {
document.getElementById('file-input').click();
}
const MAX_VISIBLE = 3;
const MAX_EXPAND = 6; // beyond this, the badge stays collapsed (too many chips to preview)
let _expanded = false;
/**
* Render the attachment strip with pending files.
* 1-3 files: show individual chips.
@@ -80,11 +80,9 @@ export function renderAttachStrip() {
label.className = 'thumb-collapsed-label';
badge.appendChild(label);
badge.title = pendingFiles.map(f => f.name || 'pasted-image').join('\n');
const canExpand = total <= MAX_EXPAND;
badge.style.cursor = canExpand ? 'pointer' : 'default';
badge.style.cursor = 'pointer';
badge.addEventListener('click', (e) => {
if (e.target.closest('.thumb-collapsed-x')) return;
if (!canExpand) return; // too many files — don't expand into chips
_expanded = true;
renderAttachStrip();
});
@@ -112,7 +110,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 +170,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
@@ -190,8 +199,6 @@ export async function uploadPending() {
}
}
const MAX_FILES = 10;
/**
* Add files to pending list (capped at MAX_FILES)
*/
+21 -13
View File
@@ -8,6 +8,7 @@ import spinnerModule from './spinner.js';
import { providerLogo } from './providers.js';
import { PROMPT_TEMPLATES, getAllPresets } from './presets.js';
import { sortModelObjects } from './modelSort.js';
import Storage from './storage.js';
let API_BASE = '';
let _active = false;
@@ -57,7 +58,7 @@ function _initGroupTab() {
});
});
_modelsCache = sortModelObjects(result);
return result;
return _modelsCache;
}
function _render() {
@@ -298,13 +299,16 @@ async function _getCharacterList() {
});
}
} catch (e) {}
// Load user templates and wait for them before returning
// Load user templates and wait for them before returning.
// The endpoint returns a JSON array directly (not {templates:[...]}).
// All user templates are personas by definition — no isCharacter filter needed.
try {
const r = await fetch(API_BASE + '/api/presets/templates', { credentials: 'same-origin' });
const data = await r.json();
(data.templates || []).forEach(t => {
if (t.isCharacter && !chars.find(c => c.id === t.id)) {
chars.push({ id: t.id, name: t.name, prompt: t.prompt || '' });
const templates = Array.isArray(data) ? data : (data.templates || []);
templates.forEach(t => {
if (t.id && t.name && !chars.find(c => c.id === t.id)) {
chars.push({ id: t.id, name: t.name, prompt: t.system_prompt || t.prompt || '' });
}
});
} catch (e) {}
@@ -409,7 +413,7 @@ export async function showModelPicker() {
});
});
_cachedModels = sortModelObjects(result);
return result;
return _cachedModels;
}
async function render(filter) {
@@ -546,7 +550,8 @@ export async function startGroup(models, parentSessionId) {
_parentSessionId = pdata.id;
// Register as group session for sidebar icon
try {
const gids = JSON.parse(localStorage.getItem('odysseus-group-sessions') || '[]');
const storedGroupSessions = Storage.getJSON('odysseus-group-sessions', []);
const gids = Array.isArray(storedGroupSessions) ? storedGroupSessions : [];
if (!gids.includes(_parentSessionId)) { gids.push(_parentSessionId); localStorage.setItem('odysseus-group-sessions', JSON.stringify(gids)); }
} catch (e) {}
} catch (e) {
@@ -671,7 +676,7 @@ function _createGroupBubble(model, box) {
// Role label — use character name if assigned, otherwise model name
const roleLabel = model._groupName || (model.character ? model.character.characterName : chatRenderer.shortModel(model.mid));
const roleTs = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
wrap.innerHTML = `<div class="role">${roleLabel} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
wrap.innerHTML = `<div class="role">${uiModule.esc(roleLabel)} <span class="role-timestamp">${roleTs}</span></div><div class="body"></div>`;
chatRenderer.applyModelColor(wrap.querySelector('.role'), model.mid);
// Spinner — identical to chat.js line 3062
@@ -855,11 +860,14 @@ async function _streamToHolder(modelIdx, sessionId, msg, holderEl, abortCtrl) {
}
// Generated image
else if (json.type === 'generated_image' && json.url) {
const img = document.createElement('img');
img.src = json.url;
img.style.cssText = 'max-width:100%;border-radius:8px;margin:8px 0;';
img.loading = 'lazy';
bodyEl.appendChild(img);
const safeImageUrl = chatRenderer.safeDisplayImageSrc(json.url);
if (safeImageUrl) {
const img = document.createElement('img');
img.src = safeImageUrl;
img.style.cssText = 'max-width:100%;border-radius:8px;margin:8px 0;';
img.loading = 'lazy';
bodyEl.appendChild(img);
}
}
// Error
else if (json.error) {
+33
View File
@@ -165,6 +165,39 @@ window.addEventListener('pageshow', clearFreshComposerRestore);
window.addEventListener('resize', _sync);
}
/* Keep minimized tool chips above the composer. Both the current modalManager
dock and the legacy fallback dock consume this root-level clearance. */
{
const root = document.documentElement;
const chatBar = document.querySelector('.chat-input-bar');
const attachStrip = document.getElementById('attach-strip');
const chatContainer = document.getElementById('chat-container');
const _syncComposerClearance = () => {
let top = window.innerHeight;
for (const el of [attachStrip, chatBar]) {
if (!el) continue;
const rect = el.getBoundingClientRect();
if (rect.height > 0) top = Math.min(top, rect.top);
}
const clearance = Math.max(12, Math.ceil(window.innerHeight - top + 8));
root.style.setProperty('--composer-clearance', clearance + 'px');
};
requestAnimationFrame(_syncComposerClearance);
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(_syncComposerClearance);
if (chatBar) ro.observe(chatBar);
if (attachStrip) ro.observe(attachStrip);
}
if (chatContainer && typeof MutationObserver !== 'undefined') {
new MutationObserver(_syncComposerClearance).observe(chatContainer, {
attributes: true,
attributeFilter: ['class'],
});
}
if (chatBar) chatBar.addEventListener('transitionend', _syncComposerClearance);
window.addEventListener('resize', _syncComposerClearance);
}
/* ---- Resizable sidebar — drag edge to resize, collapse if small, drag rail edge to expand ---- */
{
const sidebar = document.getElementById('sidebar');
+6 -1
View File
@@ -2,6 +2,8 @@
// Keyboard Shortcuts — dynamic keybinds
// ============================================
import { IS_MAC, isAltGrEvent } from './platform.js';
const _defaultKeybinds = {
search: 'ctrl+k', toggle_sidebar: 'ctrl+alt+b', new_session: 'ctrl+alt+n',
fav_session: 'ctrl+alt+f', delete_session: 'ctrl+alt+d',
@@ -13,8 +15,11 @@ const _defaultKeybinds = {
open_notes: '', open_tasks: '', open_theme: '',
};
function _matchesCombo(e, combo) {
export function _matchesCombo(e, combo, isMac = IS_MAC) {
if (!combo) return false;
// Drop AltGr keystrokes so typing characters on non-US layouts can't fire a
// Ctrl+Alt shortcut — e.g. the destructive delete_session. See platform.js.
if (isAltGrEvent(e, isMac)) return false;
const parts = combo.split('+');
const needCtrl = parts.includes('ctrl');
const needAlt = parts.includes('alt');
+2 -2
View File
@@ -175,8 +175,8 @@ export function langIcon(lang, size = 14, opts = {}) {
const key = String(lang).toLowerCase();
const inner = ICONS[key] || ICONS[ALIASES[key]] || '';
if (!inner) return '';
const cls = opts.className ? ` class="${opts.className}"` : '';
const style = opts.style ? ` style="${opts.style}"` : '';
const cls = (opts && opts.className) ? ` class="${opts.className}"` : '';
const style = (opts && opts.style) ? ` style="${opts.style}"` : '';
return (
`<svg${cls}${style} width="${size}" height="${size}" viewBox="0 0 24 24" ` +
`fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">` +
+223 -58
View File
@@ -5,6 +5,8 @@
*/
import uiModule from './ui.js';
import { splitTableRow } from './markdown/tableRow.js';
import { replaceEmojiShortcodes, hasEmojiShortcode } from './emojiShortcodes.js';
var escapeHtml = uiModule.esc;
@@ -34,12 +36,112 @@ function linkHtml(text, url) {
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer">${safeText}</a>`;
}
/**
* Sanitize the raw-HTML fragments that mdToHtml deliberately preserves from
* the source text <details> blocks (collapsible agent output) and <a> tags
* (emitted by the markdown link pass). Those fragments are later restored
* verbatim into innerHTML, so without scrubbing them a model or any content
* routed through here could smuggle in an `<img onerror=...>`, an
* `<a href="javascript:...">`, an `onmouseover=` handler, etc. and execute
* script in the authenticated page (DOM XSS).
*
* Parsing into a <template> is inert: assigning to template.innerHTML neither
* fetches resources nor runs scripts, so we can walk the resulting tree,
* drop script-capable elements, and strip event-handler attributes and
* dangerous URL schemes before the (now safe) fragment is handed back.
*/
const _ALLOWED_HTML_BAD_TAGS = new Set([
'SCRIPT', 'IFRAME', 'OBJECT', 'EMBED', 'LINK', 'META',
'STYLE', 'BASE', 'FORM', 'NOSCRIPT', 'TEMPLATE',
// Foreign-content roots. SVG/MathML have their own parser rules and are a
// classic mutation-XSS vehicle — e.g. an SVG-namespaced <script>, whose
// `tagName` is the lower-case 'script' and would slip a name check that
// assumed HTML's upper-casing. They aren't needed in the <details>/<a>
// fragments we preserve, so drop the whole subtree.
'SVG', 'MATH',
]);
const _ALLOWED_HTML_URL_ATTRS = new Set([
'href', 'src', 'srcset', 'xlink:href', 'action', 'formaction', 'background', 'poster',
]);
function _compactUrlSchemeValue(value) {
return String(value || '').replace(/[\u0000-\u0020\u007f-\u009f]+/g, '').toLowerCase();
}
function _isDangerousUrl(value) {
return /^(javascript|vbscript|data):/.test(_compactUrlSchemeValue(value));
}
function _isDangerousSrcset(value) {
return String(value || '').split(',').some(candidate => _isDangerousUrl(candidate));
}
function _cleanAllowedHtmlOnce(htmlString) {
const tpl = document.createElement('template');
tpl.innerHTML = htmlString;
for (const el of Array.from(tpl.content.querySelectorAll('*'))) {
// Upper-case the tag for comparison: HTML tagNames are upper-case, but
// SVG/MathML elements preserve their original (lower/camel) case, so a
// raw `Set.has(el.tagName)` would miss e.g. a namespaced <script>.
if (_ALLOWED_HTML_BAD_TAGS.has(el.tagName.toUpperCase())) {
el.remove();
continue;
}
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
// Drop every inline event handler (onerror, onclick, onmouseover, ...)
// and srcdoc (a frame-less script vector).
if (name.startsWith('on') || name === 'srcdoc') {
el.removeAttribute(attr.name);
continue;
}
if (name === 'style') {
const value = _compactUrlSchemeValue(attr.value);
if (/javascript:|vbscript:|data:|expression\(/.test(value)) {
el.removeAttribute(attr.name);
}
continue;
}
// Neutralize javascript:/vbscript:/data: in URL-bearing attributes.
// Strip control/space chars first so e.g. "java\tscript:" can't slip by.
if (_ALLOWED_HTML_URL_ATTRS.has(name)) {
if (name === 'srcset' ? _isDangerousSrcset(attr.value) : _isDangerousUrl(attr.value)) {
el.removeAttribute(attr.name);
}
}
}
}
return tpl.innerHTML;
}
function sanitizeAllowedHtml(html) {
const raw = String(html == null ? '' : html);
// Non-browser context (e.g. a future SSR/Node import): fail closed by
// escaping rather than trusting the markup.
if (typeof document === 'undefined') return escapeHtml(raw);
// Sanitize to a fixpoint. Re-parsing the serialized output can mutate the
// tree (the basis of mutation-XSS), so re-clean until it stops changing.
let out = raw;
for (let i = 0; i < 4; i++) {
const next = _cleanAllowedHtmlOnce(out);
if (next === out) break;
out = next;
}
return out;
}
/**
* Check if text has unclosed think tag
*/
export function hasUnclosedThinkTag(text) {
const openCount = (text.match(/<think(?:ing)?>/gi) || []).length;
const closeCount = (text.match(/<\/think(?:ing)?>/gi) || []).length;
text = text || '';
const openCount =
(text.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi) || []).length
+ (text.match(/<\|channel>thought/gi) || []).length;
const closeCount =
(text.match(/<\/(?:think(?:ing)?|thought)>/gi) || []).length
+ (text.match(/<channel\|>/gi) || []).length;
return openCount > closeCount;
}
@@ -47,8 +149,25 @@ export function startsWithReasoningPrefix(text) {
return /^\s*(?:thinking(?:\s+process)?\s*:|the user |i need |i should |i will |they are |the question |i can )/i.test(text || '');
}
export function normalizeThinkingMarkup(text) {
if (!text) return text;
let normalized = text;
normalized = normalized.replace(/<thought(\s+[^>]*)?>/gi, (_m, attrs = '') => `<think${attrs || ''}>`);
normalized = normalized.replace(/<\/thought>/gi, '</think>');
normalized = normalized.replace(/<\|channel>thought\s*\n?([\s\S]*?)<channel\|>\s*/gi, (_m, content = '') => {
const thought = String(content || '').trim();
return thought ? `<think>${thought}</think>\n` : '';
});
normalized = normalized.replace(/<\|channel>response\s*\n?([\s\S]*?)<channel\|>/gi, (_m, content = '') => content || '');
normalized = normalized.replace(/<\|channel>response\s*\n?/gi, '');
normalized = normalized.replace(/<channel\|>/gi, '');
return normalized;
}
function normalizePlainThinking(text) {
if (!text || /<think/i.test(text)) return text;
if (!text) return text;
text = normalizeThinkingMarkup(text);
if (/<think/i.test(text)) return text;
const trimmed = text.trimStart();
if (!startsWithReasoningPrefix(trimmed)) return text;
@@ -142,11 +261,21 @@ export function extractThinkingBlocks(text) {
// (b) Cut-off mid-generation — there's already real reply text before the
// opener. Drop from the tag onward as before (it's truncated thinking).
if (hasUnclosedThinkTag(normalized)) {
const strayOpener = cleanContent.match(/^\s*<think(?:ing)?(?:\s+[^>]*)?>([\s\S]*)$/i);
if (strayOpener) {
cleanContent = strayOpener[1];
const gemmaThoughtStart = cleanContent.search(/<\|channel>thought/i);
if (gemmaThoughtStart >= 0) {
const leakedThought = cleanContent
.slice(gemmaThoughtStart)
.replace(/^<\|channel>thought\s*\n?/i, '')
.trim();
if (gemmaThoughtStart === 0 && leakedThought) thinkingBlocks.push(leakedThought);
cleanContent = cleanContent.slice(0, gemmaThoughtStart);
} else {
cleanContent = cleanContent.replace(/<think(?:ing)?(?:\s+[^>]*)?>[\s\S]*$/gi, '');
const strayOpener = cleanContent.match(/^\s*<think(?:ing)?(?:\s+[^>]*)?>([\s\S]*)$/i);
if (strayOpener) {
cleanContent = strayOpener[1];
} else {
cleanContent = cleanContent.replace(/<think(?:ing)?(?:\s+[^>]*)?>[\s\S]*$/gi, '');
}
}
}
@@ -238,8 +367,19 @@ function _useSvgEmoji() {
return typeof document === 'undefined' || !document.body?.classList.contains('text-emojis');
}
export function svgifyEmoji(html) {
if (!_useSvgEmoji() || !html || !_EMOJI_RE.test(html)) return html;
// `opts.shortcodes` (default true) controls the issue-#345 `:name:` → emoji
// expansion. Chat passes it through as true; document/email body renderers pass
// false so author-typed `:shortcode:` text stays literal (see mdToHtml callers).
// The Unicode-emoji → monochrome-SVG pass always runs regardless, so a real 😀
// in a document still renders as the themed line icon as it always has.
export function svgifyEmoji(html, opts) {
if (!_useSvgEmoji() || !html) return html;
const allowShortcodes = !opts || opts.shortcodes !== false;
// Two reasons to walk the HTML: real Unicode emoji to turn into SVG icons,
// or `:shortcode:` text the model emitted instead of an emoji (issue #345).
const hasUnicode = _EMOJI_RE.test(html);
const hasShortcode = allowShortcodes && hasEmojiShortcode(html);
if (!hasUnicode && !hasShortcode) return html;
const parts = html.split(/(<[^>]*>)/); // odd indices = tags
let codeDepth = 0;
for (let i = 0; i < parts.length; i++) {
@@ -249,7 +389,13 @@ export function svgifyEmoji(html) {
else if (/^<\/(pre|code)\s*>/.test(t)) codeDepth = Math.max(0, codeDepth - 1);
continue;
}
if (codeDepth === 0 && _EMOJI_RE.test(parts[i])) parts[i] = _svgifyText(parts[i]);
if (codeDepth !== 0) continue;
let seg = parts[i];
// Expand shortcodes to Unicode first, then both they and any pre-existing
// Unicode emoji get rendered as the same monochrome line icons below.
if (hasShortcode) seg = replaceEmojiShortcodes(seg);
if (_EMOJI_RE.test(seg)) seg = _svgifyText(seg);
parts[i] = seg;
}
return parts.join('');
}
@@ -293,11 +439,47 @@ export function processWithThinking(text) {
/**
* Convert markdown to HTML
*/
export function mdToHtml(src) {
// CRITICAL: Extract allowed HTML blocks first (details/summary)
export function mdToHtml(src, opts) {
const allowedHtmlBlocks = [];
const codeBlocks = [];
const mermaidBlocks = [];
let s = (src ?? '');
// Extract fenced code blocks before any markdown/HTML preservation passes.
// Otherwise placeholders from the allowed-HTML sanitizer (e.g.
// ___ALLOWED_HTML_0___) can leak into quoted HTML/JS samples, because the
// placeholder gets captured as literal code content and never restored inside
// the final <pre><code> block.
s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => {
const cleaned = code
.replace(/\r\n/g, '\n')
.replace(/[ \t]+$/gm, '')
.replace(/^\s*\n+/, '')
.replace(/\n+\s*$/g, '');
// Mermaid diagrams: render as diagram instead of code block
if (lang && lang.toLowerCase() === 'mermaid') {
const mermaidId = 'mermaid-' + Date.now() + '-' + mermaidBlocks.length;
const raw = cleaned.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
const placeholder = `___MERMAID_BLOCK_${mermaidBlocks.length}___`;
mermaidBlocks.push(`<div class="mermaid-container"><pre class="mermaid" id="${mermaidId}">${escapeHtml(raw)}</pre></div>`);
return placeholder;
}
const escaped = cleaned.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
const placeholder = `___CODE_BLOCK_${codeBlocks.length}___`;
const langClass = lang ? ` class="language-${lang}"` : '';
const runnableLangs = ['python','py','javascript','js','html','bash','sh','shell','zsh'];
const runBtn = (lang && runnableLangs.includes(lang.toLowerCase()))
? `<button type="button" class="run-code" data-code="${escapeHtml(escaped)}" data-lang="${lang}" title="Run code"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>`
: '';
const editBtn = `<button type="button" class="edit-code" title="Edit"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>`;
codeBlocks.push(`<pre><code${langClass} data-lang="${lang || ''}">${escapeHtml(escaped)}</code>${runBtn}${editBtn}<button type="button" class="copy-code" data-code="${escapeHtml(escaped)}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></pre>`);
return placeholder;
});
// Repair common ways the agent mangles the entity-anchor convention
// (`[Name](#kind-<id>)`). Models reliably get the single-link case
// right but slip into other formats when listing many in a table.
@@ -342,9 +524,11 @@ export function mdToHtml(src) {
// allowlist keeps it from matching file names / versions ("package.json",
// "node.js", "v1.2.3"); the required start/[\s(<] prefix means domains
// already inside an http link (preceded by "//") or an email ("@") are
// skipped. Trailing sentence punctuation is kept outside the link.
// skipped. Require the TLD to end at a real domain boundary so dotted code
// identifiers like `sklearn.metrics` do not link `sklearn.me` and leave
// placeholder fragments in the remaining text.
s = s.replace(
/(^|[\s(<])((?:www\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9-]+)*\.(?:com|org|net|io|ai|co|dev|app|gov|edu|news|info|tech|xyz|me)(?:\/[^\s<>"'`\])]*)?)/gi,
/(^|[\s(<])((?:www\.)?[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9-]+)*\.(?:com|org|net|io|ai|co|dev|app|gov|edu|news|info|tech|xyz|me)(?=$|[\/\s<>"'`\]).,;:!?])(?:\/[^\s<>"'`\])]*)?)/gi,
(match, prefix, domain) => {
const trail = (domain.match(/[.,;:!?)]+$/) || [''])[0];
const core = trail ? domain.slice(0, -trail.length) : domain;
@@ -356,14 +540,14 @@ export function mdToHtml(src) {
// Default to open so agent output is visible
s = s.replace(/<details>([\s\S]*?)<\/details>/gi, (match) => {
const placeholder = `___ALLOWED_HTML_${allowedHtmlBlocks.length}___`;
allowedHtmlBlocks.push(match.replace(/<details>/i, '<details open>'));
allowedHtmlBlocks.push(sanitizeAllowedHtml(match.replace(/<details>/i, '<details open>')));
return placeholder;
});
// ALSO preserve <a> tags the same way (they're now in the HTML from markdown conversion)
s = s.replace(/<a\s+[^>]*>.*?<\/a>/gi, (match) => {
const placeholder = `___ALLOWED_HTML_${allowedHtmlBlocks.length}___`;
allowedHtmlBlocks.push(match);
allowedHtmlBlocks.push(sanitizeAllowedHtml(match));
return placeholder;
});
@@ -372,39 +556,6 @@ export function mdToHtml(src) {
s = s.replace(/\n{3,}/g, '\n\n');
// CRITICAL: Extract code blocks and replace with placeholders
const codeBlocks = [];
const mermaidBlocks = [];
s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => {
const cleaned = code
.replace(/\r\n/g, '\n')
.replace(/[ \t]+$/gm, '')
.replace(/^\s*\n+/, '')
.replace(/\n+\s*$/g, '');
// Mermaid diagrams: render as diagram instead of code block
if (lang && lang.toLowerCase() === 'mermaid') {
const mermaidId = 'mermaid-' + Date.now() + '-' + mermaidBlocks.length;
const raw = cleaned.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
const placeholder = `___MERMAID_BLOCK_${mermaidBlocks.length}___`;
mermaidBlocks.push(`<div class="mermaid-container"><pre class="mermaid" id="${mermaidId}">${escapeHtml(raw)}</pre></div>`);
return placeholder;
}
const escaped = cleaned.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
const placeholder = `___CODE_BLOCK_${codeBlocks.length}___`;
const langClass = lang ? ` class="language-${lang}"` : '';
const runnableLangs = ['python','py','javascript','js','html','bash','sh','shell','zsh'];
const runBtn = (lang && runnableLangs.includes(lang.toLowerCase()))
? `<button type="button" class="run-code" data-code="${escapeHtml(escaped)}" data-lang="${lang}" title="Run code"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>`
: '';
const editBtn = `<button type="button" class="edit-code" title="Edit"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>`;
codeBlocks.push(`<pre><code${langClass} data-lang="${lang || ''}">${escapeHtml(escaped)}</code>${runBtn}${editBtn}<button type="button" class="copy-code" data-code="${escapeHtml(escaped)}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></pre>`);
return placeholder;
});
// KaTeX math rendering (after code blocks are extracted, so math in code is safe)
const mathBlocks = [];
if (window.katex) {
@@ -458,16 +609,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>';
@@ -502,9 +655,20 @@ export function mdToHtml(src) {
s = s.replace(/^(\d+)\. (.*)$/gm, '<oli>$2</oli>');
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>`);
// GitHub-style task lists (- [ ] / - [x]) → checkbox items. Must run before
// the generic unordered-list rule so the "- " prefix isn't consumed first.
// Emits <uli> (with a class) so the unordered-list wrapper below treats it
// as a list item. Used by plan mode: plan + progress render as a checklist.
s = s.replace(/^(?:- |\* )\[([ xX])\] (.*)$/gm, (_m, mark, text) => {
const done = mark.toLowerCase() === 'x';
return `<uli class="task-item${done ? ' task-done' : ''}"><span class="task-check" aria-hidden="true"></span><span class="task-text">${text}</span></uli>`;
});
// Unordered lists. <uli> may carry attributes (task-item class), so the
// wrapper preserves them when converting <uli ...> → <li ...>.
s = s.replace(/^(?:- |\* )(.*)$/gm, '<uli>$1</uli>');
s = s.replace(/(^|\n)((?:<uli\b[^>]*>[^\n]*<\/uli>(?:\n|$))+)/g, (_, prefix, block) =>
`${prefix}<ul>${block.trim().replace(/<uli\b([^>]*)>/g, '<li$1>').replace(/<\/uli>/g, '</li>')}</ul>`);
// Blockquotes
s = s.replace(/^&gt; (.*)$/gm, '<bq>$1</bq>');
@@ -512,7 +676,7 @@ export function mdToHtml(src) {
`<blockquote>${m.trim().replace(/<\/?bq>/g, (t) => t === '<bq>' ? '<p>' : '</p>')}</blockquote>`);
// Paragraphs - but NOT for code block placeholders or allowed HTML
s = s.replace(/^(?!<h\d|<ul>|<ol>|<li>|<oli>|<pre>|<blockquote>|<bq>|<hr>|___CODE_BLOCK_|___ALLOWED_HTML_|___MATH_BLOCK_|___MERMAID_BLOCK_)([^\n]+)$/gm, '<p>$1</p>');
s = s.replace(/^(?!<h\d|<ul>|<ol>|<li|<oli>|<\/li>|<pre>|<blockquote>|<bq>|<hr>|___CODE_BLOCK_|___ALLOWED_HTML_|___MATH_BLOCK_|___MERMAID_BLOCK_)([^\n]+)$/gm, '<p>$1</p>');
// Line breaks within paragraphs
s = s.replace(/<p>([\s\S]*?)<\/p>/g, (match, content) => {
@@ -544,7 +708,7 @@ export function mdToHtml(src) {
s = s.replace(`___CODE_BLOCK_${index}___`, block);
});
return _useSvgEmoji() ? svgifyEmoji(s) : s;
return _useSvgEmoji() ? svgifyEmoji(s, opts) : s;
}
/**
@@ -602,6 +766,7 @@ const markdownModule = {
createCollapsible,
hasUnclosedThinkTag,
extractThinkingBlocks,
normalizeThinkingMarkup,
startsWithReasoningPrefix,
renderMermaid
};
+19
View File
@@ -0,0 +1,19 @@
// static/js/markdown/tableRow.js
//
// Pure helper for splitting a markdown table row into cells. No DOM —
// safe to import anywhere and to unit-test under node.
// Split a "| a | b | c |" row into trimmed cell strings.
//
// Strip only the optional leading/trailing pipe, then split — filtering out
// every empty cell (the old behaviour) dropped intentionally-empty interior
// cells too, so "| a | | c |" collapsed to 2 columns and misaligned with the
// header.
export function splitTableRow(row) {
const text = typeof row === 'string' ? row : '';
return text
.replace(/^\s*\|/, '')
.replace(/\|\s*$/, '')
.split('|')
.map((cell) => cell.trim());
}
+32 -5
View File
@@ -18,6 +18,26 @@ let selectedIds = new Set();
const MEMORY_CATEGORIES = ['fact', 'identity', 'preference', 'contact', 'project', 'goal', 'task'];
function _ensureNewMemoryCategorySelect() {
const sel = document.getElementById('new-memory-category');
if (!sel || sel.dataset.wired === '1') return;
sel.dataset.wired = '1';
MEMORY_CATEGORIES.forEach(cat => {
const opt = document.createElement('option');
opt.value = cat;
opt.textContent = cat;
if (cat === 'fact') opt.selected = true;
sel.appendChild(opt);
});
}
function _readNewMemoryCategory() {
_ensureNewMemoryCategorySelect();
const sel = document.getElementById('new-memory-category');
const cat = sel?.value || 'fact';
return MEMORY_CATEGORIES.includes(cat) ? cat : 'fact';
}
let _memoryDragWired = false;
function _wireMemoryDrag() {
if (_memoryDragWired) return;
@@ -274,6 +294,7 @@ async function syncPrefToggle(elementId, prefKey, onMsg, offMsg, dimBelow = true
}
export async function loadMemories() {
_ensureNewMemoryCategorySelect();
try {
const response = await fetch(`${window.location.origin}/api/memory`);
@@ -587,6 +608,9 @@ export function renderMemoryList() {
memoryList.innerHTML = '';
if (filtered.length === 0) {
const selectBtn = document.getElementById('memory-select-btn');
if (selectBtn) selectBtn.disabled = true;
if (selectMode) exitSelectMode();
const searchTerm = document.getElementById('memory-search')?.value?.trim() || '';
const _smiley = '<span style="vertical-align:-3px;margin-left:6px;">' + uiModule.emptyStateIcon('smiley') + '</span>';
if (searchTerm || activeCategory !== 'all') {
@@ -606,6 +630,9 @@ export function renderMemoryList() {
return;
}
const selectBtn = document.getElementById('memory-select-btn');
if (selectBtn) selectBtn.disabled = false;
filtered.forEach(memory => {
const item = document.createElement('div');
item.className = 'memory-item';
@@ -977,6 +1004,7 @@ export function updateMemoryCount() {
export async function addNewMemory() {
const input = document.getElementById('new-memory-input');
const text = input.value.trim();
const category = _readNewMemoryCategory();
if (!text) {
showError('Memory text cannot be empty');
@@ -991,6 +1019,7 @@ export async function addNewMemory() {
},
body: JSON.stringify({
text: text,
category: category,
})
});
@@ -1160,10 +1189,6 @@ async function handleImportFile(file) {
if (!file) return;
const sessionId = sessionModule?.getCurrentSessionId?.();
if (!sessionId) {
showError('Open a session first — import needs an AI model');
return;
}
const importBtn = document.getElementById('memory-import-btn');
const _origImportHtml = importBtn ? importBtn.innerHTML : '';
@@ -1180,7 +1205,9 @@ async function handleImportFile(file) {
try {
const formData = new FormData();
formData.append('file', file);
formData.append('session', sessionId);
if (sessionId) {
formData.append('session', sessionId);
}
const res = await fetch(`${window.location.origin}/api/memory/import`, {
method: 'POST',
+53 -8
View File
@@ -27,6 +27,7 @@
import { previewZoneAt, clearPreview, snapModalToZone } from './tileManager.js';
import { suspendDock, resumeDock, clearRightDock, applyEdgeDock } from './modalSnap.js';
import { dismissOrRemove } from './escMenuStack.js';
const _state = new Map(); // id -> { restoreFn, closeFn, railBtnId, isMinimized, restoreMinHeight }
@@ -77,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) {
@@ -89,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`;
}
@@ -379,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);
@@ -819,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');
@@ -835,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)')];
@@ -924,6 +938,7 @@ function _wireChipDrag(chip, dock) {
if (tz) {
const dx = (tz.left + tz.width / 2) - (l.x + l.width / 2);
const dy = (tz.top + tz.height / 2) - (l.y + l.height / 2);
l.chip.classList.add('chip-trashing');
l.chip.style.transition = 'transform 0.32s cubic-bezier(0.45, 0, 0.25, 1), opacity 0.3s ease-in, left 0.32s cubic-bezier(0.45, 0, 0.25, 1), top 0.32s cubic-bezier(0.45, 0, 0.25, 1)';
// Whirlpool: spin + shrink so the chip swirls into the X.
l.chip.style.transform = 'scale(0.15) rotate(720deg)';
@@ -987,6 +1002,7 @@ function _wireChipDrag(chip, dock) {
// `!important`, so the close animation needs setProperty(...important)
// too or the styles don't apply and the chip just snaps.
const cur = chip.style.transform || 'translate(0,0)';
chip.classList.add('chip-trashing');
chip.style.setProperty('transition', 'transform 0.32s cubic-bezier(0.45, 0, 0.25, 1), opacity 0.3s ease-in', 'important');
// Whirlpool: spin + shrink as the chip swirls into the X.
chip.style.setProperty('transform', `${cur} scale(0.15) rotate(720deg)`, 'important');
@@ -1213,7 +1229,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');
@@ -1452,6 +1470,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.
@@ -1463,7 +1499,7 @@ window.addEventListener('modal-dismissed', (e) => {
if (id === 'cookbook-modal') {
document.querySelectorAll(
'.cookbook-task-dropdown, .cookbook-gpu-split-menu, .hwfit-cached-dropdown, .cookbook-saved-menu, .cookbook-dep-menu'
).forEach(d => d.remove());
).forEach(dismissOrRemove);
}
});
@@ -1478,7 +1514,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
+361 -26
View File
@@ -5,8 +5,8 @@
// emailLibrary.js / documentLibrary.js / galleryEditor.js). While docked:
// - the modal-content lives at `right: 0; top: 0; bottom: 0` with a
// viewport-fraction width
// - body gets `right-dock-active` + `--right-dock-w` so the chat /
// doc panel / notes pane underneath reserves room via padding-right
// - body gets `right-dock-active` + `--right-dock-w` so the workspace
// underneath reserves room for the fixed side panel
// - if the remaining chat width would drop under 380px, the wide
// sidebar auto-collapses to the icon rail (mirrors notes-view UX)
//
@@ -21,6 +21,14 @@ const SNAP_PX = 60;
const UNSNAP_PX = 80;
const MIN_CHAT_WIDTH = 380;
const EMAIL_DOC_SPLIT_WIDTH_KEY = 'odysseus-email-doc-split-width';
const EDGE_DOCK_WIDTH_KEY_PREFIX = 'odysseus-edge-dock-width';
const MIN_EDGE_DOCK_WIDTH = 320;
let _edgeDockHandlePositioner = null;
function _positionEdgeDockResizeHandles() {
try { _edgeDockHandlePositioner && _edgeDockHandlePositioner(); } catch (_) {}
}
function _dockClassForSide(side) {
return side === 'left' ? 'modal-left-docked' : 'modal-right-docked';
@@ -48,6 +56,7 @@ export function clearDockSide(side, owner = null) {
if (side === 'left') {
try { window._restoreSidebarIfRouteCollapsed?.(); } catch (_) {}
}
_positionEdgeDockResizeHandles();
}
// Default dock width: ~38% of viewport, clamped to a reasonable band.
@@ -55,6 +64,78 @@ function _defaultDockWidth() {
return Math.min(640, Math.max(420, Math.round(window.innerWidth * 0.38)));
}
function _dockWidthStorageKey(modal, content, side) {
const id = modal?.id || content?.id || content?.dataset?.modalId || '';
return id ? `${EDGE_DOCK_WIDTH_KEY_PREFIX}:${side}:${id}` : null;
}
function _storedDockWidth(modal, content, side) {
const key = _dockWidthStorageKey(modal, content, side);
if (!key) return null;
try {
const n = parseFloat(localStorage.getItem(key) || '');
return Number.isFinite(n) && n > 0 ? n : null;
} catch (_) {
return null;
}
}
function _saveDockWidth(modal, content, side, width) {
const key = _dockWidthStorageKey(modal, content, side);
if (!key) return;
try { localStorage.setItem(key, String(Math.round(width))); } catch (_) {}
}
function _minEdgeDockWidth() {
return window.innerWidth < 900 ? 280 : MIN_EDGE_DOCK_WIDTH;
}
function _activeDockWidth(side) {
if (side !== 'left' && side !== 'right') return 0;
const cls = side === 'left' ? 'left-dock-active' : 'right-dock-active';
if (!document.body.classList.contains(cls)) return 0;
const prop = side === 'left' ? '--left-dock-w' : '--right-dock-w';
const raw = getComputedStyle(document.documentElement).getPropertyValue(prop);
const n = parseFloat(raw || '');
return Number.isFinite(n) && n > 0 ? n : 0;
}
function _clampDockWidthToSpace(width, min, max) {
const floor = Math.min(min, Math.max(220, Math.round(max)));
const ceiling = Math.max(floor, Math.round(max));
return Math.min(ceiling, Math.max(floor, Math.round(width)));
}
function _clampRightDockWidth(width) {
const min = _minEdgeDockWidth();
const navRight = _leftNavRight();
const leftDockW = _activeDockWidth('left');
const maxByChat = window.innerWidth - navRight - leftDockW - MIN_CHAT_WIDTH;
const max = Math.min(Math.round(window.innerWidth * 0.82), maxByChat);
return _clampDockWidthToSpace(width, min, max);
}
function _clampLeftDockWidth(width, left = _leftNavRight()) {
const min = _minEdgeDockWidth();
const rightDockW = _activeDockWidth('right');
const available = Math.max(0, window.innerWidth - left - rightDockW);
const max = Math.min(Math.round(available * 0.82), available - MIN_CHAT_WIDTH);
return _clampDockWidthToSpace(width, min, max);
}
function _resolveRightDockWidth(modal, content) {
return _clampRightDockWidth(content?._userDockWidth || _storedDockWidth(modal, content, 'right') || _defaultDockWidth());
}
function _resolveLeftDockWidth(content, left = _leftNavRight()) {
return _clampLeftDockWidth(content?._userDockWidth || _storedDockWidth(content?._dockOwner, content, 'left') || _resolveEmailDocSplitWidth(content, left), left);
}
function _isEmailDockOwner(owner) {
const id = owner?.id || '';
return id === 'email-lib-modal' || id.startsWith('email-reader-') || owner?.classList?.contains('email-window-modal');
}
function _showSnapHint(on, side = 'right') {
const cls = side === 'left' ? 'modal-snap-hint-left' : 'modal-snap-hint-right';
let hint = document.querySelector('.' + cls);
@@ -85,7 +166,7 @@ function _shouldAutoCollapseSidebar(dockW) {
const rl = (rail && window.getComputedStyle(rail).display !== 'none')
? rail.getBoundingClientRect().width
: 0;
const remaining = window.innerWidth - sb - rl - dockW;
const remaining = window.innerWidth - sb - rl - _activeDockWidth('left') - dockW;
return remaining < MIN_CHAT_WIDTH;
}
@@ -154,7 +235,7 @@ function _applyEmailDocSplitGeometry(left, emailWidth) {
if (!docPane || window.innerWidth <= 768) return;
docPane.style.setProperty('position', 'fixed', 'important');
docPane.style.setProperty('left', `${x}px`, 'important');
docPane.style.setProperty('right', '0px', 'important');
docPane.style.setProperty('right', 'var(--right-dock-w, 0px)', 'important');
docPane.style.setProperty('top', '0px', 'important');
docPane.style.setProperty('bottom', '0px', 'important');
docPane.style.setProperty('width', 'auto', 'important');
@@ -196,7 +277,9 @@ function _resolveEmailDocSplitWidth(content, left) {
function _anchorLeftDock(content) {
if (!content || content._dockSide !== 'left') return;
const left = _leftNavRight();
const w = _resolveEmailDocSplitWidth(content, left);
const w = document.body.classList.contains('doc-view')
? _resolveEmailDocSplitWidth(content, left)
: _resolveLeftDockWidth(content, left);
content.style.left = left + 'px';
content.style.width = w + 'px';
content.style.maxWidth = w + 'px';
@@ -205,14 +288,17 @@ function _anchorLeftDock(content) {
// the doc-pane becomes position:fixed starting at the email's right edge.
// No flex/max-width fighting; the doc just owns the right side from the
// email's right edge to the viewport edge — they touch flush, no gap.
const docOpen = document.body.classList.contains('doc-view');
const docOpen = document.body.classList.contains('doc-view') && _isEmailDockOwner(content._dockOwner);
if (docOpen) {
if (!document.body.classList.contains('email-doc-split-active')) {
document.body.classList.add('email-doc-split-active');
}
document.documentElement.style.setProperty('--left-dock-w', '0px');
_applyEmailDocSplitGeometry(left, w);
} else if (document.body.classList.contains('email-doc-split-active')) {
_clearEmailDocSplitGeometry();
} else {
document.documentElement.style.setProperty('--left-dock-w', w + 'px');
}
}
@@ -316,19 +402,21 @@ function _applyDockInternal(modal, side, dockClass) {
content.style.margin = '0';
let w;
if (side === 'left') {
// Email-style left dock: collapse the sidebar to the icon rail, then
// OVERLAY the window beside the rail, covering the chat area. We anchor
// at the rail's right edge (so it sits to the RIGHT of the rail — not
// left of the sidebar) and DON'T reserve body padding (so it covers the
// chat rather than pushing it), leaving the right side free for the doc.
// Left dock: collapse the sidebar to the icon rail, then pin the window
// beside the rail. Normal left docks reserve their width so chat shrinks;
// the email+document split keeps its existing overlay geometry.
_collapseSidebarToRail();
content._preDockSnapshot.collapsedSidebar = true;
content.style.right = 'auto';
content._dockSide = 'left';
content._dockOwner = modal;
_anchorLeftDock(content);
w = parseFloat(content.style.width) || 0;
document.body.classList.add('left-dock-active');
document.documentElement.style.setProperty('--left-dock-w', '0px'); // overlay, no push
document.documentElement.style.setProperty(
'--left-dock-w',
document.body.classList.contains('email-doc-split-active') ? '0px' : w + 'px',
);
// Re-anchor the email when the sidebar is toggled (expanded/collapsed) so
// the nav slides the window over instead of growing on top of it. Also
// re-anchor when the document editor pane appears/disappears (signaled by
@@ -406,7 +494,7 @@ function _applyDockInternal(modal, side, dockClass) {
};
}
} else {
w = _defaultDockWidth();
w = _resolveRightDockWidth(modal, content);
content.style.left = 'auto';
content.style.right = '0';
content.style.width = w + 'px';
@@ -419,6 +507,8 @@ function _applyDockInternal(modal, side, dockClass) {
}
}
content._dockSide = side;
content._dockOwner = modal;
_positionEdgeDockResizeHandles();
// Watch for the docked modal disappearing (removed from DOM or hidden
// via .hidden class) and clean up the body padding + sidebar in that
// case. Without this, closing a docked window leaves a phantom strip
@@ -426,11 +516,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 +570,27 @@ 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;
delete _c._dockOwner;
}
_positionEdgeDockResizeHandles();
}
function _expandSidebarFromRail() {
@@ -498,7 +614,11 @@ 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;
delete content._dockOwner;
_disconnectLeftDockObservers(content);
const snap = content._preDockSnapshot;
// Re-expand the wide sidebar if we collapsed it — but only if the
@@ -544,6 +664,7 @@ export function clearRightDock(modal, cx, cy, dockClass) {
content.style.top = (typeof targetTop === 'number') ? targetTop + 'px' : targetTop;
delete content._preDockSnapshot;
delete content._dockSuspended;
_positionEdgeDockResizeHandles();
}
// Temporarily release a docked modal's body push (chat returns to full
@@ -555,8 +676,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,10 +691,25 @@ 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._dockOwner;
delete content._dockSuspended;
return null;
}
if (side === 'left' && !_hasOtherDockedWindow('left', modal)) {
_clearEmailDocSplitGeometry();
}
if (content._preDockSnapshot?.collapsedSidebar && !_hasAnyOtherDockedWindow(modal)) {
_expandSidebarFromRail();
}
content._dockSuspended = side;
_positionEdgeDockResizeHandles();
return side;
}
@@ -599,15 +737,11 @@ export function makeRightDockController(modal, dockClass = 'modal-right-docked')
return makeEdgeDockController(modal, 'right', dockClass);
}
// Read live rail+sidebar width — used as the LEFT "edge" for snap
// detection, since the visible left boundary the user can drag to is
// the nav, not x=0 (the rail covers 0..48 and the wide sidebar covers
// 0..~290 when open).
// Read the current visible left-nav edge for snap detection. Use measured
// geometry instead of CSS vars because the sidebar can auto-collapse during a
// dock operation while --sidebar-w is still settling.
function _leftNavWidth() {
const rs = getComputedStyle(document.documentElement);
const rail = parseInt(rs.getPropertyValue('--icon-rail-w') || '48', 10) || 0;
const sb = parseInt(rs.getPropertyValue('--sidebar-w') || '0', 10) || 0;
return rail + sb;
return _leftNavRight();
}
// Generic edge-snap controller. `side` is 'left' or 'right'. Same pattern
@@ -650,6 +784,207 @@ export function makeEdgeDockController(modal, side = 'right', dockClass) {
};
}
(function _initEdgeDockResizeHandles() {
if (typeof document === 'undefined') return;
if (!document.body) {
document.addEventListener('DOMContentLoaded', _initEdgeDockResizeHandles, { once: true });
return;
}
const handles = {
left: document.createElement('div'),
right: document.createElement('div'),
};
const _setStyle = (el, prop, value) => {
if (el.style[prop] !== value) el.style[prop] = value;
};
const _hideHandle = (handle) => _setStyle(handle, 'display', 'none');
for (const side of ['left', 'right']) {
const handle = handles[side];
handle.className = `edge-dock-resize-handle edge-dock-resize-handle-${side}`;
handle.style.position = 'fixed';
handle.style.top = '0';
handle.style.bottom = '0';
handle.style.width = '10px';
handle.style.cursor = 'col-resize';
handle.style.background = 'linear-gradient(to right, transparent 0 3px, color-mix(in srgb, var(--accent, var(--red)) 35%, transparent) 3px 7px, transparent 7px 10px)';
handle.style.pointerEvents = 'auto';
handle.style.touchAction = 'none';
handle.style.display = 'none';
handle.title = 'Drag to resize docked window';
document.body.appendChild(handle);
}
const _isUsableDockOwner = (owner) => {
if (!owner || !owner.isConnected) return false;
if (owner.classList?.contains('hidden')) return false;
if (owner.style?.display === 'none') return false;
const nodes = _resolveDockNodes(owner);
const content = nodes?.content;
if (!content || !content.isConnected) return false;
if (content.classList?.contains('hidden')) return false;
if (content.style?.display === 'none') return false;
const r = content.getBoundingClientRect();
return r.width > 0 && r.height > 0;
};
const _activeDockOwner = (side) => {
const cls = _dockClassForSide(side);
const all = Array.from(document.querySelectorAll(`.${cls}`));
for (const owner of all.reverse()) {
if (_isUsableDockOwner(owner)) return owner;
}
return null;
};
const _zIndexFor = (el, fallback = 250) => {
const raw = el ? window.getComputedStyle(el).zIndex : '';
const n = parseInt(raw, 10);
return Number.isFinite(n) ? n : fallback;
};
const _hasVisibleFloatingModal = (owner) => {
const all = Array.from(document.querySelectorAll('.modal:not(.hidden):not(.modal-minimized)'));
return all.some((modal) => {
if (!modal || modal === owner) return false;
if (owner?.contains?.(modal) || modal.contains?.(owner)) return false;
if (modal.classList.contains('modal-left-docked')
|| modal.classList.contains('modal-right-docked')
|| modal.classList.contains('email-snap-left')) return false;
if (modal.style.display === 'none') return false;
const content = _resolveDockNodes(modal)?.content;
const r = content?.getBoundingClientRect?.();
return !!r && r.width > 0 && r.height > 0;
});
};
const _setWidth = (owner, side, clientX) => {
const nodes = _resolveDockNodes(owner);
const content = nodes?.content;
if (!content) return 0;
let w = 0;
if (side === 'right') {
w = _clampRightDockWidth(window.innerWidth - clientX);
content._userDockWidth = w;
content.style.left = 'auto';
content.style.right = '0';
content.style.width = w + 'px';
content.style.maxWidth = w + 'px';
document.body.classList.add('right-dock-active');
document.documentElement.style.setProperty('--right-dock-w', w + 'px');
if (_shouldAutoCollapseSidebar(w)) {
_collapseSidebarToRail();
if (content._preDockSnapshot) content._preDockSnapshot.collapsedSidebar = true;
}
} else {
const left = _leftNavRight();
w = _clampLeftDockWidth(clientX - left, left);
content._userDockWidth = w;
content._emailDocSplitUserW = w;
content.style.left = left + 'px';
content.style.right = 'auto';
content.style.width = w + 'px';
content.style.maxWidth = w + 'px';
document.body.classList.add('left-dock-active');
document.documentElement.style.setProperty(
'--left-dock-w',
document.body.classList.contains('email-doc-split-active') ? '0px' : w + 'px',
);
}
_positionEdgeDockResizeHandles();
return w;
};
_edgeDockHandlePositioner = () => {
const splitOwnsLeftSeam = document.body.classList.contains('email-doc-split-active')
&& document.body.classList.contains('doc-view')
&& window.innerWidth > 768;
for (const side of ['left', 'right']) {
const handle = handles[side];
if (window.innerWidth <= 768 || (side === 'left' && splitOwnsLeftSeam)) {
_hideHandle(handle);
continue;
}
const owner = _activeDockOwner(side);
const content = owner && _resolveDockNodes(owner)?.content;
if (!content) {
_hideHandle(handle);
continue;
}
if (_hasVisibleFloatingModal(owner)) {
_hideHandle(handle);
continue;
}
const r = content.getBoundingClientRect();
const x = side === 'right' ? r.left : r.right;
if (!Number.isFinite(x) || x <= 0 || x >= window.innerWidth) {
_hideHandle(handle);
continue;
}
_setStyle(handle, 'display', 'block');
_setStyle(handle, 'left', (x - 5) + 'px');
_setStyle(handle, 'zIndex', String(_zIndexFor(owner) + 1));
}
};
for (const side of ['left', 'right']) {
const handle = handles[side];
handle.addEventListener('pointerdown', (e) => {
if (handle.style.display === 'none') return;
const owner = _activeDockOwner(side);
if (!owner) return;
e.preventDefault();
e.stopPropagation();
handle.setPointerCapture?.(e.pointerId);
const nodes = _resolveDockNodes(owner);
const content = nodes?.content;
const prevCursor = document.body.style.cursor;
const prevUserSelect = document.body.style.userSelect;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.body.classList.add('edge-dock-resizing');
_setWidth(owner, side, e.clientX);
const onMove = (ev) => {
ev.preventDefault();
_setWidth(owner, side, ev.clientX);
};
const onUp = (ev) => {
try { handle.releasePointerCapture?.(e.pointerId); } catch (_) {}
document.removeEventListener('pointermove', onMove, true);
document.removeEventListener('pointerup', onUp, true);
document.removeEventListener('pointercancel', onUp, true);
document.body.classList.remove('edge-dock-resizing');
document.body.style.cursor = prevCursor;
document.body.style.userSelect = prevUserSelect;
const finalW = side === 'right'
? parseFloat(document.documentElement.style.getPropertyValue('--right-dock-w')) || content?.getBoundingClientRect?.().width || 0
: content?.getBoundingClientRect?.().width || 0;
if (finalW) _saveDockWidth(owner, content, side, finalW);
ev.preventDefault();
};
document.addEventListener('pointermove', onMove, true);
document.addEventListener('pointerup', onUp, true);
document.addEventListener('pointercancel', onUp, true);
});
}
new MutationObserver(_positionEdgeDockResizeHandles).observe(document.body, { attributes: true, attributeFilter: ['class'] });
new MutationObserver(_positionEdgeDockResizeHandles).observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
let raf = 0;
const schedulePosition = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
raf = 0;
_positionEdgeDockResizeHandles();
});
};
new MutationObserver(schedulePosition).observe(document.body, { childList: true });
window.addEventListener('resize', _positionEdgeDockResizeHandles);
window.addEventListener('odysseus:modal-opened', _positionEdgeDockResizeHandles);
_positionEdgeDockResizeHandles();
})();
(function _initSplitSeamIndicator() {
if (typeof document === 'undefined') return;
const stripe = document.createElement('div');
+19
View File
@@ -0,0 +1,19 @@
// static/js/model/matchKey.js
//
// Pure helper for matching a model name against a set of known keys. No DOM —
// safe to import anywhere and to unit-test under node.
// Return the most specific (longest) key that is a substring of `name`, or null.
// Returning the first match instead made "gpt-4o-mini" match the shorter
// "gpt-4o" key — billing it at gpt-4o rates (~16x) and showing the wrong
// context window.
export function matchModelKey(name, keys) {
const n = (name || '').toLowerCase();
let best = null;
for (const key of keys) {
if (n.includes(key) && (best === null || key.length > best.length)) {
best = key;
}
}
return best;
}
+272 -33
View File
@@ -8,6 +8,49 @@ import { sortModelObjects } from './modelSort.js';
const API_BASE = window.location.origin;
// ── Recent + Favorites persistence ──
// Recent is auto-tracked (last 5 picks, most-recent-first) and lives in its
// own key. Favorites is the SAME key the sidebar Models section uses, so a
// favorite toggled here shows up there and vice-versa.
const RECENT_KEY = 'odysseus-model-recent';
const FAVORITES_KEY = 'odysseus-model-favorites';
const RECENT_MAX = 5;
// Catalogs at or below this size are small enough that hiding everything
// behind search would be a regression — keep listing them in browse mode.
const BROWSE_ALL_LIMIT = 12;
function _loadList(key) {
try {
const a = JSON.parse(localStorage.getItem(key) || '[]');
return Array.isArray(a) ? a : [];
} catch { return []; }
}
function _saveList(key, list) {
try { localStorage.setItem(key, JSON.stringify(list)); } catch { /* quota / private mode */ }
}
function _loadRecent() { return _loadList(RECENT_KEY); }
function _pushRecent(mid) {
if (!mid) return;
const next = _loadRecent().filter(x => x !== mid);
next.unshift(mid);
_saveList(RECENT_KEY, next.slice(0, RECENT_MAX));
}
function _loadFavorites() { return _loadList(FAVORITES_KEY); }
function _toggleFavorite(mid) {
const favs = _loadFavorites();
const i = favs.indexOf(mid);
if (i >= 0) favs.splice(i, 1);
else favs.push(mid);
_saveList(FAVORITES_KEY, favs);
// Keep the sidebar Models section (same key) in sync if it's mounted.
try {
if (window.modelsModule && typeof window.modelsModule.refreshModels === 'function') {
window.modelsModule.refreshModels();
}
} catch { /* sidebar not present */ }
return i < 0; // true when now favorited
}
// ── Shared keyboard nav for model pickers ──
function _handlePickerKeydown(e, listEl, itemSelector, closeFn) {
if (e.key === 'Escape') { closeFn(); return; }
@@ -136,14 +179,23 @@ function _initModelPickerDropdown() {
const result = [];
const seen = new Set();
items.forEach(item => {
if (item.offline) return;
// Previously: offline endpoints were skipped entirely, so a server
// that briefly went down disappeared from the picker — confusing
// when the user can still see it (offline-tagged) in Settings.
// Now: include offline-endpoint models too but flag them
// `stale: true` so the row renderer dims them + shows the offline
// pill. The user can still click and try anyway (matches the
// existing "local server appears offline" path on line 301).
const epOffline = !!item.offline;
const allModels = (item.models || []).concat(item.models_extra || []);
const allDisplay = (item.models_display || []).concat(item.models_extra_display || []);
// Mark local endpoints whose live probe failed.
const probeResult = item.endpoint_id ? _localProbe[item.endpoint_id] : null;
const isLocalDead = !!(probeResult && probeResult.alive === false);
allModels.forEach((mid, i) => {
// Deduplicate by model ID — prefer DB endpoints over env-discovered
// Deduplicate by model ID — prefer ONLINE endpoint entries over
// offline duplicates so the user gets a working endpoint first
// when the same model is exposed by both.
if (seen.has(mid)) return;
seen.add(mid);
result.push({
@@ -152,18 +204,75 @@ function _initModelPickerDropdown() {
url: item.url,
endpointId: item.endpoint_id,
epName: item.endpoint_name || '',
stale: isLocalDead,
staleReason: isLocalDead ? (probeResult.error || 'not responding') : '',
providerText: [
item.endpoint_name || '',
item.category || '',
item.host || '',
item.url || '',
].filter(Boolean).join(' '),
stale: isLocalDead || epOffline,
staleReason: epOffline
? (item.ping_error || 'endpoint offline')
: (isLocalDead ? (probeResult.error || 'not responding') : ''),
offline: epOffline,
});
});
});
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();
const q = (filter || '').toLowerCase();
const q = (filter || '').trim().toLowerCase();
const hasAnyModel = all.length > 0;
listEl.classList.toggle('is-empty', !hasAnyModel);
menu.classList.toggle('no-models', !hasAnyModel);
@@ -171,22 +280,17 @@ function _initModelPickerDropdown() {
search.placeholder = hasAnyModel ? 'Search models…' : 'No models connected';
}
if (searchRow) {
searchRow.classList.toggle('searching', !!filter);
searchRow.classList.toggle('searching', !!q);
}
// Load favorites
const favs = (function() { try { return JSON.parse(localStorage.getItem('odysseus-model-favorites') || '[]'); } catch { return []; } })();
if (!hasAnyModel) return; // collapsed empty list — nothing to render
// Partition: favorites first, then rest
const favModels = [];
const restModels = [];
all.forEach(m => {
if (q && !m.mid.toLowerCase().includes(q) && !m.display.toLowerCase().includes(q)) return;
if (favs.includes(m.mid)) favModels.push(m);
else restModels.push(m);
});
sortModelObjects(favModels).forEach(function(m, i) { favModels[i] = m; });
sortModelObjects(restModels).forEach(function(m, i) { restModels[i] = m; });
// Unique lookup so Recent/Favorites (stored as bare model IDs) can be
// resolved back to full model objects; drops anything no longer offered.
const byId = new Map();
all.forEach(m => { if (!byId.has(m.mid)) byId.set(m.mid, m); });
const favs = _loadFavorites();
function _addSection(label) {
const el = document.createElement('div');
@@ -194,6 +298,12 @@ function _initModelPickerDropdown() {
el.textContent = label;
listEl.appendChild(el);
}
function _addEmpty(text) {
const empty = document.createElement('div');
empty.className = 'model-switch-empty';
empty.textContent = text;
listEl.appendChild(empty);
}
function _addRow(m) {
const row = document.createElement('div');
row.className = 'model-switch-item';
@@ -211,7 +321,11 @@ function _initModelPickerDropdown() {
row.appendChild(logoSpan);
}
const nameSpan = document.createElement('span');
nameSpan.className = 'mp-model-name';
nameSpan.textContent = m.display;
// Long model names are clipped with ellipsis — expose the full name on
// hover so the suffix/variant tag is still discoverable (#1982).
nameSpan.title = m.display;
row.appendChild(nameSpan);
if (m.stale) {
const badge = document.createElement('span');
@@ -226,27 +340,143 @@ function _initModelPickerDropdown() {
const _epDisplay = m.epName && !m.display.toLowerCase().includes(m.epName.toLowerCase().split('/').pop()) ? m.epName : '';
epSpan.textContent = _epDisplay;
row.appendChild(epSpan);
// Inline favorite dot — toggles favorite, never picks the model.
const favDot = document.createElement('button');
favDot.type = 'button';
favDot.className = 'mp-fav-dot' + (favs.includes(m.mid) ? ' active' : '');
favDot.textContent = '●';
const _setFavState = (on) => {
favDot.classList.toggle('active', on);
favDot.title = on ? 'Remove from favorites' : 'Add to favorites';
favDot.setAttribute('aria-label', on ? 'Remove from favorites' : 'Add to favorites');
favDot.setAttribute('aria-pressed', on ? 'true' : 'false');
};
_setFavState(favs.includes(m.mid));
favDot.addEventListener('click', (e) => {
e.stopPropagation();
const nowFav = _toggleFavorite(m.mid);
_setFavState(nowFav);
favDot.classList.remove('pulse');
void favDot.offsetWidth;
favDot.classList.add('pulse');
// Keep our in-memory copy aligned so a follow-up re-render is correct.
const idx = favs.indexOf(m.mid);
if (nowFav && idx < 0) favs.push(m.mid);
else if (!nowFav && idx >= 0) favs.splice(idx, 1);
if (uiModule && uiModule.showToast) uiModule.showToast(nowFav ? 'Favorited' : 'Unfavorited');
// In browse mode the Favorites section membership changed — rebuild
// (cheap: Recent + Favorites). In search mode the row stays put, so
// the in-place favorite update above is enough.
if (!q) {
const st = listEl.scrollTop;
_populate('');
listEl.scrollTop = st;
}
});
row.appendChild(favDot);
row.addEventListener('click', () => _pick(m));
listEl.appendChild(row);
}
if (favModels.length > 0) {
// ── Search mode: flat, filtered results across the whole catalog ──
if (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;
}
// ── Browse mode: Favorites (manual) + Recent (auto), with dedupe. ──
// Rules:
// 1. Never list the same model twice in the dropdown. Favorites
// win over Recent (if you favorited it, that's where it
// belongs — Recent shouldn't show it again as duplicate).
// 2. Small catalogs (≤ BROWSE_ALL_LIMIT total) skip the Recent
// section entirely — when there's only ~10 models, the whole
// list fits below as "All models" and a separate Recent
// section just duplicates rows.
const shown = new Set();
const favModels = favs.map(id => byId.get(id)).filter(Boolean);
if (favModels.length) {
_addSection('Favorites');
favModels.forEach(_addRow);
favModels.forEach(m => { shown.add(m.mid); _addRow(m); });
}
if (restModels.length > 0) {
if (favModels.length > 0) _addSection('All models');
restModels.forEach(_addRow);
}
if (listEl.children.length === 0) {
const empty = document.createElement('div');
empty.className = 'model-switch-empty';
if (hasAnyModel) {
empty.textContent = 'No matching models';
} else {
return;
// Recent: only render when the catalog is big enough that surfacing
// a recency shortlist is actually useful, AND only models that
// aren't already in Favorites (dedupe).
if (all.length > BROWSE_ALL_LIMIT) {
const recentModels = _loadRecent()
.map(id => byId.get(id))
.filter(Boolean)
.filter(m => !shown.has(m.mid))
.slice(0, RECENT_MAX);
if (recentModels.length) {
_addSection('Recent');
recentModels.forEach(m => { shown.add(m.mid); _addRow(m); });
}
listEl.appendChild(empty);
}
// Small catalogs: still list everything so users aren't forced to search.
if (all.length <= BROWSE_ALL_LIMIT) {
const rest = all.filter(m => !shown.has(m.mid));
if (rest.length) {
if (shown.size) _addSection('All models');
rest.forEach(_addRow);
}
} 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;
}
});
}
}
@@ -254,6 +484,10 @@ function _initModelPickerDropdown() {
const currentSessionId = _deps.getCurrentSessionId();
const _pendingChat = _deps.getPendingChat();
// Remember this pick so it surfaces under "Recent" next time the picker
// opens — the whole point of quick-switch.
if (m && m.mid) _pushRecent(m.mid);
// Broadcast immediately so listeners (e.g. the tour) can advance without
// waiting for the async session-create/PATCH that follows.
try { document.dispatchEvent(new CustomEvent('odysseus:model-picked', { detail: m })); } catch {}
@@ -330,6 +564,7 @@ function _initModelPickerDropdown() {
url: item.url || detail.url || '',
endpointId: item.endpoint_id || detail.endpointId || '',
epName: item.endpoint_name || detail.endpointName || '',
providerText: [item.endpoint_name || detail.endpointName || '', item.url || detail.url || ''].filter(Boolean).join(' '),
};
break;
}
@@ -341,6 +576,7 @@ function _initModelPickerDropdown() {
url: detail.url,
endpointId: detail.endpointId || '',
epName: detail.endpointName || '',
providerText: [detail.endpointName || '', detail.url || ''].filter(Boolean).join(' '),
};
}
if (match) await _pick(match);
@@ -353,7 +589,7 @@ function _initModelPickerDropdown() {
menu.classList.remove('closing', 'hidden');
_populate('');
if (window.modelsModule && window.modelsModule.refreshModels) {
window.modelsModule.refreshModels(true).then(() => {
window.modelsModule.refreshModels().then(() => {
if (!menu.classList.contains('hidden')) _populate(search.value || '');
updateModelPicker();
}).catch(() => {});
@@ -478,6 +714,9 @@ export function updateModelPicker() {
}
const displayName = modelId ? modelId.split('/').pop() : 'Select model';
// The header indicator clips long names with ellipsis; show the full model
// identifier on hover (#1982). No tooltip on the "Select model" placeholder.
label.title = modelId || '';
const logo = modelId ? providerLogo(modelId) : null;
if (logo) {
label.innerHTML = '<span class="model-picker-logo">' + logo + '</span> ' + displayName;
+6 -2
View File
@@ -14,8 +14,12 @@ function _compareText(a, b) {
});
}
function _arrayOrEmpty(models) {
return Array.isArray(models) ? models : [];
}
export function sortModelIds(models) {
return (models || []).slice().sort(_compareText);
return _arrayOrEmpty(models).slice().sort(_compareText);
}
export function compareModelObjects(a, b) {
@@ -25,5 +29,5 @@ export function compareModelObjects(a, b) {
}
export function sortModelObjects(models) {
return (models || []).slice().sort(compareModelObjects);
return _arrayOrEmpty(models).slice().sort(compareModelObjects);
}
+11 -3
View File
@@ -16,6 +16,7 @@ import { sortModelIds } from './modelSort.js';
let API_BASE = '';
let _cachedItems = []; // cached /api/models items for model-switch dropdown
let _lastFetchTime = 0;
let _fetchInflight = null;
const _FETCH_CACHE_TTL = 30000; // 30s client-side cache for /api/models
const COLLAPSE_KEY = 'odysseus-models-collapsed';
const FAVORITES_KEY = 'odysseus-model-favorites';
@@ -176,8 +177,15 @@ export async function refreshModels(force = false) {
box.appendChild(_loadingSpinner.createElement());
_loadingSpinner.start();
try {
const res = await fetch(`${API_BASE}/api/models`);
const data = await res.json();
if (!_fetchInflight) {
_fetchInflight = fetch(`${API_BASE}/api/models`, { credentials: 'same-origin' })
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.finally(() => { _fetchInflight = null; });
}
const data = await _fetchInflight;
_lastFetchTime = Date.now();
_cachedItems = data.items || [];
} catch (e) {
@@ -554,7 +562,7 @@ export async function refreshModels(force = false) {
box.appendChild(noModels);
// No endpoints yet: keep the welcome screen focused on first setup.
const welcomeSub = document.getElementById('welcome-sub');
if (welcomeSub) welcomeSub.innerHTML = 'Type <span style="color:var(--accent,var(--red));font-weight:600">/setup</span> to get started.';
if (welcomeSub) welcomeSub.innerHTML = 'Type <span class="setup-trigger-link" style="color:var(--accent,var(--red));font-weight:600;cursor:pointer;text-decoration:underline;" title="Click to launch setup">/setup</span> to get started.';
const welcomeTip = document.getElementById('welcome-tip');
if (welcomeTip) welcomeTip.textContent = 'Type /setup, then choose Local models or API.';
} else {
+140 -31
View File
@@ -31,6 +31,9 @@ let _reminderTimer = null;
// (previously leaked one per openPanel; on multi-open sessions this
// stacked dozens of identical handlers).
let _notesKeydownHandler = null;
// Capture-phase "Esc cancels select mode" listener on document — tracked so it
// is removed on close instead of leaking +1 per panel open/close cycle.
let _notesSelectEscHandler = null;
const REMINDER_FIRED_KEY = 'odysseus-notes-reminder-fired';
// Note IDs already shown with the entry-glow once. Re-set when the user
// reschedules the reminder so the new firing glows again on next open.
@@ -54,6 +57,10 @@ function _forceCloseNotesPanel() {
document.removeEventListener('keydown', _notesKeydownHandler);
_notesKeydownHandler = null;
}
if (_notesSelectEscHandler) {
document.removeEventListener('keydown', _notesSelectEscHandler, true);
_notesSelectEscHandler = null;
}
if (_reminderTimer) {
clearInterval(_reminderTimer);
_reminderTimer = null;
@@ -438,13 +445,22 @@ async function _patchNote(id, patch) {
// ---- Helpers ----
function _esc(s) { return uiModule.esc ? uiModule.esc(s || '') : (s || '').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
// Image src guard — reject anything that isn't a relative path or http(s)/data URL
// so an AI-saved note can't slip a `javascript:` URL into the rendered <img>.
function _attrEsc(s) {
return String(s || '')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/`/g, '&#96;');
}
// Image src guard — reject anything that isn't a relative path, http(s), or
// raster data URL so an AI-saved note can't slip script-capable media into the
// rendered <img>.
function _safeImgSrc(s) {
const v = (s || '').trim();
if (!v) return '';
if (v.startsWith('/') || v.startsWith('./') || v.startsWith('../')) return v;
if (/^https?:\/\//i.test(v) || /^data:image\//i.test(v)) return v;
if (/^https?:\/\//i.test(v) || /^data:image\/(?:png|jpe?g|gif|webp);base64,/i.test(v)) return v;
return '';
}
@@ -461,7 +477,7 @@ function _linkify(s) {
url = url.slice(0, -1);
}
const href = url.startsWith('www.') ? `https://${url}` : url;
return `<a href="${href}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
return `<a href="${_attrEsc(href)}" class="note-link" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()">${url}</a>` + (url !== m ? m.slice(url.length) : '');
});
}
function _uid() { return Math.random().toString(36).slice(2, 10); }
@@ -1118,16 +1134,15 @@ export function openPanel() {
<div class="notes-pane-header">
<h4 class="notes-pane-title"><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:-2.5px;margin-right:6px"><path d="M5 3h10l4 4v14H5z"/><path d="M15 3v5h5"/><path d="M8 17.5 15.5 10l2.5 2.5L10.5 20H8z"/></svg>Notes</h4>
<span style="flex:1"></span>
<button id="notes-archive-toggle" class="doc-action-icon-btn notes-header-text-btn" title="View archive" style="opacity:0.6;gap:5px;">
<button id="notes-archive-toggle" class="doc-action-icon-btn notes-header-text-btn" title="View archive" style="opacity:0.8;gap:5px;">
<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="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 002 2h12a2 2 0 002-2V8"/><path d="M10 12h4"/></svg>
<span class="notes-header-btn-label">Archive</span>
</button>
<button id="notes-view-toggle" class="doc-action-icon-btn notes-header-text-btn" title="Toggle view" style="opacity:0.6;gap:5px;">
<button id="notes-view-toggle" class="doc-action-icon-btn notes-header-text-btn" title="Toggle view" style="opacity:0.8;gap:5px;">
<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="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
<span class="notes-header-btn-label">Toggle</span>
</button>
<button id="notes-minimize-btn" class="modal-minimize-btn" title="Minimize" aria-label="Minimize notes" style="position:relative;left:2px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="18" x2="18" y2="18"/></svg></button>
<button id="notes-close-btn" class="close-btn" title="Close" aria-label="Close notes"></button>
</div>
<div class="notes-search-bar">
<input type="text" id="notes-search" class="memory-search-input" placeholder="Search notes…" autocomplete="off" />
@@ -1190,13 +1205,6 @@ export function openPanel() {
e.stopPropagation();
closePanel('down');
});
const closeBtn = document.getElementById('notes-close-btn');
if (closeBtn) closeBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
_forceCloseNotesPanel();
});
// Search
const searchEl = document.getElementById('notes-search');
if (searchEl) {
@@ -1214,7 +1222,7 @@ export function openPanel() {
const syncArchiveBtn = () => {
archiveBtn.classList.toggle('active', _showingArchived);
archiveBtn.title = _showingArchived ? 'Exit archive' : 'View archive';
archiveBtn.style.opacity = _showingArchived ? '1' : '0.6';
archiveBtn.style.opacity = _showingArchived ? '1' : '0.8';
// Swap to an X while in archive view so it doubles as a close-back-
// to-active-notes toggle.
archiveBtn.innerHTML = _showingArchived ? CLOSE_ICON : ARCHIVE_ICON;
@@ -1269,13 +1277,17 @@ export function openPanel() {
// than a *-bulk-cancel button, so the global Esc-cancel handler in
// keyboard-shortcuts.js can't reach it — handle it here. Capture phase
// + stopPropagation so Esc cancels select instead of closing the panel.
document.addEventListener('keydown', (e) => {
if (_notesSelectEscHandler) {
document.removeEventListener('keydown', _notesSelectEscHandler, true);
}
_notesSelectEscHandler = (e) => {
if (e.key === 'Escape' && _selectMode) {
e.preventDefault();
e.stopPropagation();
_exitSelectMode();
}
}, true);
};
document.addEventListener('keydown', _notesSelectEscHandler, true);
document.getElementById('notes-select-all').addEventListener('change', (e) => {
if (e.target.checked) _notes.forEach(n => _selectedIds.add(n.id));
else _selectedIds.clear();
@@ -1579,6 +1591,10 @@ export function closePanel(direction) {
document.removeEventListener('keydown', _notesKeydownHandler);
_notesKeydownHandler = null;
}
if (_notesSelectEscHandler) {
document.removeEventListener('keydown', _notesSelectEscHandler, true);
_notesSelectEscHandler = null;
}
if (_reminderTimer) {
clearInterval(_reminderTimer);
_reminderTimer = null;
@@ -2022,12 +2038,12 @@ function _renderQuickAdd(body) {
// drawing happens in the expanded form). The pill that's active steers
// both the placeholder and the type the form opens in.
wrap.innerHTML = `
<div class="notes-quick-type-seg is-todo" role="group">
<button type="button" class="notes-quick-type-pill" data-type="note">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg>
<div class="notes-quick-type-seg is-todo" role="group" aria-label="New item type">
<button type="button" class="notes-quick-type-pill" data-type="note" aria-label="Note" aria-pressed="false" title="Note">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="14" y2="18"/></svg>
</button>
<button type="button" class="notes-quick-type-pill active" data-type="todo">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<button type="button" class="notes-quick-type-pill active" data-type="todo" aria-label="To-do" aria-pressed="true" title="To-do">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
</button>
</div>
<input type="text" class="notes-quick-input" placeholder="Add a to-do…" />
@@ -2046,7 +2062,9 @@ function _renderQuickAdd(body) {
seg.classList.toggle('is-todo', t === 'todo');
seg.classList.toggle('is-note', t === 'note');
seg.querySelectorAll('.notes-quick-type-pill').forEach(p => {
p.classList.toggle('active', p.dataset.type === t);
const on = p.dataset.type === t;
p.classList.toggle('active', on);
p.setAttribute('aria-pressed', on ? 'true' : 'false');
});
input.placeholder = t === 'note' ? 'Add a note…' : 'Add a to-do…';
};
@@ -2151,6 +2169,21 @@ function _bindCardEvents(body) {
});
});
}
// Mobile, non-select: tapping anywhere on the card body (not on an
// interactive child — buttons, pin, checkbox, color dot, reminder pill,
// agent tag, links) opens the fullscreen editor. Previously only the
// title / content preview triggered edit, so padding + empty gutters were
// dead zones that felt broken on mobile.
if (_isNotesMobileMode() && !_selectMode) {
const _INTERACTIVE = 'button, a, input, label, .note-card-color-dot, .note-checkbox, .note-checkbox-rm, .note-cl-quickadd, .note-agent-tag, .note-card-pin, .note-card-corner-trash, .note-card-corner-menu, .note-card-corner-unarchive, .note-card-edit-corner, .note-card-reminder, .note-card-cb';
body.querySelectorAll('.note-card').forEach(card => {
card.addEventListener('click', (e) => {
if (e.target.closest(_INTERACTIVE)) return;
e.stopPropagation();
tapToEditOrSelect(card);
});
});
}
// Multi-select checkbox (only in select mode)
body.querySelectorAll('.note-card-cb').forEach(cb => {
cb.addEventListener('click', (e) => e.stopPropagation());
@@ -2770,7 +2803,7 @@ function _buildForm(note = null) {
form.className = 'note-form';
if (color && !_isBgImage(color)) form.classList.add('note-color-' + color);
if (_isBgImage(color)) form.setAttribute('style', _customColorStyle(color));
let currentImageUrl = note?.image_url || '';
let currentImageUrl = _safeImgSrc(note?.image_url || '');
form.innerHTML = `
<div class="note-form-header">
<input type="text" class="note-form-title" placeholder="Title" value="${_esc(note?.title || '')}" />
@@ -2852,7 +2885,7 @@ function _buildForm(note = null) {
let _stashedGoalItems = (type === 'goal' && Array.isArray(note?.items)) ? note.items.slice() : null;
// Drawing also stashes the saved image URL so it survives Note↔Draw flips.
let _stashedDrawUrl = (type === 'draw') ? (note?.image_url || null) : null;
let _stashedDrawUrl = (type === 'draw') ? (_safeImgSrc(note?.image_url) || null) : null;
const _refreshFormLayout = () => {
const body = form.closest('.notes-pane-body');
if (!body) return;
@@ -2904,7 +2937,7 @@ function _buildForm(note = null) {
// toggled to Draw, paint that photo onto the canvas so they can draw
// on top of it. _stashedDrawUrl wins if they were drawing earlier in
// the same edit session.
_wireCanvas(bodyEl, _stashedDrawUrl || currentImageUrl || note?.image_url || null);
_wireCanvas(bodyEl, _stashedDrawUrl || currentImageUrl || _safeImgSrc(note?.image_url) || null);
} else {
const text = (_stashedNoteText !== null && _stashedNoteText !== undefined && _stashedNoteText !== '')
? _stashedNoteText
@@ -2994,7 +3027,7 @@ function _buildForm(note = null) {
if (currentType === 'todo') _wireChecklist(form.querySelector('.note-form-body'));
if (currentType === 'goal') _wireGoalForm(form, form.querySelector('.note-form-body'));
if (currentType === 'draw') {
_wireCanvas(form.querySelector('.note-form-body'), note?.image_url || null);
_wireCanvas(form.querySelector('.note-form-body'), _safeImgSrc(note?.image_url) || null);
// Same hides we apply on type-switch — keep them consistent on initial open.
const _ip = form.querySelector('.note-form-image-wrap'); if (_ip) _ip.style.display = 'none';
const _cp = form.querySelector('.note-color-picker'); if (_cp) _cp.style.display = 'none';
@@ -3462,6 +3495,14 @@ function _buildForm(note = null) {
// let repeated clicks create duplicate notes.
const _saveBtn = form.querySelector('.note-form-save');
if (_saveBtn._saving) return;
// Mobile: when an existing note is opened and closed without edits, the
// Update (✓) button morphs into Archive (set up below). Route the click
// to the hidden archive button so the existing archive flow + undo toast
// run unchanged.
if (_saveBtn.classList.contains('archive-mode')) {
form.querySelector('.note-form-archive-btn')?.click();
return;
}
_saveBtn._saving = true; _saveBtn.disabled = true; _saveBtn.style.opacity = '0.5';
try {
const title = form.querySelector('.note-form-title').value.trim();
@@ -3556,6 +3597,28 @@ function _buildForm(note = null) {
}
});
// Mobile-only: when editing an existing note, the Update (✓) button starts in
// archive-mode (visually + behaviorally) and flips to Update on the first
// edit. Lets the user tap a note to skim, then tap ✓ to archive without ever
// touching a separate Archive button.
if (isEdit && window.innerWidth <= 768) {
const _saveLabelEl = _saveBtnEl0.querySelector('.nft-label');
const _enterArchive = () => {
_saveBtnEl0.classList.add('archive-mode');
if (_saveLabelEl) _saveLabelEl.textContent = 'Archive';
_saveBtnEl0.title = 'Archive';
};
const _enterUpdate = () => {
if (!_saveBtnEl0.classList.contains('archive-mode')) return;
_saveBtnEl0.classList.remove('archive-mode');
if (_saveLabelEl) _saveLabelEl.textContent = 'Update';
_saveBtnEl0.title = 'Update';
};
_enterArchive();
form.addEventListener('input', _enterUpdate, true);
form.addEventListener('change', _enterUpdate, true);
}
// Cancel
form.querySelector('.note-form-cancel').addEventListener('click', () => { _clearDraft(isEdit ? note.id : '__new__'); _editingId = null; _renderNotes(); });
@@ -3855,11 +3918,12 @@ function _wireCanvas(container, initialImageUrl) {
ctx.lineJoin = 'round';
// Load prior drawing as starting point so consecutive edits compose.
if (initialImageUrl) {
const safeInitialImageUrl = _safeImgSrc(initialImageUrl);
if (safeInitialImageUrl) {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => { try { ctx.drawImage(img, 0, 0, cssW, cssH); } catch {} };
img.src = initialImageUrl;
img.src = safeInitialImageUrl;
// Float an X over the canvas so the user can blank it out and go back to
// a clean draw surface. Removes itself once clicked.
const wrap = container.querySelector('.note-form-draw-wrap');
@@ -5004,9 +5068,54 @@ async function _initReminders() {
} catch {}
}
const notesModule = { openPanel, closePanel, togglePanel, isPanelOpen, openNotes: openPanel, closeNotes: closePanel, isNotesOpen: isPanelOpen, refreshDueBadge };
// Open the notes panel and scroll/flash the matching note card. Used
// by chatRenderer.js when the user clicks a [View note](#note-<id>)
// link the agent emits after a manage_notes create. Falls back to
// just opening the panel when the card isn't found (panel still
// loading, note in a different filter, etc.).
async function openNote(noteId) {
// If the panel is already open, openPanel() short-circuits and does
// nothing — including no re-fetch — so a freshly-created note added
// server-side never shows up. Force a refresh by closing first when
// open, then re-opening. Clicking the sidebar Notes button as a
// last resort keeps this working even if the module state got out
// of sync (rare but seen during HMR or after a stuck modal).
try {
if (isPanelOpen && isPanelOpen()) {
closePanel();
// give the close animation a frame to settle
await new Promise(r => setTimeout(r, 30));
}
} catch (_) {}
openPanel();
// openPanel() kicks off _fetchNotes() asynchronously, so the cards
// for newly-created notes may not be in the DOM yet. Also poll the
// _notes module array directly — if the note IS loaded but the
// active filter (e.g. archive view) is hiding it, we can still
// surface a confirmation toast.
if (!noteId) return;
let tries = 0;
const findAndFlash = () => {
const card = document.querySelector(`.note-card[data-note-id="${noteId}"]`)
|| document.querySelector(`.note-card[data-note-id^="${noteId.slice(0, 8)}"]`);
if (card) {
try { card.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (_) {}
card.classList.add('note-card-flash');
setTimeout(() => card.classList.remove('note-card-flash'), 1600);
return true;
}
return false;
};
const tryNext = () => {
if (findAndFlash()) return;
if (++tries < 20) setTimeout(tryNext, 200);
};
setTimeout(tryNext, 120);
}
const notesModule = { openPanel, closePanel, togglePanel, isPanelOpen, openNote, openNotes: openPanel, closeNotes: closePanel, isNotesOpen: isPanelOpen, refreshDueBadge };
export default notesModule;
export { openPanel as openNotes, closePanel as closeNotes, isPanelOpen as isNotesOpen };
export { openPanel as openNotes, closePanel as closeNotes, isPanelOpen as isNotesOpen, openNote };
window.notesModule = notesModule;
// Start reminder loop on module load (after a short delay so app loads first)
+1
View File
@@ -0,0 +1 @@
{ "type": "module" }
+79
View File
@@ -0,0 +1,79 @@
// static/js/planWindow.js
//
// Plan mode: show a proposed plan in a draggable, side-dockable window —
// reusing the same modal + makeWindowDraggable framework the calendar, email,
// and document panels use. Approving from here runs the plan with full tools.
import uiModule from './ui.js';
import markdownModule from './markdown.js';
import { makeWindowDraggable } from './windowDrag.js';
let _modal = null;
let _onApprove = null;
function _getModal() {
if (_modal) return _modal;
_modal = document.createElement('div');
_modal.id = 'plan-window';
_modal.className = 'modal';
_modal.style.display = 'none';
_modal.innerHTML = `
<div class="modal-content plan-window-content">
<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="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg><span id="plan-window-title">Proposed plan</span></h4>
<button class="close-btn" id="plan-window-close"></button>
</div>
<div class="modal-body plan-window-body" id="plan-window-body"></div>
<div class="modal-footer plan-window-footer">
<button type="button" class="plan-approve-btn" id="plan-window-approve">Approve &amp; Run</button>
</div>
</div>`;
document.body.appendChild(_modal);
_modal.querySelector('#plan-window-close').addEventListener('click', closePlanWindow);
_modal.querySelector('#plan-window-approve').addEventListener('click', () => {
const cb = _onApprove;
closePlanWindow();
if (typeof cb === 'function') cb();
});
// Draggable + side-dockable, same one-call helper as the other windows.
const content = _modal.querySelector('.modal-content');
const header = _modal.querySelector('.modal-header');
if (content && header) makeWindowDraggable(_modal, { content, header });
return _modal;
}
/**
* Open the plan window with rendered markdown and an approve callback.
* @param {string} planMarkdown - the agent's proposed plan (raw markdown)
* @param {Function} onApprove - called when the user clicks Approve & Run
*/
export function openPlanWindow(planMarkdown, onApprove) {
const modal = _getModal();
_onApprove = onApprove || null;
const body = modal.querySelector('#plan-window-body');
if (body) {
body.innerHTML = markdownModule.processWithThinking(
markdownModule.squashOutsideCode(planMarkdown || '')
);
if (window.hljs) body.querySelectorAll('pre code').forEach((b) => window.hljs.highlightElement(b));
}
const approveBtn = modal.querySelector('#plan-window-approve');
if (approveBtn) approveBtn.style.display = onApprove ? '' : 'none';
// Title reflects state: still awaiting approval (approve callback present) vs
// already approved and being executed.
const title = modal.querySelector('#plan-window-title');
if (title) title.textContent = onApprove ? 'Proposed plan' : 'Approved plan';
modal.style.display = 'flex';
if (uiModule && uiModule.scrollHistory) { try { uiModule.scrollHistory(); } catch (_) {} }
}
export function closePlanWindow() {
if (_modal) _modal.style.display = 'none';
}
/** True when the plan window is currently visible (for live-refresh on progress). */
export function isPlanWindowOpen() {
return !!(_modal && _modal.style.display !== 'none');
}
export default { openPlanWindow, closePlanWindow, isPlanWindowOpen };
+47
View File
@@ -0,0 +1,47 @@
// ============================================
// Platform detection + AltGr-keystroke helper
// ============================================
// Shared by the keybind code: root keyboard-shortcuts.js, the editor's
// keyboard-shortcuts.js, and settings.js. Single source of truth so the three
// guards can't drift.
// AltGr (right Alt on AZERTY/QWERTZ and most non-US layouts, used to type
// @ # { } [ ] | \ and €) is reported by browsers as Ctrl+Alt. macOS is the
// exception: there the Option key — a normal part of Mac shortcuts — also sets
// the AltGraph modifier state, so it must NOT be treated as AltGr.
//
// IS_MAC covers all Apple platforms, iPad/iPhone included: a Magic Keyboard's
// Option key sets AltGraph exactly like a Mac's, so they need the same carve-out
// — narrowing to macOS-only would re-break them. The name and the
// /Mac|iPhone|iPad/ test deliberately mirror the existing isMac checks in
// calendar.js and sessions.js; this is their single shared source of truth.
export const IS_MAC =
/Mac|iPhone|iPad/.test((typeof navigator !== 'undefined' && navigator.platform) || '') ||
/Mac/.test((typeof navigator !== 'undefined' && navigator.userAgent) || '');
// True when `e` is an AltGr keystroke we should ignore for Ctrl+Alt shortcut
// purposes. getModifierState('AltGraph') is true for AltGr but false for a
// genuine left Ctrl+Alt, so real shortcuts still work. Always false on macOS,
// where Option legitimately sets AltGraph.
//
// We also require ctrlKey+altKey: the collision we defend against is precisely
// "AltGr reported AS Ctrl+Alt", so an event that asserts AltGraph WITHOUT
// presenting as Ctrl+Alt (a Linux ISO_Level3_Shift layout, a stray modifier
// state) is left alone instead of being swallowed.
//
// Trade-off: on Windows AltGr *is* Ctrl+right-Alt, so a deliberate
// Ctrl+Alt+<char> shortcut typed via AltGr is unreachable too — accepted; use
// the left Ctrl+Alt.
//
// NOTE: the AltGr -> AltGraph mapping is taken from the UI Events spec / MDN,
// not proven by our tests. Older Firefox and some Linux setups historically did
// not report AltGraph; where a browser sets ctrlKey+altKey without it this
// guard is simply a no-op (the pre-fix behaviour) rather than a regression.
export function isAltGrEvent(e, isMac = IS_MAC) {
return (
!isMac &&
!!e.ctrlKey &&
!!e.altKey &&
!!(e.getModifierState && e.getModifierState('AltGraph'))
);
}
+30 -12
View File
@@ -8,6 +8,24 @@ let API_BASE = '';
let selectedPreset = null;
let presets = {};
export function loadStoredArray(key) {
try {
const value = JSON.parse(localStorage.getItem(key) || '[]');
return Array.isArray(value) ? value : [];
} catch (e) {
return [];
}
}
export function loadStoredObject(key) {
try {
const value = JSON.parse(localStorage.getItem(key) || '{}');
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
} catch (e) {
return {};
}
}
// Built-in prompt templates (moved from cot_prompts.py)
export const PROMPT_TEMPLATES = [
{
@@ -220,7 +238,7 @@ function initNameDropdown() {
if (!charName || charName === '__default__') return;
const match = userTemplates.find(t => t.name === charName);
const isBuiltin = PROMPT_TEMPLATES.some(t => t.name === charName);
if (!await window.styledConfirm(`Delete "${charName}"?\n\nThis will remove the character and all its memories.`, { confirmText: 'Delete', danger: true })) return;
if (!await window.styledConfirm(`Delete "${charName}"?\n\nThis will remove the persona and all its memories.`, { confirmText: 'Delete', danger: true })) return;
try {
// Delete saved template if exists
if (match) {
@@ -228,7 +246,7 @@ function initNameDropdown() {
}
// Hide built-in preset
if (isBuiltin) {
const hidden = JSON.parse(localStorage.getItem('odysseus-hidden-presets') || '[]');
const hidden = loadStoredArray('odysseus-hidden-presets');
if (!hidden.includes(charName)) hidden.push(charName);
localStorage.setItem('odysseus-hidden-presets', JSON.stringify(hidden));
}
@@ -296,7 +314,7 @@ function _populateCharSelect() {
const select = document.getElementById('char-template-select');
if (!select) return;
const currentVal = select.value;
select.innerHTML = '<option value="__default__">Default (no character)</option>';
select.innerHTML = '<option value="__default__">Default (no persona)</option>';
const savedNames = new Set(userTemplates.map(t => t.name));
if (userTemplates.length) {
@@ -311,7 +329,7 @@ function _populateCharSelect() {
select.appendChild(group);
}
const hiddenPresets = JSON.parse(localStorage.getItem('odysseus-hidden-presets') || '[]');
const hiddenPresets = loadStoredArray('odysseus-hidden-presets');
const builtins = PROMPT_TEMPLATES.filter(t => !savedNames.has(t.name) && !hiddenPresets.includes(t.name));
if (builtins.length) {
const group = document.createElement('optgroup');
@@ -405,7 +423,7 @@ function initPersistentChat() {
await fetch(`${API_BASE}/api/session/${sessionId}/important`, { method: 'POST', body: favFd });
// Save session → character mapping so it restores on switch
const charSessions = JSON.parse(localStorage.getItem('odysseus-char-sessions') || '{}');
const charSessions = loadStoredObject('odysseus-char-sessions');
charSessions[sessionId] = charName;
localStorage.setItem('odysseus-char-sessions', JSON.stringify(charSessions));
@@ -437,7 +455,7 @@ function initSaveAsTemplate() {
let name = nameInput ? nameInput.value.trim() : '';
if (!name) {
name = prompt('Enter a name for this character:');
name = prompt('Enter a name for this persona:');
if (!name || !name.trim()) return;
name = name.trim();
if (nameInput) nameInput.value = name;
@@ -616,7 +634,7 @@ export function openCustomPresetModal() {
} else {
// Character/persona tab. "Save & " prefix when the user edited a template,
// so it's clear the edit is being saved on start.
label = changed ? 'Save & Start Character' : 'Start Character';
label = changed ? 'Save & Start Persona' : 'Start Persona';
}
btn.textContent = label;
// Show a "Cancel" button next to Start when the active tab's feature is
@@ -708,7 +726,7 @@ export function openCustomPresetModal() {
const notice = document.createElement('div');
notice.id = 'char-lock-notice';
notice.style.cssText = 'font-size:11px;color:var(--color-muted);text-align:center;padding:6px;margin-bottom:8px;border:1px dashed var(--border);border-radius:6px;';
notice.textContent = 'Persistent chat — character is locked. Style, temperature, and memory can still be changed.';
notice.textContent = 'Persistent chat — persona is locked. Style, temperature, and memory can still be changed.';
modal.querySelector('.modal-body').prepend(notice);
}
} else {
@@ -825,7 +843,7 @@ export async function saveCustomPreset(showToast, showError) {
if (showToast) {
// The Inject tab is a plain tuned "prompt" chat, not a persona — say so.
showToast(_isInjectStart ? 'Prompt saved' : 'Character saved');
showToast(_isInjectStart ? 'Prompt saved' : 'Persona saved');
}
const modal = document.getElementById('custom-preset-modal');
if (modal) {
@@ -962,7 +980,7 @@ function _syncCharIndicator() {
if (hasChar) {
if (iconEl) iconEl.innerHTML = _AVATAR;
if (nameSpan) nameSpan.textContent = custom.character_name;
btn.title = `Character: ${custom.character_name} — click to configure`;
btn.title = `Persona: ${custom.character_name} — click to configure`;
} else {
// Inject/tuning chat — syringe tag labeled "Prompt" to match the
// window identity, no persona name.
@@ -1011,7 +1029,7 @@ function _syncCharIndicator() {
let _prevSessionId = null;
export function onSessionSwitch(sessionId) {
const charSessions = JSON.parse(localStorage.getItem('odysseus-char-sessions') || '{}');
const charSessions = loadStoredObject('odysseus-char-sessions');
// Leaving a persistent chat — deactivate for this switch only
if (window._persistentChatSession) {
@@ -1059,7 +1077,7 @@ export function isPersistentChat() {
* Remove a session from persistent chat mappings (call when session is deleted).
*/
export function removePersistentChat(sessionId) {
const charSessions = JSON.parse(localStorage.getItem('odysseus-char-sessions') || '{}');
const charSessions = loadStoredObject('odysseus-char-sessions');
if (charSessions[sessionId]) {
delete charSessions[sessionId];
localStorage.setItem('odysseus-char-sessions', JSON.stringify(charSessions));
+128
View File
@@ -0,0 +1,128 @@
// Shared DOM-free provider device-flow runner.
export const PROVIDER_DEVICE_FLOWS = {
copilot: {
label: 'GitHub Copilot',
startUrl: '/api/copilot/device/start',
pollUrl: '/api/copilot/device/poll',
authUrl(start) {
return start?.verification_uri_complete || start?.verification_uri || '';
},
},
'chatgpt-subscription': {
label: 'ChatGPT Subscription',
startUrl: '/api/chatgpt-subscription/device/start',
pollUrl: '/api/chatgpt-subscription/device/poll',
authUrl(start) {
return start?.verification_uri || '';
},
},
};
function _formData() {
if (typeof FormData !== 'undefined') return new FormData();
return new URLSearchParams();
}
async function _jsonOrEmpty(response) {
try {
return await response.json();
} catch (_) {
return {};
}
}
function _messageFromPayload(payload, fallback) {
if (payload && typeof payload.detail === 'string' && payload.detail.trim()) {
return payload.detail.trim();
}
if (payload && typeof payload.error === 'string' && payload.error.trim()) {
return payload.error.trim();
}
if (payload && typeof payload.message === 'string' && payload.message.trim()) {
return payload.message.trim();
}
return fallback;
}
export function formatDeviceFlowError(error, fallback = 'Request failed') {
if (!error) return fallback;
if (typeof error === 'string') return error;
if (error.detail) return String(error.detail);
if (error.message) return String(error.message);
return fallback;
}
async function _fetchJson(fetchImpl, url, options, fallback) {
const response = await fetchImpl(url, options);
const payload = await _jsonOrEmpty(response);
if (!response.ok) {
throw new Error(_messageFromPayload(payload, fallback || `Request failed (HTTP ${response.status})`));
}
return payload;
}
function _defaultSleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function _callCallback(fn, payload) {
if (typeof fn === 'function') await fn(payload);
}
export async function runProviderDeviceFlow(provider, options = {}) {
const cfg = PROVIDER_DEVICE_FLOWS[provider];
if (!cfg) throw new Error(`Unknown device-flow provider: ${provider}`);
const fetchImpl = options.fetchImpl || globalThis.fetch?.bind(globalThis);
if (!fetchImpl) throw new Error('Fetch API is unavailable');
const openWindow = options.openWindow || ((url) => {
if (globalThis.window && typeof globalThis.window.open === 'function') {
globalThis.window.open(url, '_blank', 'noopener');
}
});
const sleep = options.sleep || _defaultSleep;
const now = options.now || (() => Date.now());
const formData = options.formData || _formData();
const start = await _fetchJson(fetchImpl, cfg.startUrl, {
method: 'POST',
body: formData,
credentials: 'same-origin',
}, `Failed to start ${cfg.label} sign-in`);
if (!start.poll_id) throw new Error(`${cfg.label} sign-in did not return a poll id`);
const authUrl = cfg.authUrl(start);
await _callCallback(options.onStart, { provider, config: cfg, start, authUrl });
if (authUrl) openWindow(authUrl);
const deadline = now() + Number(start.expires_in || 900) * 1000;
let stepMs = Math.max(Number(start.interval || 5), 2) * 1000;
while (true) {
if (now() > deadline) return { status: 'expired' };
await _callCallback(options.onWaiting, { provider, config: cfg, start, authUrl });
await sleep(stepMs);
if (now() > deadline) return { status: 'expired' };
const fd = _formData();
fd.append('poll_id', start.poll_id);
const poll = await _fetchJson(fetchImpl, cfg.pollUrl, {
method: 'POST',
body: fd,
credentials: 'same-origin',
}, `${cfg.label} sign-in poll failed`);
await _callCallback(options.onPoll, { provider, config: cfg, start, poll });
if (poll.status === 'authorized') {
return { status: 'authorized', endpoint: poll.endpoint || {} };
}
if (poll.status === 'failed') {
return { status: 'failed', error: poll.error || 'denied' };
}
if (poll.interval) {
stepMs = Math.max(Number(poll.interval || 5), 2) * 1000;
}
}
}
+59 -3
View File
@@ -11,6 +11,14 @@ const _PROVIDERS = [
[/openai|gpt-|^o[13]-|chatgpt|dall-e/i,
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 10.696.453a6.023 6.023 0 0 0-5.75 4.172 6.061 6.061 0 0 0-3.946 2.945 6.024 6.024 0 0 0 .742 7.099 5.98 5.98 0 0 0 .516 4.911 6.046 6.046 0 0 0 6.51 2.9A5.996 5.996 0 0 0 13.26 23.547a6.023 6.023 0 0 0 5.75-4.172 6.061 6.061 0 0 0 3.946-2.945 6.024 6.024 0 0 0-.674-6.609zM13.26 21.047a4.508 4.508 0 0 1-2.886-1.041l.143-.082 4.793-2.769a.777.777 0 0 0 .391-.676V10.34l2.026 1.17a.072.072 0 0 1 .039.061v5.596a4.532 4.532 0 0 1-4.506 4.48zM3.968 17.64a4.473 4.473 0 0 1-.537-3.018l.143.086 4.793 2.769a.79.79 0 0 0 .782 0l5.852-3.379v2.34a.072.072 0 0 1-.029.062l-4.845 2.796a4.532 4.532 0 0 1-6.159-1.656zM2.804 7.922a4.49 4.49 0 0 1 2.348-1.973V11.6a.778.778 0 0 0 .391.676l5.852 3.378-2.026 1.17a.072.072 0 0 1-.068 0L4.456 14.03a4.532 4.532 0 0 1-1.652-6.108zm16.423 3.823L13.375 8.367l2.026-1.17a.072.072 0 0 1 .068 0l4.845 2.796a4.525 4.525 0 0 1-.7 8.08V12.42a.778.778 0 0 0-.387-.676zm2.015-3.025l-.143-.086-4.793-2.769a.79.79 0 0 0-.782 0L9.672 9.243V6.903a.072.072 0 0 1 .029-.062l4.845-2.796a4.525 4.525 0 0 1 6.696 4.675zM8.598 12.66L6.57 11.49a.072.072 0 0 1-.039-.061V5.833a4.525 4.525 0 0 1 7.413-3.48l-.143.082-4.793 2.769a.777.777 0 0 0-.391.676l-.019 6.78zm1.1-2.379l2.607-1.505 2.607 1.505v3.01l-2.607 1.505-2.607-1.505z"/></svg>'],
// OpenCode (Zen / Go) — official brand mark
[/opencode/i,
'<svg viewBox="0 0 24 30" fill="currentColor"><path d="M18 6H6V24H18V6ZM24 30H0V0H24V30Z"/></svg>'],
// GitHub / Copilot
[/github|copilot/i,
'<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5A12 12 0 0 0 8.2 23.9c.6.1.8-.3.8-.6v-2.1c-3.3.7-4-1.4-4-1.4-.5-1.4-1.3-1.8-1.3-1.8-1.1-.8.1-.8.1-.8 1.2.1 1.9 1.3 1.9 1.3 1.1 1.9 2.9 1.3 3.6 1 .1-.8.4-1.3.8-1.6-2.7-.3-5.5-1.3-5.5-5.9 0-1.3.5-2.4 1.3-3.2-.1-.3-.5-1.6.1-3.2 0 0 1-.3 3.3 1.2a11.4 11.4 0 0 1 6 0C15.3 4.7 16 5 16 5c.6 1.6.2 2.9.1 3.2.8.8 1.3 1.9 1.3 3.2 0 4.6-2.8 5.6-5.5 5.9.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6A12 12 0 0 0 12 .5Z"/></svg>'],
// OpenRouter
[/openrouter|open router/i,
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="12" r="2.5"/><circle cx="19" cy="6" r="2.5"/><circle cx="19" cy="18" r="2.5"/><path d="M7.5 12h4.5c2 0 2.5-6 4.5-6"/><path d="M12 12c2 0 2.5 6 4.5 6"/></svg>'],
@@ -32,8 +40,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
@@ -90,4 +98,52 @@ export function providerLogo(modelId) {
return null;
}
export default { providerLogo };
// Host suffix → friendly provider label. The model-info card shows this so the
// SAME model name served by DIFFERENT routes is distinguishable (e.g.
// `claude-haiku` via OpenRouter vs GitHub Copilot vs Anthropic direct); the logo
// only reflects the model vendor, not the actual endpoint. Patterns are anchored
// to the end of the hostname (^|.)domain$ so a host like `max.airlines.com`
// doesn't match `x.ai`.
const _ENDPOINT_LABELS = [
[/(^|\.)githubcopilot\.com$/i, "GitHub Copilot"],
[/(^|\.)chatgpt\.com$/i, "ChatGPT Subscription"],
[/(^|\.)openrouter\.ai$/i, "OpenRouter"],
[/(^|\.)anthropic\.com$/i, "Anthropic"],
[/(^|\.)openai\.com$/i, "OpenAI"],
[/(^|\.)(generativelanguage|aiplatform)\.googleapis\.com$/i, "Google"],
[/(^|\.)bedrock[\w.-]*\.amazonaws\.com$/i, "AWS Bedrock"],
[/(^|\.)deepseek\.com$/i, "DeepSeek"],
[/(^|\.)mistral\.ai$/i, "Mistral"],
[/(^|\.)groq\.com$/i, "Groq"],
[/(^|\.)together\.(ai|xyz)$/i, "Together"],
[/(^|\.)fireworks\.ai$/i, "Fireworks"],
[/(^|\.)perplexity\.ai$/i, "Perplexity"],
[/(^|\.)x\.ai$/i, "xAI"],
];
/**
* Friendly label for the endpoint that served a model, from its URL.
* Returns "Local" for loopback/LAN hosts, a known provider name when matched,
* else the bare host. Null when no URL is available.
*/
export function providerLabel(endpointUrl) {
if (!endpointUrl || typeof endpointUrl !== "string") return null;
let host;
try {
host = new URL(endpointUrl).hostname;
} catch (_) {
// Not a full URL (e.g. bare host[:port]) — strip scheme/path/port best-effort.
host = endpointUrl.replace(/^[a-z]+:\/\//i, "").split("/")[0].split(":")[0];
}
if (!host) return null;
if (/^(localhost|127\.|0\.0\.0\.0|::1|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(host)) {
return "Local";
}
for (const [re, label] of _ENDPOINT_LABELS) {
if (re.test(host)) return label;
}
// Unknown host → drop a leading "api." for a cleaner readout.
return host.replace(/^api\./i, "");
}
export default { providerLogo, providerLabel };
+12 -2
View File
@@ -1103,8 +1103,10 @@ function _renderResult(job) {
html += '<div class="research-job-sources">';
for (const s of job.sources.slice(0, 10)) {
const title = _esc(s.title || s.url || '');
const url = _esc(s.url || '');
html += `<a href="${url}" target="_blank" rel="noopener" class="research-source-link">${title}</a>`;
const url = _safeSourceHref(s.url);
html += url
? `<a href="${url}" target="_blank" rel="noopener" class="research-source-link">${title}</a>`
: `<span class="research-source-link">${title}</span>`;
}
if (job.sources.length > 10) html += `<span class="research-source-more">+${job.sources.length - 10} more</span>`;
html += '</div>';
@@ -1231,3 +1233,11 @@ function _esc(s) {
d.textContent = s || '';
return d.innerHTML;
}
function _safeSourceHref(raw) {
try {
const parsed = new URL(String(raw || '').trim(), window.location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') return _esc(parsed.href);
} catch {}
return '';
}
+34 -12
View File
@@ -33,31 +33,53 @@ export function initSectionCollapse(Storage) {
Storage.setJSON('section-collapsed', state);
// Always clear any in-flight animation classes from a previous toggle
// so back-to-back clicks restart cleanly.
// so back-to-back clicks restart cleanly. Bump a generation token so
// any callback still pending from a superseded toggle becomes a no-op.
section.classList.remove('section-just-expanded', 'section-just-collapsing');
const gen = (section._collapseGen = (section._collapseGen || 0) + 1);
if (willCollapse) {
// Domino-out: play the fade/slide-down on .list-item children
// BEFORE actually adding .collapsed (which hides them via
// display:none). After the cascade finishes, lock in collapse.
// Force reflow so the keyframes restart.
// Domino-out: play the fade/slide-down on the row children BEFORE
// actually adding .collapsed (which hides them via display:none),
// then lock in collapse once the cascade finishes.
//
// We wait on the REAL animations (getAnimations) rather than a fixed
// timeout. Different sections animate different rows — .list-item in
// most, .models-row in #models-section — so any hard-coded duration
// either stalls with a dead pause (when the selector matches nothing,
// as it did for #models-section) or guesses the wrong length. Force a
// reflow first so the keyframes restart from the top.
// eslint-disable-next-line no-unused-expressions
section.offsetHeight;
section.classList.add('section-just-collapsing');
const itemCount = Math.min(12, section.querySelectorAll('.list-item').length);
const total = itemCount * 25 + 230; // matches CSS keyframes + stagger
setTimeout(() => {
const lockCollapsed = () => {
if (section._collapseGen !== gen) return; // superseded by a newer toggle
section.classList.remove('section-just-collapsing');
section.classList.add('collapsed');
}, total);
};
// Only the domino-out keyframes gate the collapse — ignore unrelated
// (and possibly infinite, e.g. spinners) animations in the subtree.
const dominoOut = section.getAnimations({ subtree: true })
.filter(a => a.animationName === 'section-domino-out');
if (dominoOut.length === 0) {
lockCollapsed(); // nothing to animate — collapse now, no dead pause
} else {
Promise.allSettled(dominoOut.map(a => a.finished)).then(lockCollapsed);
// Safety net: if an animation never settles (e.g. element removed),
// still lock in the collapse so the section can't get stuck open.
setTimeout(lockCollapsed, 600);
}
} else {
// Expand path — already had this: remove .collapsed and replay
// the inbound domino.
// Expand path — remove .collapsed and replay the inbound domino.
section.classList.remove('collapsed');
// eslint-disable-next-line no-unused-expressions
section.offsetHeight;
section.classList.add('section-just-expanded');
setTimeout(() => section.classList.remove('section-just-expanded'), 700);
setTimeout(() => {
if (section._collapseGen !== gen) return; // superseded by a newer toggle
section.classList.remove('section-just-expanded');
}, 700);
}
}
+86 -12
View File
@@ -78,6 +78,42 @@ function _deselectCurrentSession(sid) {
if (window._updateSendBtnIcon) window._updateSendBtnIcon();
}
function _removeSessionFromLocalState(sid) {
if (!sid) return;
const id = String(sid);
sessions = sessions.filter(s => String(s.id) !== id);
_selectedIds.delete(id);
try {
const savedOrder = Storage.get('session-order');
if (savedOrder) {
const orderIds = JSON.parse(savedOrder);
if (Array.isArray(orderIds) && orderIds.some(x => String(x) === id)) {
Storage.set('session-order', JSON.stringify(orderIds.filter(x => String(x) !== id)));
}
}
} catch (e) {
console.warn('Failed to prune deleted session order:', e);
}
document.querySelectorAll('.list-item[data-session-id]').forEach(el => {
if (String(el.dataset.sessionId) === id) el.remove();
});
_deselectCurrentSession(id);
}
function _normalizeSessionsList(fetched) {
if (!Array.isArray(fetched)) return [];
const seen = new Set();
const unique = [];
for (const session of fetched) {
if (!session || session.id == null) continue;
const id = String(session.id);
if (seen.has(id)) continue;
seen.add(id);
unique.push(session);
}
return unique;
}
// Initialize dependencies from app.js (no-op: dependencies now imported directly)
export function initDependencies() {}
@@ -616,15 +652,17 @@ function createSessionItem(s) {
return;
}
dropdown.style.display = 'none';
// Optimistic: remove from UI immediately
const sessionEl = document.querySelector(`.list-item[data-session-id="${s.id}"]`);
if (sessionEl) sessionEl.remove();
if (!await uiModule.styledConfirm('Delete this session?', { confirmText: 'Delete', danger: true })) {
_forceSidebarOpen();
return;
}
const wasCurrentSession = currentSessionId === s.id;
// If streaming, abort it before deleting
if (wasCurrentSession && window.chatModule && window.chatModule.abortCurrentRequest) {
window.chatModule.abortCurrentRequest();
}
_deselectCurrentSession(s.id);
_removeSessionFromLocalState(s.id);
_skipAutoSelect = true;
// Clean up persistent chat mapping
try {
@@ -640,10 +678,11 @@ function createSessionItem(s) {
} else {
_forceSidebarOpen();
}
// Fire API and reload in background
fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' })
.then(() => loadSessions())
.catch(() => loadSessions());
// Await API deletion, then reload the authoritative list from the server
try {
await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' });
} catch (e) { /* network error — session may still exist server-side */ }
await loadSessions();
});
archiveItem.addEventListener('click', async () => {
@@ -1317,7 +1356,7 @@ export async function loadSessions() {
const res = await fetch(`${API_BASE}/api/sessions`);
fetched = await res.json();
}
sessions = fetched;
sessions = _normalizeSessionsList(fetched);
renderSessionList();
const sessionsSection = uiModule.el('sessions-section');
@@ -1606,7 +1645,15 @@ export async function selectSession(id, { keepSidebar = false } = {}) {
} else if (msgHistory.length) {
for (const msg of msgHistory) {
const meta = msg.metadata ? { ...msg.metadata, _fromHistory: true } : null;
let displayContent = typeof msg.content === 'string' ? msg.content : (msg.content ? String(msg.content) : '');
let displayContent;
if (typeof msg.content === 'string') {
displayContent = msg.content;
} else if (Array.isArray(msg.content)) {
// Multimodal (image/audio attachments): extract text parts, skip binary
displayContent = msg.content.filter(p => p.type === 'text').map(p => p.text).join('\n').trim();
} else {
displayContent = '';
}
// Clean up doc selection context for display
if (msg.role === 'user') {
// Hide "Continue where you left off" bubbles
@@ -1871,7 +1918,7 @@ export function setCurrentSessionId(id) {
}
// Session list keyboard navigation: arrows to move, Delete to delete
function _onSessionListKeydown(e) {
async function _onSessionListKeydown(e) {
const item = e.target.closest('.list-item[data-session-id]');
if (!item) return;
@@ -1899,6 +1946,8 @@ function _onSessionListKeydown(e) {
uiModule.showToast('Unfavorite before deleting');
return;
}
const ok = await uiModule.styledConfirm('Delete this session?', { confirmText: 'Delete', danger: true });
if (!ok) return;
_sessionListFocused = true;
(async () => {
await fetch(`${API_BASE}/api/session/${s.id}`, { method: 'DELETE' });
@@ -1950,9 +1999,13 @@ export function initDragSort() {
});
}
// Hash-based routing: navigate between sessions with browser back/forward
// Hash-based routing: navigate between sessions with browser back/forward.
// Skip entity-prefixed hashes (document-, note-, etc.) — those are handled
// by their own click handlers in chatRenderer.js and must not trigger
// session navigation (which would reset the active chat).
window.addEventListener('hashchange', () => {
const hashId = window.location.hash.replace('#', '');
if (/^(document|note|image|email|event|task|skill|research)-/.test(hashId)) return;
if (hashId && hashId !== currentSessionId) {
const target = sessions.find(s => s.id === hashId && !s.archived);
if (target) selectSession(hashId);
@@ -2108,7 +2161,14 @@ async function _checkServerStream(sessionId) {
// Skip if this is a research stream — research has its own progress UI
if (info.mode === 'research' || info.is_research) return;
// Server is still streaming — show spinner and poll
// Live-resume the detached run: replay its buffer then stream live tokens
// (#2539). Falls back to the spinner+poll path below if unavailable.
if (window.chatModule && window.chatModule.resumeStream) {
const attached = await window.chatModule.resumeStream(sessionId);
if (attached) return;
}
// Fallback: server is still streaming, show spinner and poll.
const box = document.getElementById('chat-history');
if (!box) return;
@@ -2124,6 +2184,10 @@ async function _checkServerStream(sessionId) {
box.appendChild(holder);
uiModule.scrollHistory();
// sessions.js executes before chat.js in module order, so window.chatModule
// may not be set yet when _checkServerStream first runs. Retry resumeStream
// on the first poll tick where it becomes available.
let _resumeRetried = false;
const pollId = setInterval(async () => {
if (getCurrentSessionId() !== sessionId) {
clearInterval(pollId);
@@ -2131,6 +2195,16 @@ async function _checkServerStream(sessionId) {
if (holder.parentNode) holder.remove();
return;
}
if (!_resumeRetried && window.chatModule && window.chatModule.resumeStream) {
_resumeRetried = true;
const attached = await window.chatModule.resumeStream(sessionId);
if (attached) {
clearInterval(pollId);
spinner.destroy();
if (holder.parentNode) holder.remove();
return;
}
}
try {
const r = await fetch(`${API_BASE}/api/chat/stream_status/${sessionId}`);
if (!r.ok || (await r.json()).status !== 'streaming') {
+1037 -86
View File
File diff suppressed because it is too large Load Diff
+27 -7
View File
@@ -14,6 +14,20 @@
const API_BASE = window.location.origin;
function _esc(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function _safeSignatureDataUrl(raw) {
const value = String(raw || '').trim();
return /^data:image\/png;base64,[a-z0-9+/=\s]+$/i.test(value) ? value : '';
}
// Last signature the user picked or created in this session. Lets the export
// modal pre-fill subsequent signature fields with the same one — sign once,
// applies everywhere.
@@ -446,13 +460,17 @@ export function capture(opts = {}) {
export function pick(opts = {}) {
return new Promise(async (resolve) => {
const sigs = await _listSignatures();
const tiles = sigs.map((s) => `
<div class="sig-tile" data-id="${s.id}">
<img src="${s.data_url}"/>
<div style="margin-top:4px;font-size:0.72rem;color:var(--fg);opacity:0.85;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${(s.name || '').replace(/[<>&]/g, '')}</div>
<button class="sig-tile-del" data-id="${s.id}" title="Delete">×</button>
const tiles = sigs.map((s) => {
const dataUrl = _safeSignatureDataUrl(s.data_url);
if (!dataUrl) return '';
return `
<div class="sig-tile" data-id="${_esc(s.id)}">
<img src="${_esc(dataUrl)}"/>
<div style="margin-top:4px;font-size:0.72rem;color:var(--fg);opacity:0.85;text-align:center;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${_esc(s.name || '')}</div>
<button class="sig-tile-del" data-id="${_esc(s.id)}" title="Delete">×</button>
</div>
`).join('');
`;
}).join('');
const overlay = _modal(`
<div class="modal-content" style="width:min(560px,94vw);">
@@ -477,7 +495,9 @@ export function pick(opts = {}) {
const id = tile.dataset.id;
const s = sigs.find((x) => x.id === id);
if (s) {
const out = { id: s.id, dataUrl: s.data_url, width: s.width, height: s.height, name: s.name };
const dataUrl = _safeSignatureDataUrl(s.data_url);
if (!dataUrl) return;
const out = { id: s.id, dataUrl, width: s.width, height: s.height, name: s.name };
setLastUsed(out);
close(out);
}
+40 -2
View File
@@ -621,10 +621,16 @@ function renderSkillsList() {
const showBuiltin = false;
if (!sorted.length && !showBuiltin) {
const selectBtn = document.getElementById('skills-select-btn');
if (selectBtn) selectBtn.disabled = true;
if (_selectMode) _exitSelectMode();
container.innerHTML = `<div style="text-align:center;opacity:0.4;padding:24px 0;font-size:11px;">${loaded ? 'No skills yet, use agent for it to auto extract them.' : 'Loading…'}</div>`;
return;
}
const selectBtn = document.getElementById('skills-select-btn');
if (selectBtn) selectBtn.disabled = false;
// Library-style cards: a compact bar that expands in-place to show the
// SKILL.md, with a footer (Delete left; Edit / Run / Approve right).
// Reuses the proven .doclib-card / .doclib-card-preview /
@@ -1067,9 +1073,8 @@ async function _deleteSkill(name, card = null) {
card.classList.add('doclib-card-deleting');
card.addEventListener('transitionend', () => card.remove(), { once: true });
setTimeout(() => { if (card.parentElement) card.remove(); }, 400);
} else {
await loadSkills();
}
await loadSkills();
uiModule.showToast('Skill deleted');
} catch (e) { uiModule.showError('Delete failed: ' + e.message); }
}
@@ -1818,6 +1823,35 @@ async function _showSkillSource(name) {
});
}
async function importSkillFromUrl() {
const input = document.getElementById('skill-import-url');
const url = (input?.value || '').trim();
if (!url) {
uiModule.showError('Paste a GitHub or skills.sh URL first');
return;
}
const btn = document.getElementById('skill-import-url-btn');
if (btn) btn.disabled = true;
try {
const res = await fetch(`${API}/api/skills/import-from-url`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.detail || data.error || `HTTP ${res.status}`);
if (input) input.value = '';
await loadSkills();
const name = data.skill?.name || 'skill';
uiModule.showToast(`Imported ${name} (${data.files || 1} file(s))`);
if (name) openSkill(name);
} catch (err) {
uiModule.showError('Import failed: ' + err.message);
} finally {
if (btn) btn.disabled = false;
}
}
async function addSkill() {
const name = document.getElementById('new-skill-name')?.value.trim()
|| document.getElementById('new-skill-title')?.value.trim();
@@ -1866,6 +1900,10 @@ async function addSkill() {
}
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('skill-import-url-btn')?.addEventListener('click', importSkillFromUrl);
document.getElementById('skill-import-url')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') importSkillFromUrl();
});
document.getElementById('add-skill-btn')?.addEventListener('click', addSkill);
document.getElementById('skills-search')?.addEventListener('input', renderSkillsList);
document.getElementById('skills-sort')?.addEventListener('change', (e) => {
+313
View File
@@ -0,0 +1,313 @@
// static/js/slashAutocomplete.js
// Lightweight popup that surfaces the existing /command registry as users
// type. Reads COMMANDS from slashCommands.js — no command logic lives here.
import { COMMANDS, LEGACY_ALIASES } from './slashCommands.js';
const POPUP_ID = 'slash-autocomplete';
const MAX_VISIBLE = 14;
// Flatten the registry into a searchable list of leaf entries. Each entry is
// either a top-level command or a "cmd sub" pair (so subcommands get their
// own row when relevant — /toggle web, /chats new, etc).
// Commands intentionally excluded from the autocomplete popup (pure easter
// eggs with no productivity value, or internal machinery).
const EXCLUDED = new Set(['flip','roll','8ball','fortune','odyssey','ascii']);
// Important legacy aliases to promote to their own rows in the popup. These
// are the short forms people will actually type (/new, /clear, /web, etc.)
// rather than the full /chats new, /toggle web equivalents.
const PROMOTED_ALIASES = new Set([
'new','clear','rename','fork','export','archive','favorite','unfavorite',
'web','bash','research','doc',
'memories','forget',
]);
function _flatten() {
const out = [];
const seen = new Set();
// 1. Top-level commands and their subcommands from COMMANDS
for (const [name, def] of Object.entries(COMMANDS)) {
if (EXCLUDED.has(name)) continue;
if (def.hidden) continue;
if (def.handler) {
seen.add(`/${name}`);
out.push({
token: `/${name}`,
aliases: (def.alias || []).map(a => `/${a}`),
category: def.category || '',
help: def.help || '',
usage: def.usage || '',
});
}
if (def.subs) {
for (const [sub, sdef] of Object.entries(def.subs)) {
if (sub.startsWith('_')) continue;
if (sdef.hidden) continue;
const tok = `/${name} ${sub}`;
seen.add(tok);
out.push({
token: tok,
aliases: (sdef.alias || []).map(a => `/${name} ${a}`),
category: def.category || '',
help: sdef.help || '',
usage: sdef.usage || '',
});
}
}
}
// 2. Promoted legacy aliases (/new, /clear, /web …) as convenient short rows
if (LEGACY_ALIASES) {
for (const [alias, { parent, sub }] of Object.entries(LEGACY_ALIASES)) {
if (!PROMOTED_ALIASES.has(alias)) continue;
const tok = `/${alias}`;
if (seen.has(tok)) continue;
const parentDef = COMMANDS[parent];
const subDef = parentDef?.subs?.[sub];
if (!subDef) continue;
seen.add(tok);
out.push({
token: tok,
aliases: [],
category: parentDef.category || '',
help: subDef.help || '',
usage: tok,
});
}
}
return out;
}
async function _loadSkillEntries() {
try {
const res = await fetch('/api/skills/slash-catalog', { credentials: 'same-origin' });
if (!res.ok) return [];
const data = await res.json();
return (Array.isArray(data.skills) ? data.skills : []).map(s => ({
token: s.token || `/${s.name}`,
aliases: [],
category: s.category || 'Skills',
help: s.help || 'Run skill',
usage: s.usage || `${s.token || `/${s.name}`} <request>`,
})).filter(e => e.token && e.token.startsWith('/'));
} catch {
return [];
}
}
function _scoreMatch(entry, query) {
// query already starts with "/". Match against token + aliases. Prefix wins
// over substring; alias match scores slightly lower than token match.
const q = query.toLowerCase();
const t = entry.token.toLowerCase();
if (t === q) return 1000;
if (t.startsWith(q)) return 500 + (50 - Math.min(50, t.length - q.length));
for (const a of entry.aliases) {
const al = a.toLowerCase();
if (al === q) return 900;
if (al.startsWith(q)) return 400;
}
if (t.includes(q)) return 100;
if (entry.help.toLowerCase().includes(q.slice(1))) return 25; // help text
return 0;
}
function _exactCommandGroupItems(all, query) {
const q = query.toLowerCase();
if (!/^\/[a-z0-9_-]+$/i.test(q)) return [];
const parent = all.find(entry => entry.token.toLowerCase() === q);
if (!parent) return [];
const prefix = q + ' ';
const children = all.filter(entry => entry.token.toLowerCase().startsWith(prefix));
if (!children.length) return [];
return children.concat(parent);
}
function _ensurePopup(textarea) {
let el = document.getElementById(POPUP_ID);
if (el) return el;
el = document.createElement('div');
el.id = POPUP_ID;
el.className = 'slash-autocomplete-popup';
el.setAttribute('role', 'listbox');
el.setAttribute('aria-label', 'Slash commands');
document.body.appendChild(el);
return el;
}
function _position(popup, textarea) {
const r = textarea.getBoundingClientRect();
const maxH = Math.min(window.innerHeight * 0.5, 360);
popup.style.maxHeight = maxH + 'px';
// Anchor above the textarea, left-aligned with it
popup.style.left = Math.round(r.left) + 'px';
popup.style.width = Math.max(280, Math.round(Math.min(r.width, 520))) + 'px';
// Place above when there's enough room, otherwise below.
const aboveSpace = r.top;
if (aboveSpace > maxH + 20) {
popup.style.bottom = (window.innerHeight - r.top + 6) + 'px';
popup.style.top = '';
} else {
popup.style.top = (r.bottom + 6) + 'px';
popup.style.bottom = '';
}
}
function _render(popup, items, selectedIdx, query) {
if (!items.length) {
popup.innerHTML = `<div class="slash-ac-empty">No commands match <code>${_esc(query)}</code></div>`;
return;
}
// Group by category for the headers
let html = '';
let lastCat = null;
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.category !== lastCat) {
html += `<div class="slash-ac-cat">${_esc(it.category || 'Other')}</div>`;
lastCat = it.category;
}
const sel = i === selectedIdx ? ' slash-ac-row-sel' : '';
const usage = it.usage && it.usage !== it.token ? ` <span class="slash-ac-usage">${_esc(it.usage)}</span>` : '';
html += `<div class="slash-ac-row${sel}" role="option" data-idx="${i}" data-token="${_esc(it.token)}">`
+ `<span class="slash-ac-token">${_esc(it.token)}</span>`
+ `<span class="slash-ac-help">${_esc(it.help)}</span>`
+ usage
+ `</div>`;
}
popup.innerHTML = html;
// Scroll selected into view
const selEl = popup.querySelector('.slash-ac-row-sel');
if (selEl) selEl.scrollIntoView({ block: 'nearest' });
}
function _esc(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;' }[c]));
}
export function initSlashAutocomplete(textarea) {
if (!textarea || textarea._slashAcWired) return;
textarea._slashAcWired = true;
let all = _flatten();
let popup = null;
let visible = false;
let items = [];
let selectedIdx = 0;
const hide = () => {
if (!visible) return;
visible = false;
if (popup) popup.style.display = 'none';
};
const show = () => {
if (!popup) popup = _ensurePopup(textarea);
visible = true;
popup.style.display = 'block';
_position(popup, textarea);
};
const refresh = () => {
const v = textarea.value;
// Only trigger when the message starts with "/" (no leading space) and
// contains at most one space after the command (so subcommands work).
// If the user has moved past the slash command (newline, longer prose),
// the menu hides — we don't autocomplete mid-sentence.
if (!v.startsWith('/') || v.includes('\n')) { hide(); return; }
const query = v.trim();
const groupItems = _exactCommandGroupItems(all, query);
if (groupItems.length) {
items = groupItems.slice(0, MAX_VISIBLE);
} else {
items = all
.map(e => ({ e, s: _scoreMatch(e, query) }))
.filter(x => x.s > 0)
.sort((a, b) => b.s - a.s)
.slice(0, MAX_VISIBLE)
.map(x => x.e);
}
if (!items.length && query.length > 1) { hide(); return; }
if (!items.length) {
// Just "/" with no matches — fall back to showing everything up to MAX_VISIBLE
items = all.slice(0, MAX_VISIBLE);
}
selectedIdx = 0;
show();
_render(popup, items, selectedIdx, query);
};
_loadSkillEntries().then(skillEntries => {
if (!skillEntries.length) return;
const seen = new Set(all.map(e => e.token));
const merged = all.slice();
for (const entry of skillEntries) {
if (seen.has(entry.token)) continue;
seen.add(entry.token);
merged.push(entry);
}
all = merged;
if (visible) refresh();
});
const insert = (token) => {
textarea.value = token + ' ';
textarea.dispatchEvent(new Event('input', { bubbles: true }));
textarea.focus();
const len = textarea.value.length;
textarea.setSelectionRange(len, len);
hide();
};
textarea.addEventListener('input', refresh);
textarea.addEventListener('focus', () => { if (textarea.value.startsWith('/')) refresh(); });
textarea.addEventListener('blur', () => { setTimeout(hide, 120); }); // delay so click works
textarea.addEventListener('keydown', (e) => {
if (!visible || !items.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIdx = (selectedIdx + 1) % items.length;
_render(popup, items, selectedIdx, textarea.value);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIdx = (selectedIdx - 1 + items.length) % items.length;
_render(popup, items, selectedIdx, textarea.value);
} else if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
// Tab always inserts. Enter inserts only when the user hasn't already
// typed a full command + args — i.e. the popup is still in completion
// mode, not in "ready to submit a typed-out command" mode.
const v = textarea.value.trim();
const exactHit = items.find(it => it.token === v || it.aliases.includes(v));
if (e.key === 'Enter' && exactHit) {
// User typed the whole command — let the normal submit path handle it
hide();
return;
}
e.preventDefault();
insert(items[selectedIdx].token);
} else if (e.key === 'Escape') {
e.preventDefault();
hide();
}
});
// Re-position on window resize / scroll
window.addEventListener('resize', () => { if (visible) _position(popup, textarea); });
// Click handler on the popup (delegated)
document.addEventListener('mousedown', (e) => {
if (!visible || !popup) return;
const row = e.target.closest?.('.slash-ac-row');
if (row && popup.contains(row)) {
e.preventDefault();
const tok = row.dataset.token;
if (tok) insert(tok);
}
});
}
export default { initSlashAutocomplete };
+712 -167
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -23,7 +23,9 @@ export const KEYS = {
MCP_ACTIVE: 'odysseus-mcp-active',
SECTION_ORDER: 'sidebar-section-order',
ADMIN_LAST_TAB: 'admin-last-tab',
DENSITY: 'odysseus-density'
DENSITY: 'odysseus-density',
WORKSPACE: 'odysseus-workspace',
PLAN: 'odysseus-plan'
};
/**
+206
View File
@@ -0,0 +1,206 @@
// streamingRenderer.js
//
// The DOM shell for incremental streaming markdown rendering. One instance owns
// the DOM of one streaming assistant message and is the only thing that writes to
// it while it streams.
//
// It keeps the message as two regions, separated by an invisible comment marker so
// the rendered blocks are direct children of the container (no wrapper elements to
// disturb CSS):
//
// [ finalized block, frozen ][ finalized block, frozen ] <!--tail--> [ live tail ]
//
// - Finalized blocks are rendered once and never touched again — so code-block
// hover buttons can't flicker and code is highlighted exactly once.
// - The live tail (the still-growing trailing block) is re-rendered each token,
// except an open code fence, which streams in append-mode (text appended to a
// stable <pre>, highlighted once when it closes).
//
// All the "is this safe to freeze?" logic lives in the pure segmenter; this file
// is deliberately mechanical. If anything throws, it latches into a full-re-render
// fallback so a bug can never produce broken output — only today's behavior.
import { splitFinalized, describeOpenFence } from './streamingSegmenter.js';
// Compile-time escape hatch: set to false to force the plain full-re-render path.
// (The per-instance try/catch `degraded` fallback below is the runtime safety net.)
const ENABLED = true;
export function createStreamRenderer(contentEl, { render, hljs } = {}) {
let started = false;
let tailMarker = null; // finalized nodes precede it; live-tail nodes follow it
let committedLen = 0; // chars of source already frozen
let lastText = ''; // most recent full text (for finalize)
let tailShownLen = 0; // rendered-text length of the live tail (drives token fade)
let appendMode = null; // { codeText: Text, appendedLen } while an open fence streams
let degraded = !ENABLED; // true once we fall back to full re-render
function start() {
contentEl.textContent = '';
tailMarker = document.createComment('tail');
contentEl.appendChild(tailMarker);
started = true;
}
function highlight(root) {
if (hljs) root.querySelectorAll('pre code').forEach((b) => hljs.highlightElement(b));
}
function clearTail() {
while (tailMarker.nextSibling) tailMarker.nextSibling.remove();
}
// Render `src` and freeze the nodes before the tail marker. Highlighting happens
// here, once, on the detached fragment before the nodes are ever shown.
function freeze(src) {
const holder = document.createElement('div');
holder.innerHTML = render(src);
highlight(holder);
while (holder.firstChild) contentEl.insertBefore(holder.firstChild, tailMarker);
}
// Re-render the live tail. An open trailing fence streams in append-mode.
function renderTail(tailText) {
const fence = tailText ? describeOpenFence(tailText) : null;
if (fence) {
appendOpenFence(tailText, fence);
return;
}
appendMode = null;
clearTail();
if (!tailText) {
tailShownLen = 0;
return;
}
const holder = document.createElement('div');
holder.innerHTML = render(tailText);
fadeNewText(holder, tailShownLen);
tailShownLen = holder.textContent.length;
while (holder.firstChild) contentEl.appendChild(holder.firstChild);
}
// Stream the body of an unterminated code fence by appending only the new
// characters to a stable <pre><code> text node — no re-parse, no re-highlight.
function appendOpenFence(tailText, fence) {
if (!appendMode) {
clearTail();
const pre = document.createElement('pre');
const code = document.createElement('code');
if (fence.lang) code.className = `language-${fence.lang}`;
const textNode = document.createTextNode('');
code.appendChild(textNode);
pre.appendChild(code);
contentEl.appendChild(pre);
appendMode = { codeText: textNode, appendedLen: 0 };
tailShownLen = 0; // code is never faded; prose after the fence fades fresh
}
const code = tailText.slice(fence.contentStart);
if (code.length > appendMode.appendedLen) {
appendMode.codeText.appendData(code.slice(appendMode.appendedLen));
appendMode.appendedLen = code.length;
}
}
// Wrap tail text past `prevLen` characters in <span class="token-new"> for the
// streaming fade-in. Skips code (<pre>) and thinking blocks (.thinking-content).
// Note: the original chat.js helper checked `.think-content`, a class that exists
// nowhere in the app, so thinking text used to fade; matching the real
// `.thinking-content` corrects that. Operates on the detached fragment before insertion.
function fadeNewText(container, prevLen) {
if (!prevLen) return;
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let count = 0;
const toWrap = [];
while (walker.nextNode()) {
const node = walker.currentNode;
const len = node.textContent.length;
if (count + len <= prevLen) {
count += len;
continue;
}
toWrap.push({ node, splitAt: count < prevLen ? prevLen - count : 0 });
count += len;
}
for (const { node, splitAt } of toWrap) {
const parent = node.parentNode;
if (!parent || parent.closest('pre, .thinking-content')) continue;
const target = splitAt > 0 ? node.splitText(splitAt) : node;
const span = document.createElement('span');
span.className = 'token-new';
parent.replaceChild(span, target);
span.appendChild(target);
}
}
function fullRender(fullText) {
contentEl.innerHTML = render(fullText);
highlight(contentEl);
}
// Render the latest full source text.
//
// PRECONDITION: callers must pass append-only text — each call's `fullText` must
// extend the previous one with the already-seen prefix UNCHANGED. Finalized
// blocks are frozen and never re-rendered, so a feed that rewrites earlier text
// would leave stale frozen blocks (corrected only by the next full re-render).
// chat.js satisfies this: its stripToolBlocks output only strips not-yet-finalized
// trailing tool syntax, never text that has already been frozen.
function update(fullText) {
lastText = fullText;
if (degraded) {
fullRender(fullText);
return;
}
try {
// Self-heal: if our DOM was replaced out from under us — chat.js writes
// contentEl.innerHTML directly for thinking indicators and tool blocks, and
// finalize() removes the marker — our tail marker is no longer a child of the
// container. Rebuild from scratch so we never append onto foreign content or
// touch a detached marker.
if (started && (!tailMarker || tailMarker.parentNode !== contentEl)) {
started = false;
committedLen = 0;
tailShownLen = 0;
appendMode = null;
}
if (!started) start();
const next = splitFinalized(fullText, render, committedLen);
if (next > committedLen) {
freeze(fullText.slice(committedLen, next));
committedLen = next;
appendMode = null; // whatever was streaming is now frozen
tailShownLen = 0;
}
renderTail(fullText.slice(committedLen));
} catch (err) {
degraded = true;
console.error('streamingRenderer: falling back to full render', err);
fullRender(fullText);
}
}
// Stream finished: freeze whatever is left canonically and flatten away the
// marker so the container holds exactly what a single full render would produce.
// chat.js currently re-renders the finished message from source for its own
// reasons and so doesn't call this, but it completes the renderer's lifecycle and
// is exercised by the tests.
function finalize() {
if (degraded) return;
try {
if (!started) start();
clearTail();
appendMode = null;
const rest = lastText.slice(committedLen);
if (rest.trim()) freeze(rest);
tailMarker.remove();
tailMarker = null;
committedLen = lastText.length;
} catch (err) {
degraded = true;
console.error('streamingRenderer: falling back to full render', err);
fullRender(lastText);
}
}
return { update, finalize };
}
+190
View File
@@ -0,0 +1,190 @@
// streamingSegmenter.js
//
// Pure logic for incremental ("block-at-a-time") streaming markdown rendering.
//
// While an assistant message streams in, re-rendering the whole accumulated
// markdown on every token is wasteful (O(N^2)) and recreates DOM nodes, which
// makes code-block hover buttons flicker. The fix is to FREEZE the leading part
// of the message that can no longer change, and only re-render the growing tail.
//
// This module answers the one hard question that makes freezing safe:
//
// Given the full markdown received so far, how many leading characters can
// be finalized without changing the rendered output?
//
// The contract callers rely on (`render` is the canonical markdown renderer):
//
// const n = splitFinalized(text, render);
// render(text.slice(0, n)) + render(text.slice(n)) === render(text)
//
// The module is intentionally DOM-free and renderer-agnostic so it can be unit
// tested in isolation and reused for any markdown renderer with no long-range
// cross-block dependencies (no reference-style links / footnotes).
//
// Known limitations (both bounded by the same mitigation):
// - cutIsRenderSafe proves only PRESENT-tense equivalence. If the renderer pairs
// an inline delimiter across a blank line (e.g. markdown.js will turn
// `*a\n\nb*` into emphasis spanning two paragraphs), a block frozen before the
// closing delimiter arrives can disagree with the final full render.
// - afterClosedFence boundaries are trusted without the equivalence check, so a
// fence the real renderer parses differently (e.g. a stray 4-backtick line) can
// be mis-detected as a close.
// Both only occur for input the renderer itself handles oddly, and both are
// transient: chat.js re-renders the finished message from source, so the settled
// output is always canonical.
// A fenced-code delimiter line: up to 3 leading spaces, then >=3 backticks or
// tildes, then an optional info string.
const FENCE_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
/**
* Scan `text` starting at `fromOffset` which MUST be at top level (callers only
* ever advance to a finalized boundary, never into a fence) and collect the
* candidate cut points.
*
* @returns {{ boundaries: Array<{offset:number, afterClosedFence:boolean}>, inFence:boolean }}
* - A blank-line run at top level yields a boundary at the start of the next
* non-blank line (`afterClosedFence: false`).
* - A fence close yields a boundary just past the closing fence line
* (`afterClosedFence: true`) such a cut is unconditionally safe, since
* nothing can ever merge into a completed code block.
*/
function findBoundaries(text, fromOffset) {
const boundaries = [];
const n = text.length;
let inFence = false;
let fenceMarker = '';
let i = fromOffset;
while (i < n) {
const nl = text.indexOf('\n', i);
const lineEnd = nl === -1 ? n : nl;
const afterNl = nl === -1 ? n : nl + 1;
const line = text.slice(i, lineEnd);
const fence = line.match(FENCE_RE);
if (fence) {
const marker = fence[1];
if (!inFence) {
inFence = true;
fenceMarker = marker;
} else if (
marker[0] === fenceMarker[0] &&
marker.length >= fenceMarker.length &&
fence[2].trim() === '' // a closing fence carries no info string
) {
inFence = false;
fenceMarker = '';
boundaries.push({ offset: afterNl, afterClosedFence: true });
}
i = afterNl;
} else if (!inFence && line.trim() === '') {
// Consume the entire run of blank lines; the boundary is the start of the
// next non-blank line so the finalized side owns the separator and the tail
// starts clean.
let j = afterNl;
while (j < n) {
const nl2 = text.indexOf('\n', j);
const lineEnd2 = nl2 === -1 ? n : nl2;
if (text.slice(j, lineEnd2).trim() !== '') break;
if (nl2 === -1) {
j = n;
break;
}
j = nl2 + 1;
}
boundaries.push({ offset: j, afterClosedFence: false });
i = j;
} else {
i = afterNl;
}
}
return { boundaries, inFence };
}
/**
* Does cutting between `before` and `after` leave the rendered output unchanged?
* This is the self-verifying safety check: it directly compares rendering the two
* sides separately against rendering them joined, so constructs that span the cut
* (loose lists, setext headings, lazy blockquote continuations, tables) are caught
* with no hand-coded grammar rules.
*
* Renderer non-determinism (e.g. mermaid ids seeded with Date.now()) can only make
* this return a false negative, never a false positive so the bias is always
* toward under-finalizing, which is the safe direction.
*/
function cutIsRenderSafe(before, after, render) {
return render(before) + render(after) === render(before + after);
}
/**
* Return how many leading characters of `text` can be safely finalized, scanning
* forward from `committedLen` (the amount already finalized).
*
* Guarantees `render(text.slice(0, n)) + render(text.slice(n)) === render(text)`,
* and `committedLen <= n <= text.length`.
*
* @param {string} text Full markdown accumulated so far.
* @param {(src:string)=>string} render Canonical markdown renderer.
* @param {number} [committedLen=0] Characters already finalized (always a prior boundary).
* @returns {number}
*/
export function splitFinalized(text, render, committedLen = 0) {
const { boundaries } = findBoundaries(text, committedLen);
let best = committedLen;
let segStart = committedLen;
for (let k = 0; k < boundaries.length; k++) {
const { offset, afterClosedFence } = boundaries[k];
if (afterClosedFence) {
// A completed code block — always safe to freeze through here.
best = offset;
} else {
// A prose/list/table boundary. We need a following block to compare
// against (the last block must stay live, it can still grow), and the cut
// must be render-equivalent locally.
const nextOffset = k + 1 < boundaries.length ? boundaries[k + 1].offset : text.length;
const before = text.slice(segStart, offset);
const after = text.slice(offset, nextOffset);
if (after.trim() !== '' && cutIsRenderSafe(before, after, render)) {
best = offset;
}
}
segStart = offset;
}
return best;
}
/**
* If `text` begins with a fenced-code opener whose fence never closes, describe it
* so the renderer can stream the code in append-mode instead of re-rendering it.
* Returns `{ lang, contentStart }` (contentStart = offset of the first code char),
* or null when `text` does not start with a still-open fence.
*
* The opener line must be complete (terminated by a newline) so the info string /
* language is known before append-mode begins.
*/
export function describeOpenFence(text) {
const open = text.match(/^( {0,3})(`{3,}|~{3,})([^\n]*)\n/);
if (!open) return null;
const marker = open[2];
const contentStart = open[0].length;
for (let i = contentStart; i < text.length; ) {
const nl = text.indexOf('\n', i);
const line = text.slice(i, nl === -1 ? text.length : nl);
const close = line.match(/^ {0,3}(`{3,}|~{3,})\s*$/);
if (close && close[1][0] === marker[0] && close[1].length >= marker.length) {
return null; // the fence closes — let the normal finalize path handle it
}
if (nl === -1) break;
i = nl + 1;
}
const lang = (open[3] || '').trim().split(/\s+/)[0] || '';
return { lang, contentStart };
}
+157 -32
View File
@@ -7,6 +7,7 @@ import markdownModule from './markdown.js';
import * as spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js';
import { sortModelIds } from './modelSort.js';
import { ordinalSuffix } from './util/ordinal.js';
const API_BASE = window.location.origin;
let _open = false;
@@ -23,7 +24,7 @@ const DAYS_OF_WEEK = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'S
async function _fetchTasks() {
try {
const res = await fetch(`${API_BASE}/api/tasks?include_last_run=true`, { credentials: 'same-origin' });
const res = await fetch(`${API_BASE}/api/tasks`, { credentials: 'same-origin' });
const data = await res.json();
_tasks = data.tasks || [];
} catch (e) {
@@ -127,6 +128,21 @@ async function _runNow(id, force = false) {
}
}
async function _stopTask(id) {
const res = await fetch(`${API_BASE}/api/tasks/${id}/stop`, {
method: 'POST',
credentials: 'same-origin',
});
if (!res.ok) {
let msg = `Failed to stop task (${res.status})`;
try {
const data = await res.json();
if (data && data.detail) msg = data.detail;
} catch (_) {}
throw new Error(msg);
}
}
async function _fetchRuns(taskId, limit = 10) {
const res = await fetch(`${API_BASE}/api/tasks/${taskId}/runs?limit=${limit}`, {
credentials: 'same-origin',
@@ -229,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 || '—';
@@ -311,7 +327,6 @@ const _TASK_ICONS = {
draft_email_replies: '<polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/>',
extract_email_events:'<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/><path d="M7 14h5"/><path d="M7 18h8"/>',
classify_events: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/><path d="M8 15h.01M12 15h.01M16 15h.01"/>',
mark_email_boundaries:'<path d="M4 4h16v16H4z"/><path d="M4 9h16"/><path d="M9 4v16"/>',
learn_sender_signatures:'<path d="M20 6 9 17l-5-5"/><path d="M14 6h6v6"/>',
check_email_urgency: '<path d="M13.73 21a2 2 0 0 1-3.46 0"/><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/>',
// Skills
@@ -334,6 +349,26 @@ function _taskIcon(task) {
return `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.4;flex-shrink:0;position:relative;top:-4px;">${path}</svg>`;
}
const _MODEL_BACKED_ACTIONS = new Set([
'summarize_emails',
'draft_email_replies',
'extract_email_events',
'classify_events',
'learn_sender_signatures',
'check_email_urgency',
'test_skills',
'audit_skills',
'consolidate_memory',
]);
function _taskAiMark(task) {
const kind = task?.task_type || task?.kind || '';
const action = task?.action || '';
const aiAction = _MODEL_BACKED_ACTIONS.has(action);
if (!(kind === 'llm' || kind === 'research' || task?.model || task?.endpointUrl || aiAction)) return '';
return '<svg class="task-ai-mark" width="10" height="10" viewBox="0 0 24 24" fill="currentColor" aria-label="Uses model" title="Uses model"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>';
}
// ---- Custom pickers ----
function _buildTimePicker(containerId, hour, minute) {
@@ -461,7 +496,6 @@ const _CATEGORY_MAP = {
extract_email_events: 'Calendar',
summarize_emails: 'Email',
draft_email_replies: 'Email',
mark_email_boundaries: 'Email',
learn_sender_signatures: 'Email',
check_email_urgency: 'Email',
daily_brief: 'Assistant',
@@ -470,8 +504,13 @@ const _CATEGORY_MAP = {
ssh_command: 'System',
run_script: 'System',
run_local: 'System',
cookbook_serve: 'Cookbook',
};
const _CATEGORY_ORDER = ['Other', 'Calendar', 'Email', 'Chats', 'Documents', 'Memory', 'Research', 'Skills', 'Assistant', 'System'];
// Cookbook serves listed FIRST so a just-saved schedule shows at the
// top instead of scrolling off the bottom of the list. The remaining
// order is preserved for backwards-compatibility with users who've
// learned where things are.
const _CATEGORY_ORDER = ['Cookbook', 'Other', 'Calendar', 'Email', 'Chats', 'Documents', 'Memory', 'Research', 'Skills', 'Assistant', 'System'];
const _CATEGORY_ICONS = {
Calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
Email: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
@@ -482,6 +521,8 @@ const _CATEGORY_ICONS = {
Skills: '<path d="M9 11l3 3L22 4"/><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H20v15H6.5A2.5 2.5 0 0 0 4 19.5z"/>',
Assistant: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 18a5 5 0 0 1 10 0"/>',
System: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
// Cookbook icon — matches the recipe-book glyph used on the sidebar.
Cookbook: '<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"/>',
Other: '<circle cx="12" cy="12" r="3"/>',
};
@@ -568,6 +609,18 @@ function _renderTaskChips() {
for (const c of cats) mkChip(`${c} (${counts[c]})`, c, _taskFilter === c);
}
const _TASK_CACHE_LABELS = {
summarize_emails: 'email summaries',
draft_email_replies: 'AI reply drafts',
extract_email_events: 'email calendar cache',
learn_sender_signatures: 'sender signatures',
check_email_urgency: 'email tags',
};
function _taskClearCacheLabel(taskOrEntry) {
return _TASK_CACHE_LABELS[taskOrEntry?.action || ''] || '';
}
function _renderList() {
const list = document.getElementById('tasks-list');
if (!list) return;
@@ -628,14 +681,14 @@ function _renderList() {
const titleRow = document.createElement('div');
titleRow.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;';
const statusBadge = task.status === 'paused'
? `<span class="task-status-badge task-paused-badge" data-task-status-action="resume" title="Click to resume" style="position:relative;top:4px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg> paused</span>`
? `<span class="task-status-badge task-state-badge task-paused-badge" data-task-status-action="resume" title="Paused - click to resume" style="position:relative;top:4px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><polygon points="7 4 19 12 7 20 7 4"/></svg><span class="task-state-label">paused</span></span>`
: task.status === 'active'
? `<span class="task-status-badge task-active-badge" data-task-status-action="pause" title="Click to pause" style="position:relative;top:4px;"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><polygon points="7 4 19 12 7 20 7 4"/></svg> active</span>`
? `<span class="task-status-badge task-state-badge task-active-badge" data-task-status-action="pause" title="Active - click to pause" style="position:relative;top:4px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg><span class="task-state-label">active</span></span>`
: '';
const builtinBadge = task.is_builtin
? `<span class="task-builtin-badge${task.is_modified ? ' modified' : ''}" title="${task.is_modified ? 'Built-in task — edited from its default' : 'Built-in task'}">built-in${task.is_modified ? ' · edited' : ''}</span>`
: '';
titleRow.innerHTML = `${_taskIcon(task)}<span class="memory-item-title">${_esc(task.name)}</span>${builtinBadge}<span style="flex:1;"></span>${statusBadge}`;
titleRow.innerHTML = `${_taskIcon(task)}<span class="memory-item-title">${_esc(task.name)}</span>${_taskAiMark(task)}${builtinBadge}<span style="flex:1;"></span>${statusBadge}`;
// ... menu button (hover to show)
const actionsWrap = document.createElement('div');
@@ -659,6 +712,9 @@ function _renderList() {
if (task.is_builtin && task.is_modified) {
items.push({ label: 'Revert to default', icon: '<polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>', action: () => _doRevert(task.id) });
}
if (_taskClearCacheLabel(task)) {
items.push({ label: 'Clear cache', icon: '<path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v5"/><path d="M14 11v5"/>', action: () => _doClearTaskCache(task.id, _taskClearCacheLabel(task)) });
}
items.push({ label: 'Delete', icon: '<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>', action: () => _doDelete(task.id), danger: true });
_showTaskDropdown(menuBtn, items);
});
@@ -667,10 +723,10 @@ function _renderList() {
// manual triggering. Hidden for completed tasks (same gate as before).
if (task.status !== 'completed') {
const runBtn = document.createElement('button');
runBtn.className = 'memory-item-btn task-card-run-btn';
runBtn.className = 'task-status-badge task-run-now-badge task-card-run-btn';
runBtn.title = 'Run now';
runBtn.style.cssText = 'position:relative;top:4px;margin-right:4px;display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:2px 6px;';
runBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg><span>Run</span>';
runBtn.style.cssText = 'position:relative;top:1px;margin-right:4px;';
runBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg><span>Run</span>';
runBtn.addEventListener('click', (e) => { e.stopPropagation(); _doRunNow(task.id); });
actionsWrap.insertBefore(runBtn, menuBtn);
}
@@ -906,9 +962,13 @@ function _showPresetPicker() {
let 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;"><h2 style="margin:0;padding:0;line-height:1;">Add Task</h2></div>';
html += '<p class="memory-desc" style="position:relative;top:4px;">Describe a task for the AI to draft, or pick a type below to set one up manually.</p>';
html += '<div class="task-ai-compose" style="display:flex;gap:6px;margin:6px 0 10px;">'
+ '<input type="text" id="task-ai-input" class="memory-search-input" style="flex:1;" placeholder="Describe a task — e.g. &quot;every weekday 7am summarize my unread email&quot;" />'
+ '<button class="memory-toolbar-btn active" id="task-ai-btn" title="Draft a task with AI" style="white-space:nowrap;height:28px;"><svg width="12" height="12" 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>Draft with AI</button>'
// flex-wrap + min-width:0 on the input lets the row collapse cleanly
// on narrow modal widths instead of pushing the AI button past the
// right edge. margin-left:-4px nudges the compose row 4px into the
// description bar above so the input lines up with it visually.
html += '<div class="task-ai-compose" style="display:flex;gap:6px;margin:6px 0 10px -4px;flex-wrap:wrap;align-items:center;">'
+ '<input type="text" id="task-ai-input" class="memory-search-input" style="flex:1 1 220px;min-width:0;" placeholder="Describe a task — e.g. &quot;every weekday 7am summarize my unread email&quot;" />'
+ '<button class="memory-toolbar-btn active" id="task-ai-btn" title="Draft a task with AI" style="white-space:nowrap;height:28px;flex:0 0 auto;"><svg width="12" height="12" 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>Draft with AI</button>'
+ '</div>';
html += '<div class="memory-list" style="max-height:none;flex:1;gap:0px;margin-top:2px;padding-right:8px;">';
_TASK_PRESETS.forEach((p, i) => {
@@ -1578,6 +1638,25 @@ async function _doRevert(id) {
} catch (e) { if (uiModule) uiModule.showError(e.message); }
}
async function _doClearTaskCache(id, label = 'cache') {
const ok = uiModule?.styledConfirm
? await uiModule.styledConfirm(`Clear cached ${label} for this task?`, { confirmText: 'Clear' })
: confirm(`Clear cached ${label} for this task?`);
if (!ok) return;
try {
const res = await fetch(`${API_BASE}/api/tasks/${encodeURIComponent(id)}/clear-cache`, {
method: 'POST',
credentials: 'same-origin',
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) throw new Error(data.detail || data.error || `HTTP ${res.status}`);
const n = Object.values(data.cleared || {}).reduce((a, b) => a + Number(b || 0), 0) + Number(data.files || 0);
if (uiModule) uiModule.showToast(`Cleared ${label}${n ? ` (${n})` : ''}`);
} catch (e) {
if (uiModule) uiModule.showError(`Clear cache failed: ${e.message || e}`);
}
}
async function _doToggleAll() {
// If any task is active → pause all. Else resume all paused tasks.
const hasActive = _tasks.some(t => t.status === 'active');
@@ -1667,7 +1746,7 @@ async function _renderActivityView() {
<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;">Activity</h2>
<button class="memory-toolbar-btn" id="tasks-activity-refresh" title="Refresh" style="margin-left:auto;">Refresh</button>
<button class="memory-toolbar-btn" id="tasks-activity-refresh" title="Refresh" style="margin-left:auto;"><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;"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
</div>
<p class="memory-desc">Recent task runs across all scheduled tasks.</p>
<div style="display:flex;align-items:center;gap:6px;margin:6px 0 8px;">
@@ -1680,10 +1759,6 @@ async function _renderActivityView() {
document.getElementById('tasks-activity-refresh').addEventListener('click', _renderActivityView);
// Loading placeholder matches the document library: app whirlpool + label.
const _actList = document.getElementById('tasks-activity-list');
if (_actList) _actList.appendChild(spinnerModule.createLoadingRow('Loading…'));
// Solo filter: clicking a chip shows ONLY that group (a category, or
// Errors). Clicking the active chip again clears the filter (show all).
// At most one chip is active at a time. _solo holds the active key, or null.
@@ -1771,6 +1846,14 @@ async function _renderActivityView() {
const searchEl = document.getElementById('tasks-activity-search');
if (searchEl) searchEl.addEventListener('input', () => { _afQuery = searchEl.value; _buildChips(); _applyFilter(); });
const _actList = document.getElementById('tasks-activity-list');
if (_activityEntries.length) {
_buildChips();
_applyFilter();
} else if (_actList) {
_actList.appendChild(spinnerModule.createLoadingRow('Loading…'));
}
try {
const res = await fetch(`${API_BASE}/api/tasks/runs/recent?limit=100`, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -1796,6 +1879,7 @@ async function _renderActivityView() {
kind: r.task_type || 'llm',
taskName: r.task_name || (r.task_type === 'action' ? (r.action || 'Action') : 'Task'),
taskId: r.task_id,
action: r.action || '',
result: resultText,
prompt: '',
ts: r.finished_at || r.started_at,
@@ -1916,9 +2000,9 @@ function _wireActivityRows(list) {
// counter). No-op when there's nothing to tick.
_startActivityTimers(list);
list.querySelectorAll('.task-log-row').forEach(row => {
// Click anywhere on the (non-running, non-skipped) row to toggle expand.
// Click anywhere on the row to toggle expand.
// Buttons inside still get their own handlers via stopPropagation.
if (!row.classList.contains('is-running') && !row.classList.contains('is-skipped')) {
if (!row.classList.contains('is-skipped')) {
row.addEventListener('click', () => row.classList.toggle('expanded'));
}
row.querySelector('.task-log-row-toggle')?.addEventListener('click', (e) => {
@@ -1943,6 +2027,25 @@ function _wireActivityRows(list) {
const entry = _activityEntries[idx];
if (entry?.taskId) _doRunNow(entry.taskId, true);
});
row.querySelector('.task-log-stop')?.addEventListener('click', async (e) => {
e.stopPropagation();
const idx = parseInt(row.dataset.entryIdx, 10);
const entry = _activityEntries[idx];
if (!entry?.taskId) return;
try {
await _stopTask(entry.taskId);
uiModule.showToast('Task stopped');
_renderActivityView();
} catch (err) {
uiModule.showError(err.message || 'Failed to stop task');
}
});
row.querySelector('.task-log-run-again')?.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(row.dataset.entryIdx, 10);
const entry = _activityEntries[idx];
if (entry?.taskId) _doRunNow(entry.taskId);
});
row.querySelector('.task-log-copy')?.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(row.dataset.entryIdx, 10);
@@ -1954,6 +2057,12 @@ function _wireActivityRows(list) {
uiModule.showToast('Log copied');
} catch (_) { uiModule.showError('Copy failed'); }
});
row.querySelector('.task-log-clear-cache')?.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt(row.dataset.entryIdx, 10);
const entry = _activityEntries[idx];
if (entry?.taskId) _doClearTaskCache(entry.taskId, _taskClearCacheLabel(entry));
});
});
}
@@ -2113,13 +2222,11 @@ function _renderActivityEntry(entry) {
const statusDot = `<span class="task-log-status task-log-status-${status}" title="${status}"></span>`;
// Render the result through markdown so code blocks, lists, links look right.
let resultHtml;
// Running / queued rows: body stays empty — the status now lives on the
// right side of the head row ("Running <whirlpool>"), wired below.
const _isRunning = entry.status === 'running' || entry.status === 'queued';
// Skipped (noop) rows: render as a slim, dimmed one-liner — no body, no
// actions, just `· name · skipped — reason · time`. CSS via .is-skipped.
const _isSkipped = entry.status === 'skipped';
if (_isRunning) {
if (_isRunning && !(entry.result || '').trim()) {
resultHtml = '';
} else {
try {
@@ -2154,7 +2261,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() && !_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
@@ -2179,6 +2288,19 @@ function _renderActivityEntry(entry) {
Copy log
</button>`;
}
const clearLabel = _taskClearCacheLabel(entry);
if (hasResult && clearLabel && entry.taskId) {
actionBtn += `<button class="task-log-clear-cache" type="button" title="Clear cached ${_escHtml(clearLabel)} for this task">
<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="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v5"/><path d="M14 11v5"/></svg>
Clear cache
</button>`;
}
if (hasResult && entry.taskId) {
actionBtn += `<button class="task-log-run-again" type="button" title="Run this task again">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
Run again
</button>`;
}
// Running rows replace the relative-time on the right with "Running NN" + a
// live whirlpool spinner. Queued shows "Queued" the same way (no timer —
// hasn't actually started yet). The elapsed counter ticks every second via
@@ -2186,12 +2308,14 @@ 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>` : '';
rightHtml = `<span class="task-log-running-inline"><span class="task-log-running-label">${label}</span>${elapsedInit}<span data-spin-here="1"></span>${forceBtn}</span>`;
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 {
rightHtml = `<span class="task-log-time" title="${_escHtml(tsAbs)}">${_escHtml(tsLabel)}</span>`;
}
@@ -2205,10 +2329,10 @@ function _renderActivityEntry(entry) {
<div class="task-log-row is-skipped" data-kind="${_escHtml(entry.kind)}" data-entry-idx="${entryIdx}" style="${styleVars}">
<div class="task-log-row-head">
${statusDot}
<span class="task-log-name">${_escHtml(entry.taskName)}</span>
<span class="task-log-task-icon">${_taskIcon({ action: entry.action, task_type: entry.kind })}</span>
<span class="task-log-name">${_escHtml(entry.taskName)}</span>${_taskAiMark(entry)}
${repeatBadge}
<span class="task-log-skipped-reason">skipped${reason ? ' — ' + _escHtml(reason) : ''}</span>
<span style="flex:1"></span>
<span class="task-log-time" title="${_escHtml(tsAbs)}">${_escHtml(tsLabel)}</span>
</div>
</div>
@@ -2218,12 +2342,13 @@ function _renderActivityEntry(entry) {
<div class="task-log-row${long ? ' is-long' : ''}${_isRunning ? ' is-running' : ''}" data-kind="${_escHtml(entry.kind)}" data-entry-idx="${entryIdx}" style="${styleVars}">
<div class="task-log-row-head">
${statusDot}
<span class="task-log-name">${_escHtml(entry.taskName)}</span>
<span class="task-log-task-icon">${_taskIcon({ action: entry.action, task_type: entry.kind })}</span>
<span class="task-log-name">${_escHtml(entry.taskName)}</span>${_taskAiMark(entry)}
${repeatBadge}
<span style="flex:1"></span>
${rightHtml}
</div>
${_isRunning ? '' : `<div class="task-log-row-body">${resultHtml}</div>`}
${(_isRunning && !hasRunningProgress) ? '' : `<div class="task-log-row-body">${resultHtml}</div>`}
${promptHtml}
<div class="task-log-row-actions">
${long ? '<button class="task-log-row-toggle" type="button">Show more</button>' : '<span></span>'}
@@ -2308,7 +2433,7 @@ function _renderMainView() {
<p class="memory-desc" style="position:relative;top:-4px;">Scheduled prompts and actions that run automatically. Results appear in a dedicated session.</p>
<div class="memory-toolbar">
<div class="memory-category-filters" style="display:flex;align-items:center;gap:6px;">
<select class="memory-sort-select" id="tasks-sort" style="position:relative;top:-4px;width:86px;font-size:11px;height:24px;">
<select class="memory-sort-select" id="tasks-sort" aria-label="Sort tasks" title="Sort tasks" style="position:relative;top:-4px;width:86px;font-size:11px;height:24px;">
<option value="recent">Recent</option>
<option value="name">AZ</option>
<option value="status">Status</option>
+29 -9
View File
@@ -4,6 +4,7 @@
import Storage from './storage.js';
import uiModule from './ui.js';
import { initColorPickers, attachColorPicker } from './colorPicker.js';
import { hexToRgb } from './color/hex.js';
import { makeWindowDraggable } from './windowDrag.js';
import { snapModalToZone } from './tileManager.js';
@@ -128,10 +129,10 @@ function _syncCustomThemesToServer(ct) {
// --- Syntax color derivation from theme base colors ---
function hexToHSL(hex) {
hex = hex.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16) / 255;
const g = parseInt(hex.substring(2, 4), 16) / 255;
const b = parseInt(hex.substring(4, 6), 16) / 255;
const rgb = hexToRgb(hex) || { r: 0, g: 0, b: 0 };
const r = rgb.r / 255;
const g = rgb.g / 255;
const b = rgb.b / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) { h = s = 0; }
@@ -1495,6 +1496,9 @@ function _initSynapse() {
const canvas = document.createElement('canvas');
canvas.id = 'synapse-canvas';
canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;';
// Decorative background effect — hide from assistive tech so screen readers
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
canvas.setAttribute('aria-hidden', 'true');
document.body.prepend(canvas);
const ctx = canvas.getContext('2d');
const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -1588,6 +1592,9 @@ function _initRain() {
const canvas = document.createElement('canvas');
canvas.id = 'rain-canvas';
canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;';
// Decorative background effect — hide from assistive tech so screen readers
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
canvas.setAttribute('aria-hidden', 'true');
document.body.prepend(canvas);
const ctx = canvas.getContext('2d');
const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -1660,6 +1667,9 @@ function _initConstellations() {
const canvas = document.createElement('canvas');
canvas.id = 'constellations-canvas';
canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;';
// Decorative background effect — hide from assistive tech so screen readers
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
canvas.setAttribute('aria-hidden', 'true');
document.body.prepend(canvas);
const ctx = canvas.getContext('2d');
const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -1763,6 +1773,9 @@ function _initPerlinFlow() {
const canvas = document.createElement('canvas');
canvas.id = 'perlin-flow-canvas';
canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;';
// Decorative background effect — hide from assistive tech so screen readers
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
canvas.setAttribute('aria-hidden', 'true');
document.body.prepend(canvas);
const ctx = canvas.getContext('2d');
const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -1785,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;
@@ -1818,6 +1830,9 @@ function _initPetals() {
const canvas = document.createElement('canvas');
canvas.id = 'petals-canvas';
canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;';
// Decorative background effect — hide from assistive tech so screen readers
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
canvas.setAttribute('aria-hidden', 'true');
document.body.prepend(canvas);
const ctx = canvas.getContext('2d');
const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -1872,6 +1887,9 @@ function _initSparkles() {
const canvas = document.createElement('canvas');
canvas.id = 'sparkles-canvas';
canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;';
// Decorative background effect — hide from assistive tech so screen readers
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
canvas.setAttribute('aria-hidden', 'true');
document.body.prepend(canvas);
const ctx = canvas.getContext('2d');
const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -1927,6 +1945,9 @@ function _initEmbers() {
const canvas = document.createElement('canvas');
canvas.id = 'embers-canvas';
canvas.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:0;';
// Decorative background effect — hide from assistive tech so screen readers
// don't announce an empty canvas and axe's "region" rule doesn't flag it.
canvas.setAttribute('aria-hidden', 'true');
document.body.prepend(canvas);
const ctx = canvas.getContext('2d');
const dpr = Math.min(window.devicePixelRatio || 1, 2);
@@ -1961,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')) {
+120 -8
View File
@@ -6,12 +6,16 @@
import themeModule from './theme.js';
import * as Modals from './modalManager.js';
import spinnerModule from './spinner.js';
import { registerMenuDismiss, dismissTopMenu, dismissOrRemove } from './escMenuStack.js';
let toastEl = null;
let autoScrollEnabled = true;
let hoveredToggleCard = null;
let hoveredToggleWindow = null;
let hoveredDockChip = null;
let _lastPointerClientX = null;
let _lastPointerClientY = null;
// Smooth scroll state
let _scrollRafId = null;
@@ -74,6 +78,66 @@ function _spaceWindowId(win) {
return null;
}
function _windowAtPointer() {
if (_lastPointerClientX == null || _lastPointerClientY == null) return null;
const x = _lastPointerClientX;
const y = _lastPointerClientY;
const candidates = [
...document.querySelectorAll('.modal:not(.hidden):not(.modal-minimized) .modal-content'),
...document.querySelectorAll('.doc-editor-pane'),
].filter(el => {
if (!document.contains(el)) return false;
const r = el.getBoundingClientRect();
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
});
if (!candidates.length) return null;
return candidates.reduce((top, el) => {
const mz = parseInt(getComputedStyle(el.closest('.modal') || el).zIndex, 10) || 0;
const tz = parseInt(getComputedStyle(top.closest('.modal') || top).zIndex, 10) || 0;
return mz >= tz ? el : top;
});
}
function _containsPointer(el) {
if (!el || _lastPointerClientX == null || _lastPointerClientY == null) return false;
const r = el.getBoundingClientRect();
return _lastPointerClientX >= r.left && _lastPointerClientX <= r.right
&& _lastPointerClientY >= r.top && _lastPointerClientY <= r.bottom;
}
function _closeHoveredWindow() {
let win = _windowAtPointer();
if (!win) {
try {
const underPointer = document.elementFromPoint(_lastPointerClientX, _lastPointerClientY);
win = underPointer?.closest?.('.modal:not(.hidden):not(.modal-minimized) .modal-content, .doc-editor-pane') || null;
} catch {}
}
if (!win) win = hoveredToggleWindow;
if (!win || !document.contains(win)) return false;
const modalForWin = win.closest?.('.modal[id]');
if (modalForWin?.id === 'email-lib-modal') {
const closeBtn = document.getElementById('email-lib-close') || modalForWin.querySelector('.close-btn');
if (closeBtn) {
try { closeBtn.click(); return true; } catch {}
}
try { modalForWin.remove(); return true; } catch {}
}
const id = _spaceWindowId(win);
if (id && Modals.isRegistered(id)) {
Modals.close(id);
return true;
}
const modal = _visibleModalForSpace(win);
if (!modal) return false;
const closeBtn = modal.querySelector('.close-btn, .modal-close, .modal-close-btn, [data-action="close"]');
if (closeBtn) {
try { closeBtn.click(); return true; } catch {}
}
try { modal.classList.add('hidden'); return true; } catch {}
return false;
}
function _spaceIsBlocked(e, surface) {
const target = _targetEl(e.target);
if (!target) return false;
@@ -103,6 +167,8 @@ function _initHoverCardSpaceToggle() {
if (document._odysseusHoverCardSpaceToggle) return;
document._odysseusHoverCardSpaceToggle = true;
document.addEventListener('pointerover', (e) => {
_lastPointerClientX = e.clientX;
_lastPointerClientY = e.clientY;
const chip = e.target?.closest?.('.minimized-dock-chip[data-modal-id]');
if (chip) hoveredDockChip = chip;
const card = e.target?.closest?.(SPACE_CARD_SELECTOR);
@@ -110,6 +176,10 @@ function _initHoverCardSpaceToggle() {
const win = e.target?.closest?.('.modal:not(.hidden):not(.modal-minimized) .modal-content, .doc-editor-pane');
if (win) hoveredToggleWindow = win;
}, true);
document.addEventListener('pointermove', (e) => {
_lastPointerClientX = e.clientX;
_lastPointerClientY = e.clientY;
}, true);
document.addEventListener('pointerout', (e) => {
const next = e.relatedTarget;
if (hoveredDockChip && (!next || !hoveredDockChip.contains(next))) hoveredDockChip = null;
@@ -252,6 +322,12 @@ export function showToast(msg, durationOrOpts) {
icon.className = 'toast-checkmark';
icon.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
toastEl.appendChild(icon);
} else if (leadingIcon === 'spinner') {
const wp = spinnerModule.createWhirlpool(14);
const icon = wp.element;
icon.classList.add('toast-whirlpool');
icon.style.cssText = 'width:14px;height:14px;margin:0 8px 0 0;display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;';
toastEl.appendChild(icon);
}
textSpan.textContent = msg;
toastEl.appendChild(textSpan);
@@ -503,8 +579,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>' +
@@ -524,6 +600,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 = '';
@@ -534,6 +612,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); }
@@ -550,6 +629,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();
}
}
@@ -580,7 +666,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>' +
@@ -609,6 +695,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 = '';
@@ -620,6 +708,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()); }
@@ -631,6 +720,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) {
@@ -694,7 +790,7 @@ function _initScrollDismiss() {
if (chatHistory) {
chatHistory.addEventListener('scroll', () => {
chatHistory.querySelectorAll('.dropdown.show').forEach(d => d.classList.remove('show'));
document.querySelectorAll('.ctx-popup').forEach(p => p.remove());
document.querySelectorAll('.ctx-popup').forEach(dismissOrRemove);
}, { passive: true });
} else {
// Retry once if element doesn't exist yet
@@ -747,7 +843,8 @@ const uiModule = {
el,
esc,
isTouchInsideModal,
emptyStateIcon
emptyStateIcon,
registerMenuDismiss
};
export default uiModule;
@@ -808,7 +905,9 @@ if ('ontouchstart' in window) {
'.email-card-dropdown, .hwfit-cached-dropdown, .cookbook-saved-menu, .cookbook-dep-menu'
).forEach(d => {
if (d._anchor) d._anchor.classList.remove('cookbook-menu-active', 'reader-more-active');
d.remove();
// Registered menus tear down through their own dismiss (releasing the
// Escape-stack entry); unregistered ones (email/dep) just get removed.
dismissOrRemove(d);
});
}
@@ -1114,8 +1213,6 @@ if (!window._odyEscExpandGuard) {
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape' || e.defaultPrevented) return;
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
// Find the single thing to close, in priority order. The first hit wins.
// Important: if a thinking block is open we MUST handle it ourselves and
@@ -1123,6 +1220,21 @@ if (!window._odyEscExpandGuard) {
// (the live-stream chat rebuilds thinking DOM mid-stream so the header
// can briefly be absent). Toggling the `expanded` class directly is the
// fallback so ESC never bypasses the thinking block to hit a modal.
if (_closeHoveredWindow()) {
e.stopImmediatePropagation(); e.preventDefault();
return;
}
// Transient ad-hoc menus (dropdowns / context popups) live outside the
// .modal system and register a dismiss callback in escMenuStack. Close the
// most-recently-opened one first — so a menu opened over a modal dismisses
// before the modal — and do it BEFORE the text-input guard below, since a
// menu may own the focused input (e.g. a search dropdown).
if (dismissTopMenu()) {
e.stopImmediatePropagation(); e.preventDefault();
return;
}
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
const expanded = document.querySelector('.doclib-card-expanded');
const think = document.querySelector('.thinking-content.expanded');
if (expanded) {
+13
View File
@@ -0,0 +1,13 @@
// Pure (browser-free) English ordinal suffix, e.g. 1 -> "st", 21 -> "st",
// 22 -> "nd", 23 -> "rd", 11/12/13 -> "th". Extracted so it can be unit-tested.
export function ordinalSuffix(n) {
const a = Math.abs(Math.trunc(Number(n) || 0));
const mod100 = a % 100;
if (mod100 >= 11 && mod100 <= 13) return 'th';
switch (a % 10) {
case 1: return 'st';
case 2: return 'nd';
case 3: return 'rd';
default: return 'th';
}
}
+39 -5
View File
@@ -37,6 +37,7 @@
// Default true when onEnterFullscreen is supplied.
import { makeEdgeDockController } from './modalSnap.js';
import { makeWindowResizable } from './windowResize.js';
const SNAP_PX = 6; // cursor distance from top edge for fullscreen snap
const UNSNAP_PX = 24; // cursor distance from top before fullscreen exits
@@ -62,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;
@@ -70,12 +72,32 @@ export function makeWindowDraggable(modal, options = {}) {
header.style.cursor = 'move';
header.style.userSelect = 'none';
// Edge/corner resize. Every draggable window also becomes resizable — the
// same gesture a native desktop window uses (grab an edge or corner, drag).
// Skipped on mobile (windows are full-screen sheets there) and while the
// window is fullscreen-snapped or docked. Wired here so all ~12 callsites
// get it without per-file changes.
if (options.enableResize !== false) {
const _dockClasses = ['modal-right-docked', 'modal-left-docked'];
makeWindowResizable(content, {
modal,
mobileSkip,
minWidth: options.minWidth,
minHeight: options.minHeight,
isLocked: () => (fsClass && modal && modal.classList.contains(fsClass))
|| (modal && _dockClasses.some((c) => modal.classList.contains(c))),
storageKey: options.resizeStorageKey
|| (modal && modal.id ? 'winsize-' + modal.id
: (content.id ? 'winsize-' + content.id : null)),
});
}
const rightDock = enableDock ? makeEdgeDockController(modal, 'right') : null;
// Left dock is opt-in (enableLeftDock). For most windows it's off — the
// sidebar lives on the left, so a left dock collides with it. The email
// window enables it so you can park the message on the left and read it
// while replying in the document on the right.
const leftDock = (enableDock && options.enableLeftDock) ? makeEdgeDockController(modal, 'left') : null;
// Left dock is enabled by default too. modalSnap collapses the wide sidebar
// and anchors the panel beside the icon rail, so it no longer collides with
// the navigation. Callers can still pass enableLeftDock:false for a special
// modal that should only dock right.
const leftDock = (enableDock && options.enableLeftDock !== false) ? makeEdgeDockController(modal, 'left') : null;
// Per-drag state, reset on mousedown.
let dragging = false;
@@ -126,7 +148,18 @@ export function makeWindowDraggable(modal, options = {}) {
const _startDrag = (cx, cy) => {
dragging = true;
if (modal) modal.classList.add('modal-dragging');
// Cancel any in-flight open animation so we don't pin a mid-animation
// rect and then jump once the animation settles.
try {
content.getAnimations()
.filter(a => a.playState !== 'finished')
.forEach(a => a.cancel());
} catch (_) {}
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
@@ -216,6 +249,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) {
+233
View File
@@ -0,0 +1,233 @@
// Shared window-resize helper. Companion to makeWindowDraggable: gives every
// draggable tool window (Library, Notes, Tasks, Calendar, Gallery, Email,
// Cookbook, Memory, Settings, Theme, Compare, Research, Sessions) edge- and
// corner-resize, the same way a native desktop window resizes — grab any of
// the four edges or four corners and drag.
//
// Why edge-proximity detection instead of injected handle elements:
// The windows differ structurally. `.modal-content` scrolls its own body
// (overflow:auto) while `.notes-pane` keeps overflow:hidden and scrolls an
// inner element. Absolutely-positioned handle children would scroll away
// with the content in the first case. Detecting pointer proximity to the
// window's border works uniformly regardless of the overflow model and
// matches the user's mental model ("drag the edges or corners").
//
// API:
// makeWindowResizable(content, {
// modal, // optional wrapping .modal (for id-based size persistence)
// mobileSkip, // viewport width at/below which resize is disabled (sheets)
// isLocked, // () => bool — skip while fullscreen / docked
// minWidth, minHeight,
// storageKey, // localStorage key to persist {w,h}; null disables
// onResizeEnd, // ({rect}) => void
// })
const EDGE = 7; // px proximity to a border that arms a resize grip
const MIN_W = 320; // smallest a window may be dragged to
const MIN_H = 200;
// Controls that must keep their own click/drag behaviour even when they sit
// within EDGE px of the window border (close buttons, sliders, inputs, links).
const INTERACTIVE = 'button, input, select, textarea, a, [contenteditable=""], [contenteditable="true"]';
export function makeWindowResizable(content, options = {}) {
if (!content) return;
const modal = options.modal || null;
const mobileSkip = (typeof options.mobileSkip === 'number') ? options.mobileSkip : 768;
const minW = options.minWidth || MIN_W;
const minH = options.minHeight || MIN_H;
const isLocked = options.isLocked || (() => false);
const onResizeEnd = options.onResizeEnd || null;
const storageKey = options.storageKey || null;
const _skip = () => (mobileSkip > 0 && window.innerWidth <= mobileSkip) || isLocked();
// Which borders is (cx,cy) within EDGE px of? Only counts when the pointer
// is also within the window's span on the perpendicular axis, so the corners
// resolve to true diagonal grips rather than the whole side.
function edgesAt(cx, cy) {
const r = content.getBoundingClientRect();
const within = (cy >= r.top - EDGE && cy <= r.bottom + EDGE && cx >= r.left - EDGE && cx <= r.right + EDGE);
if (!within) return { l: false, r: false, t: false, b: false, rect: r };
const onY = cy >= r.top - EDGE && cy <= r.bottom + EDGE;
const onX = cx >= r.left - EDGE && cx <= r.right + EDGE;
return {
l: Math.abs(cx - r.left) <= EDGE && onY,
r: Math.abs(cx - r.right) <= EDGE && onY,
t: Math.abs(cy - r.top) <= EDGE && onX,
b: Math.abs(cy - r.bottom) <= EDGE && onX,
rect: r,
};
}
function cursorFor(e) {
if ((e.l && e.t) || (e.r && e.b)) return 'nwse-resize';
if ((e.r && e.t) || (e.l && e.b)) return 'nesw-resize';
if (e.l || e.r) return 'ew-resize';
if (e.t || e.b) return 'ns-resize';
return '';
}
let hoverCursor = false;
function clearHoverCursor() {
if (hoverCursor) { content.style.cursor = ''; hoverCursor = false; }
}
function onHover(ev) {
if (resizing) return;
if (_skip()) { clearHoverCursor(); return; }
if (ev.target && ev.target.closest && ev.target.closest(INTERACTIVE)) { clearHoverCursor(); return; }
const c = cursorFor(edgesAt(ev.clientX, ev.clientY));
if (c) { content.style.cursor = c; hoverCursor = true; }
else clearHoverCursor();
}
let resizing = false;
let active = null;
let startRect = null, startX = 0, startY = 0;
function begin(cx, cy, edges) {
resizing = true;
active = edges;
// Kill the modal/pane open-animation (a scale transform that runs for the
// first ~200-250ms) BEFORE measuring. Done as a permanent inline style
// rather than a toggled class on purpose: a class that flips animation
// off→on would re-trigger the scale-in on mouseup, mis-measuring the final
// size and visibly popping the window. The open animation is a one-shot,
// so killing it for this instance is harmless (it replays on next open).
content.style.animation = 'none';
content.classList.add('window-resizing');
const r = content.getBoundingClientRect();
startRect = { left: r.left, top: r.top, width: r.width, height: r.height };
startX = cx; startY = cy;
// Pin to fixed with explicit box, same as the drag helper does, so the
// centering transform / margin stops fighting the new dimensions. Drop the
// max-width/height caps (e.g. 85vh) so the window can actually grow.
content.style.position = 'fixed';
content.style.margin = '0';
content.style.transform = 'none';
content.style.left = r.left + 'px';
content.style.top = r.top + 'px';
content.style.width = r.width + 'px';
content.style.height = r.height + 'px';
content.style.maxWidth = 'none';
content.style.maxHeight = 'none';
document.body.classList.add('window-resizing-active');
document.body.style.cursor = cursorFor(edges);
}
function move(cx, cy) {
if (!resizing) return;
const dx = cx - startX, dy = cy - startY;
let { left, top, width, height } = startRect;
const vw = window.innerWidth, vh = window.innerHeight;
if (active.r) width = startRect.width + dx;
if (active.b) height = startRect.height + dy;
if (active.l) { width = startRect.width - dx; left = startRect.left + dx; }
if (active.t) { height = startRect.height - dy; top = startRect.top + dy; }
// Min-size clamps — keep the opposite edge anchored when pulling from
// the left/top so the window doesn't jump.
if (width < minW) { if (active.l) left = startRect.left + (startRect.width - minW); width = minW; }
if (height < minH) { if (active.t) top = startRect.top + (startRect.height - minH); height = minH; }
// Keep the window on-screen and never larger than the viewport.
if (active.l && left < 0) { width += left; left = 0; }
if (active.t && top < 0) { height += top; top = 0; }
if (left + width > vw) width = Math.max(minW, vw - left);
if (top + height > vh) height = Math.max(minH, vh - top);
content.style.left = left + 'px';
content.style.top = top + 'px';
content.style.width = width + 'px';
content.style.height = height + 'px';
}
function end() {
if (!resizing) return;
resizing = false;
content.classList.remove('window-resizing');
document.body.classList.remove('window-resizing-active');
document.body.style.cursor = '';
clearHoverCursor();
const r = content.getBoundingClientRect();
if (storageKey) {
try { localStorage.setItem(storageKey, JSON.stringify({ w: Math.round(r.width), h: Math.round(r.height) })); } catch (_) {}
}
if (onResizeEnd) { try { onResizeEnd({ rect: r }); } catch (_) {} }
}
function armFrom(target, cx, cy) {
if (_skip()) return false;
if (target && target.closest && target.closest(INTERACTIVE)) return false;
const edges = edgesAt(cx, cy);
if (!(edges.l || edges.r || edges.t || edges.b)) return false;
begin(cx, cy, edges);
return true;
}
// Capture phase: pre-empt the header's drag listener (which lives on a
// descendant and fires in the bubble phase) when the grab lands on a border.
content.addEventListener('mousedown', (ev) => {
if (ev.button !== 0) return;
if (!armFrom(ev.target, ev.clientX, ev.clientY)) return;
ev.preventDefault();
ev.stopPropagation();
const mu = () => {
end();
document.removeEventListener('mousemove', mm);
document.removeEventListener('mouseup', mu);
};
// Self-heal a missed mouseup (released outside the window, dropped event,
// window blur): a move with no buttons pressed means the drag is over —
// finish instead of running away on every subsequent mousemove.
const mm = (e) => {
if (e.buttons === 0) { mu(); return; }
move(e.clientX, e.clientY);
};
document.addEventListener('mousemove', mm);
document.addEventListener('mouseup', mu);
}, true);
content.addEventListener('mousemove', onHover);
content.addEventListener('mouseleave', clearHoverCursor);
content.addEventListener('touchstart', (ev) => {
const t = ev.touches[0];
if (!t) return;
if (!armFrom(ev.target, t.clientX, t.clientY)) return;
ev.preventDefault();
ev.stopPropagation();
const tm = (e) => { const tt = e.touches[0]; if (tt) move(tt.clientX, tt.clientY); };
const te = () => {
end();
document.removeEventListener('touchmove', tm);
document.removeEventListener('touchend', te);
document.removeEventListener('touchcancel', te);
};
document.addEventListener('touchmove', tm, { passive: false });
document.addEventListener('touchend', te);
document.addEventListener('touchcancel', te);
}, true);
// Restore a previously chosen size on (re)open. Applying width/height inline
// while the window is still centered by its overlay keeps it centered at the
// new size; once dragged/resized it pins to fixed as usual.
//
// Deferred one frame on purpose: some windows (e.g. Notes) snap to an edge
// dock or fullscreen synchronously right AFTER this helper is wired. Waiting a
// frame lets that settle so we can re-check _skip() and NOT stretch a
// docked/fullscreen window to a stale windowed size. The open animation masks
// the one-frame delay, so there is no visible jump.
if (storageKey) {
requestAnimationFrame(() => {
if (_skip() || !content.isConnected) return;
try {
const saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
if (saved && saved.w && saved.h) {
const w = Math.max(minW, Math.min(saved.w, window.innerWidth));
const h = Math.max(minH, Math.min(saved.h, window.innerHeight));
content.style.width = w + 'px';
content.style.height = h + 'px';
content.style.maxWidth = 'none';
content.style.maxHeight = 'none';
}
} catch (_) {}
});
}
}
+160
View File
@@ -0,0 +1,160 @@
// static/js/workspace.js
//
// Workspace picker: browse server directories in a draggable modal, choose a
// folder, and show it as a removable pill in the chat input bar. While set, the
// chat request sends `workspace` so the agent's file/shell tools are confined
// to that folder (see routes/chat_routes.py + src/tool_execution.py).
import Storage, { KEYS } from './storage.js';
import uiModule from './ui.js';
import { makeWindowDraggable } from './windowDrag.js';
const API_BASE = window.location.origin;
// Same folder glyph as the overflow menu item + pill (not an emoji).
const _FOLDER_SVG = '<svg class="workspace-row-icon" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>';
let _modal = null;
let _curPath = '';
export function getWorkspace() {
return Storage.get(KEYS.WORKSPACE, '') || '';
}
function _basename(p) {
if (!p) return '';
// Handle both POSIX (/) and Windows (\) separators.
const parts = p.replace(/[\\/]+$/, '').split(/[\\/]/);
return parts[parts.length - 1] || p;
}
export function syncWorkspaceIndicator(path) {
const pill = document.getElementById('workspace-indicator-btn');
const name = document.getElementById('workspace-indicator-name');
const overflow = document.getElementById('overflow-workspace-btn');
if (pill) {
pill.style.display = path ? '' : 'none';
pill.classList.toggle('active', !!path);
if (path) pill.title = `Workspace: ${path} — click to clear`;
}
if (name) name.textContent = path ? _basename(path) : '';
if (overflow) overflow.classList.toggle('active', !!path);
// Recompute the "+" overflow dot (app.js owns updatePlusDot via this event).
try { document.dispatchEvent(new CustomEvent('overflow-state-change')); } catch (_) {}
}
export function setWorkspace(path) {
if (path) Storage.set(KEYS.WORKSPACE, path);
else Storage.remove(KEYS.WORKSPACE);
syncWorkspaceIndicator(path || '');
}
export function clearWorkspace() {
setWorkspace('');
if (uiModule && uiModule.showToast) uiModule.showToast('Workspace cleared');
}
async function _load(path) {
const url = `${API_BASE}/api/workspace/browse${path ? `?path=${encodeURIComponent(path)}` : ''}`;
const res = await fetch(url, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`browse failed: ${res.status}`);
return res.json();
}
function _render(data) {
_curPath = data.path;
const body = _modal.querySelector('#workspace-body');
const pathEl = _modal.querySelector('#workspace-cur-path');
if (pathEl) {
// Reflect the resolved (realpath) location back into the editable field.
pathEl.value = data.path;
pathEl.title = data.path;
}
let rows = '';
if (data.parent) {
rows += `<div class="workspace-row workspace-up" data-path="${encodeURIComponent(data.parent)}">↑ ..</div>`;
}
for (const d of data.dirs) {
// Backend supplies the full child path (os.path.join → cross-platform).
rows += `<div class="workspace-row" data-path="${encodeURIComponent(d.path)}">${_FOLDER_SVG}<span>${uiModule.esc(d.name)}</span></div>`;
}
if (!data.dirs.length && !data.parent) rows = '<div class="workspace-empty">No subfolders</div>';
body.innerHTML = rows || '<div class="workspace-empty">No subfolders</div>';
body.querySelectorAll('.workspace-row').forEach((row) => {
row.addEventListener('click', () => _navigate(decodeURIComponent(row.dataset.path)));
});
}
async function _navigate(path) {
try {
_render(await _load(path));
} catch (e) {
if (uiModule && uiModule.showError) uiModule.showError('Could not open folder');
}
}
function _getModal() {
if (_modal) return _modal;
_modal = document.createElement('div');
_modal.id = 'workspace-modal';
_modal.className = 'modal';
_modal.style.display = 'none';
_modal.innerHTML = `
<div class="modal-content">
<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="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>Select workspace</h4>
<button class="close-btn" id="workspace-close" aria-label="Close"></button>
</div>
<input type="text" class="styled-prompt-input workspace-cur" id="workspace-cur-path"
spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off"
placeholder="Type or paste a folder path, then press Enter" />
<div class="modal-body workspace-body" id="workspace-body"></div>
<div class="modal-footer workspace-footer">
<button type="button" class="confirm-btn confirm-btn-secondary" id="workspace-cancel">Cancel</button>
<button type="button" class="confirm-btn confirm-btn-primary" id="workspace-use">Use this folder</button>
</div>
</div>`;
document.body.appendChild(_modal);
_modal.querySelector('#workspace-close').addEventListener('click', closeWorkspaceBrowser);
_modal.querySelector('#workspace-cancel').addEventListener('click', closeWorkspaceBrowser);
// Editable path bar: Enter navigates to a typed/pasted folder.
_modal.querySelector('#workspace-cur-path').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const v = e.target.value.trim();
if (v) _navigate(v);
}
});
_modal.querySelector('#workspace-use').addEventListener('click', () => {
setWorkspace(_curPath);
if (uiModule && uiModule.showToast) uiModule.showToast(`Workspace set: ${_basename(_curPath)}`);
closeWorkspaceBrowser();
});
const content = _modal.querySelector('.modal-content');
const header = _modal.querySelector('.modal-header');
if (content && header) makeWindowDraggable(_modal, { content, header });
return _modal;
}
export async function openWorkspaceBrowser() {
const modal = _getModal();
modal.style.display = 'flex';
try {
_render(await _load(getWorkspace() || ''));
} catch (e) {
if (uiModule && uiModule.showError) uiModule.showError('Could not browse folders');
}
}
export function closeWorkspaceBrowser() {
if (_modal) _modal.style.display = 'none';
}
export function initWorkspace() {
// Restore persisted workspace into the pill on load.
syncWorkspaceIndicator(getWorkspace());
const overflow = document.getElementById('overflow-workspace-btn');
if (overflow) overflow.addEventListener('click', openWorkspaceBrowser);
const pill = document.getElementById('workspace-indicator-btn');
if (pill) pill.addEventListener('click', clearWorkspace);
}
export default { initWorkspace, openWorkspaceBrowser, getWorkspace, setWorkspace, clearWorkspace, syncWorkspaceIndicator };
+38 -13
View File
@@ -150,16 +150,31 @@
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);
outline-offset: 2px;
}
button {
width: 100%;
/* Asymmetric vertical padding nudges the label 1px down while keeping
the button's total height the same as 0.7rem all-around. */
padding: calc(0.7rem + 1px) 0.7rem calc(0.7rem - 1px);
border: none; border-radius: 6px;
background: var(--red); color: #fff; font-size: 1rem; cursor: pointer;
/* Darken the brand red slightly so #fff label text clears the WCAG AA
4.5:1 contrast threshold (plain --red #e06c75 only reaches ~3.2:1). */
background: color-mix(in srgb, var(--red) 78%, #000); color: #fff; font-size: 1rem; cursor: pointer;
font-weight: 600; font-family: 'Fira Code', monospace;
}
button:hover { background: color-mix(in srgb, var(--red) 85%, black); }
button:hover { background: color-mix(in srgb, var(--red) 66%, black); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.error { color: #e55; font-size: 0.85rem; margin-bottom: 0.75rem; display: none; }
.toggle { text-align: center; margin-top: calc(1rem + 4px); font-size: 0.85rem; color: color-mix(in srgb, var(--fg) 50%, transparent); }
@@ -185,7 +200,17 @@
align-items: center; justify-content: center;
font-size: 0; margin: 0; color: transparent;
}
.remember-toggle .remember-check { display: none; }
/* Visually hide the native checkbox but keep it in the accessibility tree
and keyboard-focusable (display:none would drop it from tab order). It
overlays the dot so a click/tap still toggles it. */
.remember-toggle .remember-check {
position: absolute; top: 0; left: 0;
width: 100%; height: 100%; margin: 0;
opacity: 0; cursor: pointer;
}
.remember-toggle .remember-check:focus-visible + .remember-dot {
outline: 2px solid var(--red); outline-offset: 2px;
}
.remember-toggle .remember-dot {
display: block; width: 10px; height: 10px; min-width: 10px; min-height: 10px;
border-radius: 50%;
@@ -223,21 +248,21 @@
</style>
</head>
<body>
<div class="card">
<div class="logo">
<svg class="logo-boat" viewBox="0 0 32 32"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity="0.6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg><span>Odysseus</span>
</div>
<main class="card">
<h1 class="logo">
<svg class="logo-boat" viewBox="0 0 32 32" aria-hidden="true" focusable="false"><path d="M16 4L16 22L6 22Z" fill="currentColor"/><path d="M16 8L16 22L24 22Z" fill="currentColor" opacity="0.6"/><path d="M4 24Q10 20 16 24Q22 28 28 24" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round"/></svg><span>Odysseus</span>
</h1>
<p class="setup-note" id="setupNote" style="display:none"></p>
<div class="error" id="error"></div>
<div class="error" id="error" role="alert" aria-live="assertive"></div>
<form id="authForm" autocomplete="on">
<label for="username">Username</label>
<div class="pw-wrapper">
<input id="username" name="username" type="text" required autofocus autocomplete="username">
<label class="remember-toggle" id="rememberToggle" title="Remember me">
<input type="checkbox" class="remember-check" id="remember" checked>
<span class="remember-dot"></span>
<input type="checkbox" class="remember-check" id="remember" checked aria-label="Remember me">
<span class="remember-dot" aria-hidden="true"></span>
</label>
</div>
@@ -266,9 +291,9 @@
<span id="toggleText">Don't have an account? </span>
<a id="toggleLink" href="#">Sign up</a>
</div>
</div>
</main>
<div class="version-label" id="version-label"></div>
<footer class="version-label" id="version-label"></footer>
<script nonce="{{CSP_NONCE}}">
(async () => {
@@ -468,7 +493,7 @@
form._totpMode = true;
const totpWrap = document.createElement('div');
totpWrap.style.cssText = 'margin-top:12px;';
totpWrap.innerHTML = '<label style="font-size:0.85em;opacity:0.7;display:block;margin-bottom:4px;">2FA Code</label><input type="text" id="totp-input" placeholder="Enter 6-digit code" autocomplete="one-time-code" inputmode="numeric" maxlength="8" style="width:100%;padding:10px 12px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:8px;font-size:14px;box-sizing:border-box;text-align:center;letter-spacing:4px;">';
totpWrap.innerHTML = '<label for="totp-input" style="font-size:0.85em;opacity:0.7;display:block;margin-bottom:4px;">2FA Code</label><input type="text" id="totp-input" placeholder="Enter 6-digit code" aria-label="Two-factor authentication code" autocomplete="one-time-code" inputmode="numeric" maxlength="8" style="width:100%;padding:10px 12px;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:8px;font-size:14px;box-sizing:border-box;text-align:center;letter-spacing:4px;">';
const formEl = submitBtn.parentElement;
formEl.insertBefore(totpWrap, submitBtn);
const totpInput = document.getElementById('totp-input');
+2189 -109
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v326';
const CACHE_NAME = 'odysseus-v327';
// Core shell precached on install so repeat opens are instant without any
// network wait. Keep this list in sync with the <script type="module"> tags