mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-13 12:38:02 +00:00
Merge remote-tracking branch 'upstream/dev' into fix/no-scroll-snapping
This commit is contained in:
+266
-129
@@ -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
@@ -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 — 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 — 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> — 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">✖</button>
|
||||
<button class="close-btn" id="close-theme-popup" aria-label="Close theme">✖</button>
|
||||
</div>
|
||||
<!-- Theme tabs -->
|
||||
<div class="admin-tabs" id="theme-tabs">
|
||||
@@ -464,12 +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">↺</button></div>
|
||||
<div class="color-row"><label>Text</label><input type="color" id="clr-fg"><button class="color-reset-btn" data-reset="fg" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Panel</label><input type="color" id="clr-panel"><button class="color-reset-btn" data-reset="panel" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Sidebar</label><input type="color" id="adv-sidebarBg"><button class="color-reset-btn" data-reset-adv="sidebarBg" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Border</label><input type="color" id="clr-border"><button class="color-reset-btn" data-reset="border" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Accent</label><input type="color" id="clr-red"><button class="color-reset-btn" data-reset="red" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Background</label><input type="color" id="clr-bg"><button class="color-reset-btn" data-reset="bg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Text</label><input type="color" id="clr-fg"><button class="color-reset-btn" data-reset="fg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Panel</label><input type="color" id="clr-panel"><button class="color-reset-btn" data-reset="panel" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Sidebar</label><input type="color" id="adv-sidebarBg"><button class="color-reset-btn" data-reset-adv="sidebarBg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Border</label><input type="color" id="clr-border"><button class="color-reset-btn" data-reset="border" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Accent</label><input type="color" id="clr-red"><button class="color-reset-btn" data-reset="red" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-adv-toggle" id="theme-adv-toggle">
|
||||
@@ -479,38 +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">↺</button></div>
|
||||
<div class="color-row"><label>AI Chat Bubble</label><input type="color" id="adv-aiBubbleBg"><button class="color-reset-btn" data-reset-adv="aiBubbleBg" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Border Chat Bubble</label><input type="color" id="adv-bubbleBorder"><button class="color-reset-btn" data-reset-adv="bubbleBorder" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>User Chat Bubble</label><input type="color" id="adv-userBubbleBg"><button class="color-reset-btn" data-reset-adv="userBubbleBg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>AI Chat Bubble</label><input type="color" id="adv-aiBubbleBg"><button class="color-reset-btn" data-reset-adv="aiBubbleBg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Border Chat Bubble</label><input type="color" id="adv-bubbleBorder"><button class="color-reset-btn" data-reset-adv="bubbleBorder" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-adv-group">
|
||||
<div class="theme-adv-group-label">Sidebar</div>
|
||||
<div class="theme-custom">
|
||||
<div class="color-row"><label>Odysseus Logo</label><input type="color" id="adv-brandColor"><button class="color-reset-btn" data-reset-adv="brandColor" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label title="Hamburger menu"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></label><input type="color" id="adv-hamburgerColor"><button class="color-reset-btn" data-reset-adv="hamburgerColor" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Odysseus Logo</label><input type="color" id="adv-brandColor"><button class="color-reset-btn" data-reset-adv="brandColor" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label title="Hamburger menu"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" style="vertical-align:-2px;"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></label><input type="color" id="adv-hamburgerColor"><button class="color-reset-btn" data-reset-adv="hamburgerColor" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-adv-group">
|
||||
<div class="theme-adv-group-label">Chat Input / Prompt Area</div>
|
||||
<div class="theme-custom">
|
||||
<div class="color-row"><label>Input Bg</label><input type="color" id="adv-inputBg"><button class="color-reset-btn" data-reset-adv="inputBg" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Input Border</label><input type="color" id="adv-inputBorder"><button class="color-reset-btn" data-reset-adv="inputBorder" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Send Btn</label><input type="color" id="adv-sendBtnBg"><button class="color-reset-btn" data-reset-adv="sendBtnBg" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Send Hover</label><input type="color" id="adv-sendBtnHover"><button class="color-reset-btn" data-reset-adv="sendBtnHover" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Input Bg</label><input type="color" id="adv-inputBg"><button class="color-reset-btn" data-reset-adv="inputBg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Input Border</label><input type="color" id="adv-inputBorder"><button class="color-reset-btn" data-reset-adv="inputBorder" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Send Btn</label><input type="color" id="adv-sendBtnBg"><button class="color-reset-btn" data-reset-adv="sendBtnBg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Send Hover</label><input type="color" id="adv-sendBtnHover"><button class="color-reset-btn" data-reset-adv="sendBtnHover" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-adv-group">
|
||||
<div class="theme-adv-group-label">Code Blocks</div>
|
||||
<div class="theme-custom">
|
||||
<div class="color-row"><label>Code Bg</label><input type="color" id="adv-codeBg"><button class="color-reset-btn" data-reset-adv="codeBg" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Code Text</label><input type="color" id="adv-codeFg"><button class="color-reset-btn" data-reset-adv="codeFg" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Code Bg</label><input type="color" id="adv-codeBg"><button class="color-reset-btn" data-reset-adv="codeBg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
<div class="color-row"><label>Code Text</label><input type="color" id="adv-codeFg"><button class="color-reset-btn" data-reset-adv="codeFg" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-adv-group">
|
||||
<div class="theme-adv-group-label">Controls</div>
|
||||
<div class="theme-custom">
|
||||
<div class="color-row"><label>Toggle On</label><input type="color" id="adv-toggleActive"><button class="color-reset-btn" data-reset-adv="toggleActive" title="Reset this color">↺</button></div>
|
||||
<div class="color-row"><label>Toggle On</label><input type="color" id="adv-toggleActive"><button class="color-reset-btn" data-reset-adv="toggleActive" title="Reset this color" aria-label="Reset color">↺</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-adv-group">
|
||||
@@ -559,7 +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">↺ 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">⚙</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 & system prompt</span></span>
|
||||
<span class="vis-label">Personas <span class="vis-hint">Persona picker & system prompt</span></span>
|
||||
<input type="checkbox" checked data-ui-key="preset-mini-btn"><span class="vis-switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -74,6 +74,42 @@ export function _calBgCss(c, fallback) {
|
||||
return c || fallback || 'var(--accent)';
|
||||
}
|
||||
|
||||
function _hexToRgb(c) {
|
||||
if (typeof c !== 'string') return null;
|
||||
const m = c.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
||||
if (!m) return null;
|
||||
const hex = m[1].length === 3
|
||||
? m[1].split('').map(ch => ch + ch).join('')
|
||||
: m[1];
|
||||
return {
|
||||
r: parseInt(hex.slice(0, 2), 16),
|
||||
g: parseInt(hex.slice(2, 4), 16),
|
||||
b: parseInt(hex.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function _relativeLuminance({ r, g, b }) {
|
||||
return [r, g, b].map(v => {
|
||||
const c = v / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
}).reduce((sum, c, i) => sum + c * [0.2126, 0.7152, 0.0722][i], 0);
|
||||
}
|
||||
|
||||
function _contrastRatio(a, b) {
|
||||
const light = Math.max(a, b);
|
||||
const dark = Math.min(a, b);
|
||||
return (light + 0.05) / (dark + 0.05);
|
||||
}
|
||||
|
||||
export function _calReadableTextColor(bg) {
|
||||
const rgb = _hexToRgb(bg);
|
||||
if (!rgb) return 'var(--fg)';
|
||||
const lum = _relativeLuminance(rgb);
|
||||
const white = _contrastRatio(lum, 1);
|
||||
const ink = _contrastRatio(lum, 0.006);
|
||||
return ink >= white ? '#111820' : '#ffffff';
|
||||
}
|
||||
|
||||
// ── date helpers ──
|
||||
|
||||
// `YYYY-MM-DD` string from a Date.
|
||||
@@ -82,13 +118,17 @@ export function _ds(d) {
|
||||
}
|
||||
|
||||
export function _addDays(dateStr, n) {
|
||||
if (typeof dateStr !== 'string' || !dateStr) return '';
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
if (isNaN(d)) return '';
|
||||
d.setDate(d.getDate() + n);
|
||||
return _ds(d);
|
||||
}
|
||||
|
||||
export function _shiftDT(iso, days) {
|
||||
if (typeof iso !== 'string' || !iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return '';
|
||||
d.setDate(d.getDate() + days);
|
||||
return _ds(d) + (iso.length > 10 ? 'T' + iso.slice(11) : '');
|
||||
}
|
||||
@@ -111,7 +151,7 @@ export function _tzOffset() {
|
||||
// bucket by the USER's local date. Without this an event at
|
||||
// "2026-05-13T22:00:00Z" (07:00 May 14 JST) would render on May 13.
|
||||
export function _localDateOf(isoStr) {
|
||||
if (!isoStr) return '';
|
||||
if (typeof isoStr !== 'string' || !isoStr) return '';
|
||||
if (isoStr.length === 10) return isoStr;
|
||||
if (/[Zz]$|[+\-]\d{2}:?\d{2}$/.test(isoStr)) {
|
||||
const d = new Date(isoStr);
|
||||
|
||||
+7
-1
@@ -8,7 +8,13 @@
|
||||
let _enabled = true;
|
||||
let _observer = null;
|
||||
const PREF_KEY = 'odysseus-sensitive-blur';
|
||||
const _prefEnabled = () => localStorage.getItem(PREF_KEY) === 'on';
|
||||
export const _prefEnabled = () => {
|
||||
try {
|
||||
return localStorage.getItem(PREF_KEY) === 'on';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Patterns that indicate sensitive data
|
||||
const PATTERNS = [
|
||||
|
||||
+711
-138
File diff suppressed because it is too large
Load Diff
+232
-96
@@ -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) || ' '}</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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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
@@ -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 ──
|
||||
|
||||
|
||||
@@ -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,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
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
@@ -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
File diff suppressed because it is too large
Load Diff
+132
-21
@@ -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;
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// 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 (_) {} }
|
||||
+799
-87
File diff suppressed because it is too large
Load Diff
+533
-73
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>') + '</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,'&').replace(/</g,'<').replace(/>/g,'>') + '</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,'&').replace(/</g,'<').replace(/>/g,'>').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
@@ -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;
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
import { isAltGrEvent } from '../platform.js';
|
||||
|
||||
export function wireKeyboardShortcuts(deps) {
|
||||
const {
|
||||
@@ -79,7 +80,11 @@ export function wireKeyboardShortcuts(deps) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') return;
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// Skip the Ctrl+Alt editor chords for an AltGr keystroke (see platform.js);
|
||||
// only the chord block is skipped, so the layout-character handlers below
|
||||
// still act — AltGr+5 / AltGr+8 stay as the [ ] brush-size shortcut on
|
||||
// AZERTY / QWERTZ.
|
||||
if ((e.ctrlKey || e.metaKey) && !isAltGrEvent(e)) {
|
||||
if (e.key === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); }
|
||||
// Ctrl+Shift+D = Deselect: clears the wand selection (and
|
||||
// lasso if active) without affecting layers.
|
||||
|
||||
@@ -37,7 +37,8 @@ export function computeSnap(layer, nx, ny, ctx) {
|
||||
{ y: ch, label: 'canvas-b' },
|
||||
{ y: ch / 2, label: 'canvas-cy' },
|
||||
];
|
||||
for (const other of ctx.otherLayers) {
|
||||
const otherLayers = Array.isArray(ctx.otherLayers) ? ctx.otherLayers : [];
|
||||
for (const other of otherLayers) {
|
||||
if (!other.visible || other.id === layer.id) continue;
|
||||
const o = other.offset || { x: 0, y: 0 };
|
||||
const ow = other.canvas.width, oh = other.canvas.height;
|
||||
|
||||
+89
-51
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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(', ');
|
||||
}
|
||||
|
||||
@@ -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; 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]);
|
||||
|
||||
@@ -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, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/`/g, '`');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -165,6 +165,39 @@ window.addEventListener('pageshow', clearFreshComposerRestore);
|
||||
window.addEventListener('resize', _sync);
|
||||
}
|
||||
|
||||
/* Keep minimized tool chips above the composer. Both the current modalManager
|
||||
dock and the legacy fallback dock consume this root-level clearance. */
|
||||
{
|
||||
const root = document.documentElement;
|
||||
const chatBar = document.querySelector('.chat-input-bar');
|
||||
const attachStrip = document.getElementById('attach-strip');
|
||||
const chatContainer = document.getElementById('chat-container');
|
||||
const _syncComposerClearance = () => {
|
||||
let top = window.innerHeight;
|
||||
for (const el of [attachStrip, chatBar]) {
|
||||
if (!el) continue;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.height > 0) top = Math.min(top, rect.top);
|
||||
}
|
||||
const clearance = Math.max(12, Math.ceil(window.innerHeight - top + 8));
|
||||
root.style.setProperty('--composer-clearance', clearance + 'px');
|
||||
};
|
||||
requestAnimationFrame(_syncComposerClearance);
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(_syncComposerClearance);
|
||||
if (chatBar) ro.observe(chatBar);
|
||||
if (attachStrip) ro.observe(attachStrip);
|
||||
}
|
||||
if (chatContainer && typeof MutationObserver !== 'undefined') {
|
||||
new MutationObserver(_syncComposerClearance).observe(chatContainer, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
}
|
||||
if (chatBar) chatBar.addEventListener('transitionend', _syncComposerClearance);
|
||||
window.addEventListener('resize', _syncComposerClearance);
|
||||
}
|
||||
|
||||
/* ---- Resizable sidebar — drag edge to resize, collapse if small, drag rail edge to expand ---- */
|
||||
{
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// Keyboard Shortcuts — dynamic keybinds
|
||||
// ============================================
|
||||
|
||||
import { IS_MAC, isAltGrEvent } from './platform.js';
|
||||
|
||||
const _defaultKeybinds = {
|
||||
search: 'ctrl+k', toggle_sidebar: 'ctrl+alt+b', new_session: 'ctrl+alt+n',
|
||||
fav_session: 'ctrl+alt+f', delete_session: 'ctrl+alt+d',
|
||||
@@ -13,8 +15,11 @@ const _defaultKeybinds = {
|
||||
open_notes: '', open_tasks: '', open_theme: '',
|
||||
};
|
||||
|
||||
function _matchesCombo(e, combo) {
|
||||
export function _matchesCombo(e, combo, isMac = IS_MAC) {
|
||||
if (!combo) return false;
|
||||
// Drop AltGr keystrokes so typing characters on non-US layouts can't fire a
|
||||
// Ctrl+Alt shortcut — e.g. the destructive delete_session. See platform.js.
|
||||
if (isAltGrEvent(e, isMac)) return false;
|
||||
const parts = combo.split('+');
|
||||
const needCtrl = parts.includes('ctrl');
|
||||
const needAlt = parts.includes('alt');
|
||||
|
||||
@@ -175,8 +175,8 @@ export function langIcon(lang, size = 14, opts = {}) {
|
||||
const key = String(lang).toLowerCase();
|
||||
const inner = ICONS[key] || ICONS[ALIASES[key]] || '';
|
||||
if (!inner) return '';
|
||||
const cls = opts.className ? ` class="${opts.className}"` : '';
|
||||
const style = opts.style ? ` style="${opts.style}"` : '';
|
||||
const cls = (opts && opts.className) ? ` class="${opts.className}"` : '';
|
||||
const style = (opts && opts.style) ? ` style="${opts.style}"` : '';
|
||||
return (
|
||||
`<svg${cls}${style} width="${size}" height="${size}" viewBox="0 0 24 24" ` +
|
||||
`fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">` +
|
||||
|
||||
+223
-58
@@ -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(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
const placeholder = `___MERMAID_BLOCK_${mermaidBlocks.length}___`;
|
||||
mermaidBlocks.push(`<div class="mermaid-container"><pre class="mermaid" id="${mermaidId}">${escapeHtml(raw)}</pre></div>`);
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
const escaped = cleaned.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
const placeholder = `___CODE_BLOCK_${codeBlocks.length}___`;
|
||||
|
||||
const langClass = lang ? ` class="language-${lang}"` : '';
|
||||
const runnableLangs = ['python','py','javascript','js','html','bash','sh','shell','zsh'];
|
||||
const runBtn = (lang && runnableLangs.includes(lang.toLowerCase()))
|
||||
? `<button type="button" class="run-code" data-code="${escapeHtml(escaped)}" data-lang="${lang}" title="Run code"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>`
|
||||
: '';
|
||||
const editBtn = `<button type="button" class="edit-code" title="Edit"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>`;
|
||||
codeBlocks.push(`<pre><code${langClass} data-lang="${lang || ''}">${escapeHtml(escaped)}</code>${runBtn}${editBtn}<button type="button" class="copy-code" data-code="${escapeHtml(escaped)}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></pre>`);
|
||||
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// Repair common ways the agent mangles the entity-anchor convention
|
||||
// (`[Name](#kind-<id>)`). Models reliably get the single-link case
|
||||
// right but slip into other formats when listing many in a table.
|
||||
@@ -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(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
const placeholder = `___MERMAID_BLOCK_${mermaidBlocks.length}___`;
|
||||
mermaidBlocks.push(`<div class="mermaid-container"><pre class="mermaid" id="${mermaidId}">${escapeHtml(raw)}</pre></div>`);
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
const escaped = cleaned.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||
const placeholder = `___CODE_BLOCK_${codeBlocks.length}___`;
|
||||
|
||||
const langClass = lang ? ` class="language-${lang}"` : '';
|
||||
const runnableLangs = ['python','py','javascript','js','html','bash','sh','shell','zsh'];
|
||||
const runBtn = (lang && runnableLangs.includes(lang.toLowerCase()))
|
||||
? `<button type="button" class="run-code" data-code="${escapeHtml(escaped)}" data-lang="${lang}" title="Run code"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>`
|
||||
: '';
|
||||
const editBtn = `<button type="button" class="edit-code" title="Edit"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>`;
|
||||
codeBlocks.push(`<pre><code${langClass} data-lang="${lang || ''}">${escapeHtml(escaped)}</code>${runBtn}${editBtn}<button type="button" class="copy-code" data-code="${escapeHtml(escaped)}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></pre>`);
|
||||
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// KaTeX math rendering (after code blocks are extracted, so math in code is safe)
|
||||
const mathBlocks = [];
|
||||
if (window.katex) {
|
||||
@@ -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(/^> (.*)$/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
|
||||
};
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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, '<').replace(/>/g, '>'); }
|
||||
// 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, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/`/g, '`');
|
||||
}
|
||||
// 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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{ "type": "module" }
|
||||
@@ -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 & 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 };
|
||||
@@ -0,0 +1,47 @@
|
||||
// ============================================
|
||||
// Platform detection + AltGr-keystroke helper
|
||||
// ============================================
|
||||
// Shared by the keybind code: root keyboard-shortcuts.js, the editor's
|
||||
// keyboard-shortcuts.js, and settings.js. Single source of truth so the three
|
||||
// guards can't drift.
|
||||
|
||||
// AltGr (right Alt on AZERTY/QWERTZ and most non-US layouts, used to type
|
||||
// @ # { } [ ] | \ and €) is reported by browsers as Ctrl+Alt. macOS is the
|
||||
// exception: there the Option key — a normal part of Mac shortcuts — also sets
|
||||
// the AltGraph modifier state, so it must NOT be treated as AltGr.
|
||||
//
|
||||
// IS_MAC covers all Apple platforms, iPad/iPhone included: a Magic Keyboard's
|
||||
// Option key sets AltGraph exactly like a Mac's, so they need the same carve-out
|
||||
// — narrowing to macOS-only would re-break them. The name and the
|
||||
// /Mac|iPhone|iPad/ test deliberately mirror the existing isMac checks in
|
||||
// calendar.js and sessions.js; this is their single shared source of truth.
|
||||
export const IS_MAC =
|
||||
/Mac|iPhone|iPad/.test((typeof navigator !== 'undefined' && navigator.platform) || '') ||
|
||||
/Mac/.test((typeof navigator !== 'undefined' && navigator.userAgent) || '');
|
||||
|
||||
// True when `e` is an AltGr keystroke we should ignore for Ctrl+Alt shortcut
|
||||
// purposes. getModifierState('AltGraph') is true for AltGr but false for a
|
||||
// genuine left Ctrl+Alt, so real shortcuts still work. Always false on macOS,
|
||||
// where Option legitimately sets AltGraph.
|
||||
//
|
||||
// We also require ctrlKey+altKey: the collision we defend against is precisely
|
||||
// "AltGr reported AS Ctrl+Alt", so an event that asserts AltGraph WITHOUT
|
||||
// presenting as Ctrl+Alt (a Linux ISO_Level3_Shift layout, a stray modifier
|
||||
// state) is left alone instead of being swallowed.
|
||||
//
|
||||
// Trade-off: on Windows AltGr *is* Ctrl+right-Alt, so a deliberate
|
||||
// Ctrl+Alt+<char> shortcut typed via AltGr is unreachable too — accepted; use
|
||||
// the left Ctrl+Alt.
|
||||
//
|
||||
// NOTE: the AltGr -> AltGraph mapping is taken from the UI Events spec / MDN,
|
||||
// not proven by our tests. Older Firefox and some Linux setups historically did
|
||||
// not report AltGraph; where a browser sets ctrlKey+altKey without it this
|
||||
// guard is simply a no-op (the pre-fix behaviour) rather than a regression.
|
||||
export function isAltGrEvent(e, isMac = IS_MAC) {
|
||||
return (
|
||||
!isMac &&
|
||||
!!e.ctrlKey &&
|
||||
!!e.altKey &&
|
||||
!!(e.getModifierState && e.getModifierState('AltGraph'))
|
||||
);
|
||||
}
|
||||
+30
-12
@@ -8,6 +8,24 @@ let API_BASE = '';
|
||||
let selectedPreset = null;
|
||||
let presets = {};
|
||||
|
||||
export function loadStoredArray(key) {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(key) || '[]');
|
||||
return Array.isArray(value) ? value : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function loadStoredObject(key) {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(key) || '{}');
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Built-in prompt templates (moved from cot_prompts.py)
|
||||
export const PROMPT_TEMPLATES = [
|
||||
{
|
||||
@@ -220,7 +238,7 @@ function initNameDropdown() {
|
||||
if (!charName || charName === '__default__') return;
|
||||
const match = userTemplates.find(t => t.name === charName);
|
||||
const isBuiltin = PROMPT_TEMPLATES.some(t => t.name === charName);
|
||||
if (!await window.styledConfirm(`Delete "${charName}"?\n\nThis will remove the character and all its memories.`, { confirmText: 'Delete', danger: true })) return;
|
||||
if (!await window.styledConfirm(`Delete "${charName}"?\n\nThis will remove the persona and all its memories.`, { confirmText: 'Delete', danger: true })) return;
|
||||
try {
|
||||
// Delete saved template if exists
|
||||
if (match) {
|
||||
@@ -228,7 +246,7 @@ function initNameDropdown() {
|
||||
}
|
||||
// Hide built-in preset
|
||||
if (isBuiltin) {
|
||||
const hidden = JSON.parse(localStorage.getItem('odysseus-hidden-presets') || '[]');
|
||||
const hidden = loadStoredArray('odysseus-hidden-presets');
|
||||
if (!hidden.includes(charName)) hidden.push(charName);
|
||||
localStorage.setItem('odysseus-hidden-presets', JSON.stringify(hidden));
|
||||
}
|
||||
@@ -296,7 +314,7 @@ function _populateCharSelect() {
|
||||
const select = document.getElementById('char-template-select');
|
||||
if (!select) return;
|
||||
const currentVal = select.value;
|
||||
select.innerHTML = '<option value="__default__">Default (no character)</option>';
|
||||
select.innerHTML = '<option value="__default__">Default (no persona)</option>';
|
||||
|
||||
const savedNames = new Set(userTemplates.map(t => t.name));
|
||||
if (userTemplates.length) {
|
||||
@@ -311,7 +329,7 @@ function _populateCharSelect() {
|
||||
select.appendChild(group);
|
||||
}
|
||||
|
||||
const hiddenPresets = JSON.parse(localStorage.getItem('odysseus-hidden-presets') || '[]');
|
||||
const hiddenPresets = loadStoredArray('odysseus-hidden-presets');
|
||||
const builtins = PROMPT_TEMPLATES.filter(t => !savedNames.has(t.name) && !hiddenPresets.includes(t.name));
|
||||
if (builtins.length) {
|
||||
const group = document.createElement('optgroup');
|
||||
@@ -405,7 +423,7 @@ function initPersistentChat() {
|
||||
await fetch(`${API_BASE}/api/session/${sessionId}/important`, { method: 'POST', body: favFd });
|
||||
|
||||
// Save session → character mapping so it restores on switch
|
||||
const charSessions = JSON.parse(localStorage.getItem('odysseus-char-sessions') || '{}');
|
||||
const charSessions = loadStoredObject('odysseus-char-sessions');
|
||||
charSessions[sessionId] = charName;
|
||||
localStorage.setItem('odysseus-char-sessions', JSON.stringify(charSessions));
|
||||
|
||||
@@ -437,7 +455,7 @@ function initSaveAsTemplate() {
|
||||
|
||||
let name = nameInput ? nameInput.value.trim() : '';
|
||||
if (!name) {
|
||||
name = prompt('Enter a name for this character:');
|
||||
name = prompt('Enter a name for this persona:');
|
||||
if (!name || !name.trim()) return;
|
||||
name = name.trim();
|
||||
if (nameInput) nameInput.value = name;
|
||||
@@ -616,7 +634,7 @@ export function openCustomPresetModal() {
|
||||
} else {
|
||||
// Character/persona tab. "Save & " prefix when the user edited a template,
|
||||
// so it's clear the edit is being saved on start.
|
||||
label = changed ? 'Save & Start Character' : 'Start Character';
|
||||
label = changed ? 'Save & Start Persona' : 'Start Persona';
|
||||
}
|
||||
btn.textContent = label;
|
||||
// Show a "Cancel" button next to Start when the active tab's feature is
|
||||
@@ -708,7 +726,7 @@ export function openCustomPresetModal() {
|
||||
const notice = document.createElement('div');
|
||||
notice.id = 'char-lock-notice';
|
||||
notice.style.cssText = 'font-size:11px;color:var(--color-muted);text-align:center;padding:6px;margin-bottom:8px;border:1px dashed var(--border);border-radius:6px;';
|
||||
notice.textContent = 'Persistent chat — character is locked. Style, temperature, and memory can still be changed.';
|
||||
notice.textContent = 'Persistent chat — persona is locked. Style, temperature, and memory can still be changed.';
|
||||
modal.querySelector('.modal-body').prepend(notice);
|
||||
}
|
||||
} else {
|
||||
@@ -825,7 +843,7 @@ export async function saveCustomPreset(showToast, showError) {
|
||||
|
||||
if (showToast) {
|
||||
// The Inject tab is a plain tuned "prompt" chat, not a persona — say so.
|
||||
showToast(_isInjectStart ? 'Prompt saved' : 'Character saved');
|
||||
showToast(_isInjectStart ? 'Prompt saved' : 'Persona saved');
|
||||
}
|
||||
const modal = document.getElementById('custom-preset-modal');
|
||||
if (modal) {
|
||||
@@ -962,7 +980,7 @@ function _syncCharIndicator() {
|
||||
if (hasChar) {
|
||||
if (iconEl) iconEl.innerHTML = _AVATAR;
|
||||
if (nameSpan) nameSpan.textContent = custom.character_name;
|
||||
btn.title = `Character: ${custom.character_name} — click to configure`;
|
||||
btn.title = `Persona: ${custom.character_name} — click to configure`;
|
||||
} else {
|
||||
// Inject/tuning chat — syringe tag labeled "Prompt" to match the
|
||||
// window identity, no persona name.
|
||||
@@ -1011,7 +1029,7 @@ function _syncCharIndicator() {
|
||||
let _prevSessionId = null;
|
||||
|
||||
export function onSessionSwitch(sessionId) {
|
||||
const charSessions = JSON.parse(localStorage.getItem('odysseus-char-sessions') || '{}');
|
||||
const charSessions = loadStoredObject('odysseus-char-sessions');
|
||||
|
||||
// Leaving a persistent chat — deactivate for this switch only
|
||||
if (window._persistentChatSession) {
|
||||
@@ -1059,7 +1077,7 @@ export function isPersistentChat() {
|
||||
* Remove a session from persistent chat mappings (call when session is deleted).
|
||||
*/
|
||||
export function removePersistentChat(sessionId) {
|
||||
const charSessions = JSON.parse(localStorage.getItem('odysseus-char-sessions') || '{}');
|
||||
const charSessions = loadStoredObject('odysseus-char-sessions');
|
||||
if (charSessions[sessionId]) {
|
||||
delete charSessions[sessionId];
|
||||
localStorage.setItem('odysseus-char-sessions', JSON.stringify(charSessions));
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+27
-7
@@ -14,6 +14,20 @@
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
function _esc(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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
@@ -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) => {
|
||||
|
||||
@@ -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 => ({ '&':'&','<':'<','>':'>','"':'"','\'':''' }[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
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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. "every weekday 7am summarize my unread email"" />'
|
||||
+ '<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. "every weekday 7am summarize my unread email"" />'
|
||||
+ '<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">A–Z</option>
|
||||
<option value="status">Status</option>
|
||||
|
||||
+29
-9
@@ -4,6 +4,7 @@
|
||||
import Storage from './storage.js';
|
||||
import uiModule from './ui.js';
|
||||
import { initColorPickers, attachColorPicker } from './colorPicker.js';
|
||||
import { hexToRgb } from './color/hex.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { snapModalToZone } from './tileManager.js';
|
||||
|
||||
@@ -128,10 +129,10 @@ function _syncCustomThemesToServer(ct) {
|
||||
|
||||
// --- Syntax color derivation from theme base colors ---
|
||||
function hexToHSL(hex) {
|
||||
hex = hex.replace('#', '');
|
||||
const r = parseInt(hex.substring(0, 2), 16) / 255;
|
||||
const g = parseInt(hex.substring(2, 4), 16) / 255;
|
||||
const b = parseInt(hex.substring(4, 6), 16) / 255;
|
||||
const rgb = hexToRgb(hex) || { r: 0, g: 0, b: 0 };
|
||||
const r = rgb.r / 255;
|
||||
const g = rgb.g / 255;
|
||||
const b = rgb.b / 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
let h, s, l = (max + min) / 2;
|
||||
if (max === min) { h = s = 0; }
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 (_) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user