mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-07-11 12:27:13 +00:00
Merge dev into main for testing
This commit is contained in:
+5
-3
@@ -91,7 +91,7 @@ async function _createDirectChatFromPreferredModel() {
|
||||
if (!sessionModule) return false;
|
||||
|
||||
const pending = sessionModule.getPendingChat && sessionModule.getPendingChat();
|
||||
if (pending && pending.url && pending.modelId) {
|
||||
if (pending && pending.url && pending.modelId && pending.endpointId) {
|
||||
sessionModule.createDirectChat(pending.url, pending.modelId, pending.endpointId);
|
||||
return true;
|
||||
}
|
||||
@@ -99,7 +99,7 @@ async function _createDirectChatFromPreferredModel() {
|
||||
const sessions = sessionModule.getSessions();
|
||||
const currentId = sessionModule.getCurrentSessionId();
|
||||
const current = sessions.find(s => s.id === currentId);
|
||||
if (current && current.endpoint_url && current.model) {
|
||||
if (current && current.endpoint_url && current.model && current.endpoint_id) {
|
||||
sessionModule.createDirectChat(current.endpoint_url, current.model, current.endpoint_id);
|
||||
return true;
|
||||
}
|
||||
@@ -2460,7 +2460,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', 'text-emojis']);
|
||||
const UI_VIS_DEFAULT_OFF = new Set(['models-section', 'rag-toggle-btn', 'text-emojis', 'chat-fullwidth']);
|
||||
|
||||
// Keys that need admin to toggle off (reserved for future use)
|
||||
const UI_VIS_ADMIN_ONLY = new Set([]);
|
||||
@@ -2493,6 +2493,8 @@ function initializeEventListeners() {
|
||||
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);
|
||||
// Fullwidth chat toggle (chat-fullwidth: checked=fullwidth, unchecked=big-padding
|
||||
document.body.classList.toggle('fullwidth-chat', state['chat-fullwidth'] === true);
|
||||
}
|
||||
|
||||
// Rearrange toggles in session/model sort dropdowns
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+31
-2
@@ -76,7 +76,7 @@
|
||||
}
|
||||
// Apply font early
|
||||
if (t && t.font) {
|
||||
var fm = {mono:"'Fira Code', monospace",sans:"system-ui, -apple-system, 'Segoe UI', sans-serif",serif:"Georgia, 'Times New Roman', serif"};
|
||||
var fm = {mono:"'Fira Code', monospace",sans:"system-ui, -apple-system, 'Segoe UI', sans-serif",serif:"Georgia, 'Times New Roman', serif",opendyslexic:"'OpenDyslexic', sans-serif"};
|
||||
if (fm[t.font]) { s.setProperty('--font-family', fm[t.font]); }
|
||||
else { s.setProperty('--font-family', "'" + t.font.replace(/'/g,'') + "', sans-serif"); }
|
||||
}
|
||||
@@ -84,6 +84,12 @@
|
||||
if (t && t.density && t.density !== 'comfortable') {
|
||||
document.documentElement.classList.add('density-' + t.density);
|
||||
}
|
||||
// Apply UI text-size scale early (global accessibility pref, independent
|
||||
// of the active theme) so there's no flash on load.
|
||||
try {
|
||||
var _us = localStorage.getItem('odysseus-ui-scale');
|
||||
if (_us && _us !== '100') document.documentElement.classList.add('ui-scale-' + _us);
|
||||
} catch(e){}
|
||||
// Apply background pattern on body once available
|
||||
if (t && t.bgPattern && t.bgPattern !== 'none') {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
@@ -581,6 +587,7 @@
|
||||
<option value="mono">Monospace</option>
|
||||
<option value="sans">Sans-serif</option>
|
||||
<option value="serif">Serif</option>
|
||||
<option value="opendyslexic">OpenDyslexic (dyslexia-friendly)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="theme-fd-group">
|
||||
@@ -591,6 +598,13 @@
|
||||
<option value="spacious">Spacious</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="theme-fd-group">
|
||||
<label class="theme-fd-label">Text size</label>
|
||||
<select id="theme-text-size-select" class="theme-fd-select" aria-label="Text size">
|
||||
<option value="100">Default</option>
|
||||
<option value="125">Larger</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="theme-fd-group" id="theme-frosted-group">
|
||||
<label class="theme-fd-label" for="theme-frosted-toggle">Frosted</label>
|
||||
<label class="admin-switch" style="margin-top:4px;">
|
||||
@@ -1318,7 +1332,7 @@
|
||||
|
||||
<!-- Cookbook Modal -->
|
||||
<div id="cookbook-modal" class="modal hidden">
|
||||
<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-content" role="dialog" aria-label="Cookbook" style="width: min(780px, 92vw); 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" aria-label="Close cookbook">✖</button>
|
||||
@@ -1806,6 +1820,11 @@
|
||||
<span class="vis-label">Session Header <span class="vis-hint">Model name & export above chat</span></span>
|
||||
<input type="checkbox" checked data-ui-key="chat-meta"><span class="vis-switch"></span>
|
||||
</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"><path d="M4 6h16"/><path d="M4 10h8"/></svg></span>
|
||||
<span class="vis-label">Full-width chat <span class="vis-hint">Use the full window width (desktop)</span></span>
|
||||
<input type="checkbox" data-ui-key="chat-fullwidth"><span class="vis-switch"></span>
|
||||
</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"><path d="M12 3v2m0 14v2m-7-9H3m18 0h-2m-1.5-6.5L16 7m-8-1.5L6.5 7m11 11l-1.5-1.5M8 18l-1.5 1.5"/><circle cx="12" cy="12" r="4"/></svg></span>
|
||||
<span class="vis-label">Welcome Message <span class="vis-hint">Logo & tips on empty chat</span></span>
|
||||
@@ -2074,6 +2093,16 @@
|
||||
<label class="admin-switch"><input type="checkbox" id="adm-signupToggle"><span class="admin-slider"></span></label>
|
||||
</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="M12 15v3m-3-3h6M12 3v2m0 16v-2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M3 12h2m16 0h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/><circle cx="12" cy="12" r="3"/></svg>Model Defaults</h2>
|
||||
<div class="admin-toggle-row">
|
||||
<div>
|
||||
<div class="admin-toggle-label">Share defaults with users</div>
|
||||
<div class="admin-toggle-sub">When on, users without a personal default inherit the global default model (only if those models are allowed for them).</div>
|
||||
</div>
|
||||
<label class="admin-switch"><input type="checkbox" id="adm-shareDefaultsToggle"><span class="admin-slider"></span></label>
|
||||
</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="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>Users</h2>
|
||||
<div id="adm-userList"><div class="admin-empty">Loading...</div></div>
|
||||
|
||||
+43
-4
@@ -343,6 +343,28 @@ function initSignupToggle() {
|
||||
});
|
||||
}
|
||||
|
||||
function initShareDefaultsToggle() {
|
||||
const toggle = el('adm-shareDefaultsToggle');
|
||||
fetch('/api/auth/settings', { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(d => { toggle.checked = !!d.share_defaults_with_users; })
|
||||
.catch(e => console.warn('Settings fetch failed:', e));
|
||||
toggle.addEventListener('change', async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/settings', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ share_defaults_with_users: toggle.checked }),
|
||||
});
|
||||
const data = await res.json();
|
||||
toggle.checked = !!data.share_defaults_with_users;
|
||||
} catch (e) {
|
||||
toggle.checked = !toggle.checked;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initAddUser() {
|
||||
fetch('/api/auth/policy', { credentials: 'same-origin' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
@@ -1582,8 +1604,8 @@ function initEndpointForm() {
|
||||
wrap.style.cssText = 'display:flex;align-items:center;padding:8px 0;';
|
||||
wrap.appendChild(wp.element);
|
||||
const txt = document.createElement('span');
|
||||
txt.textContent = 'Scanning ports 8000-8020 and 11434 for model servers...';
|
||||
txt.style.cssText = 'opacity:0.7;';
|
||||
txt.textContent = 'Scanning ports 8000-8020, 8080, 1234, 11434, and 11435 for model servers...';
|
||||
txt.style.cssText = 'font-size:12px;opacity:0.7;';
|
||||
wrap.appendChild(txt);
|
||||
msg.appendChild(wrap);
|
||||
discoverBtn._wp = wp;
|
||||
@@ -1598,12 +1620,24 @@ function initEndpointForm() {
|
||||
} else {
|
||||
// Auto-add each discovered endpoint. Server dedupes on base_url
|
||||
// and returns `existing: true` for already-registered ones.
|
||||
// Map fingerprinted provider IDs to friendly display names.
|
||||
const _PROVIDER_DISPLAY = {
|
||||
llamacpp: 'llama.cpp', lmstudio: 'LM Studio', vllm: 'vLLM',
|
||||
ollama: 'Ollama',
|
||||
};
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
for (const item of items) {
|
||||
const base = item.url.replace('/chat/completions', '').replace(/\/$/, '');
|
||||
const providerDisplay = _PROVIDER_DISPLAY[item.provider] || null;
|
||||
const fd = new FormData();
|
||||
fd.append('base_url', base);
|
||||
if (providerDisplay) {
|
||||
// Use "Provider (host:port)" so the endpoint is immediately
|
||||
// identifiable in the list, e.g. "llama.cpp (localhost:8080)".
|
||||
const hostPart = base.replace(/^https?:\/\//, '').split('/')[0];
|
||||
fd.append('name', `${providerDisplay} (${hostPart})`);
|
||||
}
|
||||
fd.append('endpoint_kind', 'local');
|
||||
fd.append('model_refresh_mode', 'auto');
|
||||
fd.append('skip_probe', 'false');
|
||||
@@ -1617,7 +1651,12 @@ function initEndpointForm() {
|
||||
}
|
||||
}
|
||||
const totalModels = items.reduce((n, i) => n + (i.models ? i.models.length : 0), 0);
|
||||
const parts = [`Found ${items.length} server${items.length !== 1 ? 's' : ''} with ${totalModels} model${totalModels !== 1 ? 's' : ''}`];
|
||||
const serverNames = items.map(i =>
|
||||
(_PROVIDER_DISPLAY[i.provider] || i.url.replace(/^https?:\/\//, '').split('/')[0])
|
||||
);
|
||||
const parts = [
|
||||
`Found ${items.length} server${items.length !== 1 ? 's' : ''} (${serverNames.join(', ')}) with ${totalModels} model${totalModels !== 1 ? 's' : ''}`,
|
||||
];
|
||||
if (added) parts.push(`added ${added} new`);
|
||||
if (skipped) parts.push(`${skipped} already added`);
|
||||
msg.innerHTML = parts.join(' — ');
|
||||
@@ -2987,7 +3026,7 @@ function initLogsView() {
|
||||
function initAll() {
|
||||
modalEl = el('settings-modal');
|
||||
const inits = [
|
||||
initSignupToggle, initAddUser, initEndpointForm, initMcpForm,
|
||||
initSignupToggle, initShareDefaultsToggle, initAddUser, initEndpointForm, initMcpForm,
|
||||
initCalDAV, initBackup, initDangerZone, initTokenForm, initLogsView,
|
||||
() => settingsModule.initIntegrations()
|
||||
];
|
||||
|
||||
+10
-9
@@ -5,6 +5,7 @@
|
||||
import uiModule from './ui.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { attachColorPicker } from './colorPicker.js';
|
||||
import { bindMenuDismiss } from './escMenuStack.js';
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
WEEKDAYS, WEEKDAYS_SUN, MONTHS, MON_SHORT,
|
||||
CAL_PALETTE, CAL_COLORS, _CAL_CUSTOM_GRADIENT, _TYPE_PALETTE,
|
||||
_trashIcon, _moreIcon, _bellIcon,
|
||||
_isCalBgImage, _calBgImageUrl, _calBgCss,
|
||||
_isCalBgImage, _calBgImageUrl, _calBgCss, _cssUrlEscape,
|
||||
_calReadableTextColor,
|
||||
_ds, _addDays, _shiftDT, _tzOffset, _localDateOf,
|
||||
} from './calendar/utils.js';
|
||||
@@ -417,8 +418,8 @@ function _calEventFg(ev) {
|
||||
// Returns '' for normal solid-color events.
|
||||
function _calItemBgStyle(ev) {
|
||||
if (!_isCalBgImage(ev.color)) return '';
|
||||
const url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22");
|
||||
return `background-image: linear-gradient(color-mix(in srgb, var(--bg) 70%, transparent), color-mix(in srgb, var(--bg) 70%, transparent)), url('${url}'); background-size: cover; background-position: center;`;
|
||||
const url = _calBgImageUrl(ev.color);
|
||||
return `background-image: linear-gradient(color-mix(in srgb, var(--bg) 70%, transparent), color-mix(in srgb, var(--bg) 70%, transparent)), url('${_cssUrlEscape(url)}'); background-size: cover; background-position: center;`;
|
||||
}
|
||||
|
||||
function _todayCount() {
|
||||
@@ -537,7 +538,7 @@ function _showEventMoreMenu(ev, anchor) {
|
||||
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;`;
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};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;`;
|
||||
|
||||
const _item = (icon, label, onClick, danger) => {
|
||||
const it = document.createElement('div');
|
||||
@@ -1324,8 +1325,8 @@ async function _renderWeek() {
|
||||
// events keep the original tinted treatment.
|
||||
let bgDecl;
|
||||
if (_isCalBgImage(ev.color)) {
|
||||
const _url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22");
|
||||
bgDecl = `background-image: linear-gradient(color-mix(in srgb, var(--bg) 55%, transparent), color-mix(in srgb, var(--bg) 55%, transparent)), url('${_url}'); background-size: cover; background-position: center;`;
|
||||
const _url = _calBgImageUrl(ev.color);
|
||||
bgDecl = `background-image: linear-gradient(color-mix(in srgb, var(--bg) 55%, transparent), color-mix(in srgb, var(--bg) 55%, transparent)), url('${_cssUrlEscape(_url)}'); background-size: cover; background-position: center;`;
|
||||
} else {
|
||||
bgDecl = `background:color-mix(in srgb, ${_calColor(ev)} 18%, var(--bg));`;
|
||||
}
|
||||
@@ -2917,7 +2918,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
||||
let bg;
|
||||
if (isCustom) {
|
||||
const url = _calBgImageUrl(cur);
|
||||
bg = url ? `center/cover no-repeat url('${url}')` : _CAL_CUSTOM_GRADIENT;
|
||||
bg = url ? `center/cover no-repeat url('${_cssUrlEscape(url)}')` : _CAL_CUSTOM_GRADIENT;
|
||||
} else {
|
||||
bg = c.hex || 'var(--border)';
|
||||
}
|
||||
@@ -2992,7 +2993,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
||||
// stays readable. Chrome accent falls back to the theme accent.
|
||||
const url = _calBgImageUrl(hex);
|
||||
_formCard.style.setProperty('--ev-color', 'var(--accent)');
|
||||
_formCard.style.backgroundImage = `linear-gradient(color-mix(in srgb, var(--panel) 65%, transparent), color-mix(in srgb, var(--panel) 65%, transparent)), url('${url.replace(/'/g, "\\'")}')`;
|
||||
_formCard.style.backgroundImage = `linear-gradient(color-mix(in srgb, var(--panel) 65%, transparent), color-mix(in srgb, var(--panel) 65%, transparent)), url('${_cssUrlEscape(url)}')`;
|
||||
_formCard.style.backgroundSize = 'cover';
|
||||
_formCard.style.backgroundPosition = 'center';
|
||||
_formCard.classList.add('cal-form-bg-image');
|
||||
@@ -3014,7 +3015,7 @@ function _showEventForm(existing, defaultDate, defaultEndDate) {
|
||||
if (!url) return;
|
||||
const sentinel = 'bg:' + url;
|
||||
dot.dataset.color = sentinel;
|
||||
dot.style.background = `center/cover no-repeat url('${url}')`;
|
||||
dot.style.background = `center/cover no-repeat url('${_cssUrlEscape(url)}')`;
|
||||
document.querySelectorAll('#cal-f-colors .note-color-dot').forEach(d => d.classList.remove('active'));
|
||||
dot.classList.add('active');
|
||||
_applyFormTint(sentinel);
|
||||
|
||||
@@ -65,13 +65,25 @@ export function _calBgImageUrl(c) {
|
||||
return _isCalBgImage(c) ? c.slice(3) : '';
|
||||
}
|
||||
|
||||
// Escape a value for safe embedding inside a single-quoted CSS `url('...')`.
|
||||
// Backslashes MUST be escaped first: otherwise a trailing/embedded `\` in the
|
||||
// (CalDAV-syncable, untrusted) bg-image URL would escape the closing quote we
|
||||
// add for `'` and let the value break out of the string (CodeQL
|
||||
// js/incomplete-sanitization). `"` is percent-encoded for good measure.
|
||||
export function _cssUrlEscape(s) {
|
||||
return String(s == null ? '' : s)
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/"/g, '%22');
|
||||
}
|
||||
|
||||
// Returns a value safe to drop into `style="background:..."`. Falls back to
|
||||
// the calendar default for bg-image events in spots where an image would be
|
||||
// too small to render usefully (small grid dots, multi-day bars).
|
||||
export function _calBgCss(c, fallback) {
|
||||
if (_isCalBgImage(c)) {
|
||||
const u = _calBgImageUrl(c);
|
||||
return u ? `center/cover no-repeat url('${u.replace(/'/g, "\\'")}')` : (fallback || 'var(--accent)');
|
||||
return u ? `center/cover no-repeat url('${_cssUrlEscape(u)}')` : (fallback || 'var(--accent)');
|
||||
}
|
||||
return c || fallback || 'var(--accent)';
|
||||
}
|
||||
|
||||
+13
-142
@@ -12,7 +12,6 @@ import chatRenderer from './chatRenderer.js';
|
||||
import chatStream from './chatStream.js';
|
||||
import { addAITTSButton } from './tts-ai.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import { svgifyEmoji } from './markdown.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import presetsModule from './presets.js';
|
||||
import fileHandlerModule from './fileHandler.js';
|
||||
@@ -2325,148 +2324,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
} else if (json.type === 'ask_user') {
|
||||
if (_isBg) continue;
|
||||
// The agent posed a multiple-choice question; the turn has ended.
|
||||
// Render clickable options at the bottom of the history. The
|
||||
// user's pick is sent as the next message and the agent resumes.
|
||||
// Use the shared history renderer so the live and restored
|
||||
// versions have identical behavior.
|
||||
_cancelThinkingTimer();
|
||||
_removeThinkingSpinner();
|
||||
const _aq = json.data || {};
|
||||
const _opts = Array.isArray(_aq.options) ? _aq.options : [];
|
||||
if (_aq.question && _opts.length) {
|
||||
const chatBox = document.getElementById('chat-history');
|
||||
// Drop any prior unanswered card so only the latest shows.
|
||||
chatBox.querySelectorAll('.ask-user-card').forEach(n => n.remove());
|
||||
const card = document.createElement('div');
|
||||
card.className = 'ask-user-card';
|
||||
const multi = !!_aq.multi;
|
||||
// Group the choices for assistive tech and label the group with
|
||||
// the question (set below); make the card focusable so it can be
|
||||
// moved to when it appears.
|
||||
card.setAttribute('role', 'group');
|
||||
card.tabIndex = -1;
|
||||
// Render any emoji in agent-supplied text through the app's
|
||||
// pipeline: escape, then svgify to monochrome theme-tinted
|
||||
// glyphs (project rule: never colorful emoji; respects the
|
||||
// "Text-only Emojis" setting like the rest of the chat).
|
||||
const _emo = (s) => svgifyEmoji(uiModule.esc(String(s)));
|
||||
|
||||
// Header row holds the close (×) to dismiss the affordances and
|
||||
// just type a reply instead.
|
||||
const head = document.createElement('div');
|
||||
head.className = 'ask-user-head';
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'modal-close ask-user-close';
|
||||
closeBtn.setAttribute('aria-label', 'Dismiss question');
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.addEventListener('click', () => {
|
||||
card.remove();
|
||||
const mi = uiModule.el('message');
|
||||
if (mi) mi.focus();
|
||||
});
|
||||
head.appendChild(closeBtn);
|
||||
card.appendChild(head);
|
||||
|
||||
// Render the question inside the card so it's self-contained:
|
||||
// some models call ask_user without first narrating the question
|
||||
// as assistant text, in which case the card would otherwise show
|
||||
// bare options with no prompt.
|
||||
if (_aq.question) {
|
||||
const q = document.createElement('div');
|
||||
q.className = 'ask-user-question';
|
||||
q.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
|
||||
q.innerHTML = _emo(_aq.question);
|
||||
card.appendChild(q);
|
||||
// Label the choice group with the question for screen readers.
|
||||
card.setAttribute('aria-labelledby', q.id);
|
||||
} else {
|
||||
card.setAttribute('aria-label', 'Question from the assistant');
|
||||
}
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ask-user-options';
|
||||
card.appendChild(list);
|
||||
|
||||
const _send = (text) => {
|
||||
if (!text) return;
|
||||
// Remove the card once answered — the choice is sent as a
|
||||
// normal user message (and the question persists as the
|
||||
// assistant text above), so the affordances are spent.
|
||||
card.remove();
|
||||
const mi = uiModule.el('message');
|
||||
if (mi) mi.value = text;
|
||||
const sb = document.querySelector('.send-btn');
|
||||
if (sb) sb.click();
|
||||
};
|
||||
|
||||
_opts.forEach((opt, i) => {
|
||||
const label = (opt && opt.label) ? String(opt.label) : String(opt || '');
|
||||
if (!label) return;
|
||||
const descr = (opt && opt.description) ? String(opt.description) : '';
|
||||
const row = document.createElement(multi ? 'label' : 'button');
|
||||
row.className = 'ask-user-option';
|
||||
if (multi) {
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.value = label;
|
||||
row.appendChild(cb);
|
||||
}
|
||||
const txt = document.createElement('span');
|
||||
txt.className = 'ask-user-option-label';
|
||||
txt.innerHTML = _emo(label);
|
||||
row.appendChild(txt);
|
||||
if (descr) {
|
||||
const d = document.createElement('span');
|
||||
d.className = 'ask-user-option-desc';
|
||||
d.innerHTML = _emo(descr);
|
||||
row.appendChild(d);
|
||||
}
|
||||
if (!multi) {
|
||||
row.type = 'button';
|
||||
row.addEventListener('click', () => _send(label));
|
||||
}
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
// Free-text "Other" — type a custom answer + send (Enter or →).
|
||||
const other = document.createElement('div');
|
||||
other.className = 'ask-user-other';
|
||||
const otherInput = document.createElement('input');
|
||||
otherInput.type = 'text';
|
||||
otherInput.className = 'styled-prompt-input ask-user-other-input';
|
||||
otherInput.placeholder = multi ? 'Other (added to selection)…' : 'Other… (type your own answer)';
|
||||
otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer');
|
||||
const otherSend = document.createElement('button');
|
||||
otherSend.type = 'button';
|
||||
otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send';
|
||||
otherSend.setAttribute('aria-label', 'Send answer');
|
||||
otherSend.textContent = multi ? 'Send selection' : 'Send';
|
||||
const _submit = () => {
|
||||
const free = otherInput.value.trim();
|
||||
if (multi) {
|
||||
const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map(c => c.value);
|
||||
if (free) picked.push(free);
|
||||
if (picked.length) _send(picked.join(', '));
|
||||
} else if (free) {
|
||||
_send(free);
|
||||
}
|
||||
};
|
||||
otherSend.addEventListener('click', _submit);
|
||||
otherInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
_submit();
|
||||
}
|
||||
});
|
||||
other.appendChild(otherInput);
|
||||
other.appendChild(otherSend);
|
||||
card.appendChild(other);
|
||||
|
||||
chatBox.appendChild(card);
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
// Move focus to the card so keyboard/screen-reader users land on
|
||||
// the question + choices when it appears.
|
||||
try { card.focus(); } catch (_) {}
|
||||
}
|
||||
chatRenderer.renderAskUserCard(json.data || {});
|
||||
|
||||
} else if (json.type === 'plan_update') {
|
||||
if (_isBg) continue;
|
||||
@@ -5023,7 +4885,16 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (!header) return;
|
||||
const node = header.closest('.agent-thread-node');
|
||||
if (!node) return;
|
||||
node.classList.toggle('open');
|
||||
const opened = node.classList.toggle('open');
|
||||
if (opened) {
|
||||
// Expanding the final tool trace can push a pending ask_user card below
|
||||
// the viewport. Keep that immediately-adjacent prompt visible.
|
||||
const thread = node.closest('.agent-thread');
|
||||
const pendingCard = thread?.nextElementSibling;
|
||||
if (pendingCard?.classList.contains('ask-user-card')) {
|
||||
requestAnimationFrame(() => pendingCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' }));
|
||||
}
|
||||
}
|
||||
});
|
||||
window.__odysseus_thread_click_bound = true;
|
||||
}
|
||||
|
||||
+191
-3
@@ -3,6 +3,7 @@
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import { svgifyEmoji } from './markdown.js';
|
||||
import { addAITTSButton } from './tts-ai.js';
|
||||
import { providerLogo, providerLabel } from './providers.js';
|
||||
import settingsModule from './settings.js';
|
||||
@@ -406,8 +407,44 @@ function _openVisionEditor(att, userMsgEl) {
|
||||
|
||||
// Tool call syntax patterns to strip from displayed text
|
||||
const TOOL_CALL_RE = /\[TOOL_CALL\][\s\S]*?\[\/TOOL_CALL\]/gi;
|
||||
// Only strip fenced tool-call blocks that look like structured invocations, not regular code examples
|
||||
const EXEC_FENCE_RE = /```(?:web_search|read_file|write_file|create_document|edit_document|update_document)\s*\n[\s\S]*?```/gi;
|
||||
// Strip fenced tool-call blocks that look like structured invocations, not
|
||||
// regular code examples. The tool tags are NOT hard-coded here — they are the
|
||||
// backend's authoritative TOOL_TAGS set, fetched once from GET /api/tools and
|
||||
// built into EXEC_FENCE_RE at load. TOOL_TAGS (src/agent_tools/__init__.py) is
|
||||
// thus the single source: the live-strip list can never drift from the backend
|
||||
// or miss a future tool (#3993). bash/python are carved out on purpose — they
|
||||
// are languages a user may legitimately have asked the model to show, not tool
|
||||
// invocations.
|
||||
//
|
||||
// Until the fetch resolves, EXEC_FENCE_RE stays null and exec fences aren't
|
||||
// stripped — normally a sub-second window before the first stream. If the fetch
|
||||
// fails it stays null for the rest of the session (logged below), so live exec
|
||||
// fences won't be stripped until reload. Either way the backend already strips
|
||||
// persisted history (src/tool_parsing.py builds the same regex from TOOL_TAGS),
|
||||
// so a reload always renders clean.
|
||||
let EXEC_FENCE_RE = null;
|
||||
const EXEC_FENCE_NON_TOOL = new Set(['bash', 'python']);
|
||||
|
||||
async function loadExecFenceRegex() {
|
||||
try {
|
||||
const res = await fetch('/api/tools', { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
const tags = (data.tools || [])
|
||||
.map((t) => t.id)
|
||||
.filter((id) => id && !EXEC_FENCE_NON_TOOL.has(id));
|
||||
if (tags.length) {
|
||||
EXEC_FENCE_RE = new RegExp(
|
||||
'```(?:' + tags.join('|') + ')\\s*\\n[\\s\\S]*?```', 'gi'
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Surface the failure rather than swallowing it: EXEC_FENCE_RE stays null,
|
||||
// so this session won't strip live exec fences until reload (persisted path
|
||||
// stays clean regardless).
|
||||
console.warn('chatRenderer: /api/tools fetch failed; live exec-fence stripping disabled until reload', err);
|
||||
}
|
||||
}
|
||||
loadExecFenceRegex();
|
||||
// XML-style tool calls: <minimax:tool_call>, <tool_call>, <function_call>, bare <invoke>
|
||||
const XML_TOOL_CALL_RE = /<(?:[\w]+:)?(?:tool_call|function_call)>[\s\S]*?<\/(?:[\w]+:)?(?:tool_call|function_call)>/gi;
|
||||
const XML_INVOKE_RE = /<invoke\s+name=['"][^'"]*['"]>[\s\S]*?<\/invoke>/gi;
|
||||
@@ -852,7 +889,7 @@ export function roleTimestamp(when) {
|
||||
*/
|
||||
export function stripToolBlocks(text) {
|
||||
let cleaned = text.replace(TOOL_CALL_RE, '');
|
||||
cleaned = cleaned.replace(EXEC_FENCE_RE, '');
|
||||
if (EXEC_FENCE_RE) cleaned = cleaned.replace(EXEC_FENCE_RE, '');
|
||||
cleaned = cleaned.replace(DSML_TOOL_RE, '');
|
||||
cleaned = cleaned.replace(DSML_STRAY_RE, '');
|
||||
cleaned = cleaned.replace(XML_TOOL_CALL_RE, '');
|
||||
@@ -1985,6 +2022,142 @@ export function displayMetrics(messageElement, metrics) {
|
||||
if (uiModule) uiModule.scrollHistory();
|
||||
}
|
||||
|
||||
/** Remove any unanswered multiple-choice cards currently in the chat. */
|
||||
export function removeAskUserCards(root) {
|
||||
const scope = root || document.getElementById('chat-history') || document;
|
||||
scope.querySelectorAll('.ask-user-card').forEach((node) => node.remove());
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an ask_user payload as a durable choice card.
|
||||
*
|
||||
* This lives in the history renderer rather than the streaming loop so the
|
||||
* same UI can be used both for a live SSE event and for a persisted tool event
|
||||
* after a session reload.
|
||||
*/
|
||||
export function renderAskUserCard(payload, options) {
|
||||
const aq = payload || {};
|
||||
const opts = Array.isArray(aq.options) ? aq.options : [];
|
||||
const chatBox = document.getElementById('chat-history');
|
||||
if (!chatBox || !aq.question || opts.length < 2) return null;
|
||||
|
||||
const renderOptions = options || {};
|
||||
removeAskUserCards(chatBox);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'ask-user-card';
|
||||
card.setAttribute('role', 'group');
|
||||
card.tabIndex = -1;
|
||||
const multi = !!aq.multi;
|
||||
const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value)));
|
||||
|
||||
const head = document.createElement('div');
|
||||
head.className = 'ask-user-head';
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'modal-close ask-user-close';
|
||||
closeBtn.setAttribute('aria-label', 'Dismiss question');
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.addEventListener('click', () => {
|
||||
card.remove();
|
||||
const input = uiModule.el('message');
|
||||
if (input) input.focus();
|
||||
});
|
||||
head.appendChild(closeBtn);
|
||||
card.appendChild(head);
|
||||
|
||||
const question = document.createElement('div');
|
||||
question.className = 'ask-user-question';
|
||||
question.id = `ask-user-q-${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
|
||||
question.innerHTML = emojiText(aq.question);
|
||||
card.appendChild(question);
|
||||
card.setAttribute('aria-labelledby', question.id);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'ask-user-options';
|
||||
card.appendChild(list);
|
||||
|
||||
const send = (text) => {
|
||||
if (!text) return;
|
||||
card.remove();
|
||||
const input = uiModule.el('message');
|
||||
if (input) input.value = text;
|
||||
const sendButton = document.querySelector('.send-btn');
|
||||
if (sendButton) sendButton.click();
|
||||
};
|
||||
|
||||
opts.forEach((opt) => {
|
||||
const label = (opt && opt.label) ? String(opt.label) : String(opt || '');
|
||||
if (!label) return;
|
||||
const description = (opt && opt.description) ? String(opt.description) : '';
|
||||
const row = document.createElement(multi ? 'label' : 'button');
|
||||
row.className = 'ask-user-option';
|
||||
if (multi) {
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.value = label;
|
||||
row.appendChild(checkbox);
|
||||
}
|
||||
const labelText = document.createElement('span');
|
||||
labelText.className = 'ask-user-option-label';
|
||||
labelText.innerHTML = emojiText(label);
|
||||
row.appendChild(labelText);
|
||||
if (description) {
|
||||
const descriptionText = document.createElement('span');
|
||||
descriptionText.className = 'ask-user-option-desc';
|
||||
descriptionText.innerHTML = emojiText(description);
|
||||
row.appendChild(descriptionText);
|
||||
}
|
||||
if (!multi) {
|
||||
row.type = 'button';
|
||||
row.addEventListener('click', () => send(label));
|
||||
}
|
||||
list.appendChild(row);
|
||||
});
|
||||
|
||||
const other = document.createElement('div');
|
||||
other.className = 'ask-user-other';
|
||||
const otherInput = document.createElement('input');
|
||||
otherInput.type = 'text';
|
||||
otherInput.className = 'styled-prompt-input ask-user-other-input';
|
||||
otherInput.placeholder = multi ? 'Other (added to selection)…' : 'Other… (type your own answer)';
|
||||
otherInput.setAttribute('aria-label', multi ? 'Add a custom option' : 'Type a custom answer');
|
||||
const otherSend = document.createElement('button');
|
||||
otherSend.type = 'button';
|
||||
otherSend.className = 'confirm-btn confirm-btn-primary ask-user-other-send';
|
||||
otherSend.setAttribute('aria-label', 'Send answer');
|
||||
otherSend.textContent = multi ? 'Send selection' : 'Send';
|
||||
const submit = () => {
|
||||
const freeText = otherInput.value.trim();
|
||||
if (multi) {
|
||||
const picked = Array.from(card.querySelectorAll('.ask-user-option input:checked')).map((input) => input.value);
|
||||
if (freeText) picked.push(freeText);
|
||||
if (picked.length) send(picked.join(', '));
|
||||
} else if (freeText) {
|
||||
send(freeText);
|
||||
}
|
||||
};
|
||||
otherSend.addEventListener('click', submit);
|
||||
otherInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
});
|
||||
other.appendChild(otherInput);
|
||||
other.appendChild(otherSend);
|
||||
card.appendChild(other);
|
||||
|
||||
chatBox.appendChild(card);
|
||||
if (renderOptions.scroll !== false) {
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
if (renderOptions.focus !== false) {
|
||||
try { card.focus(); } catch (_) {}
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to the chat history.
|
||||
*/
|
||||
@@ -1994,6 +2167,11 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
const box = document.getElementById('chat-history');
|
||||
if (!box) { console.error('Chat history element not found'); return; }
|
||||
|
||||
// Loading a later user message means any earlier ask_user card was
|
||||
// answered. This also removes the live card as soon as a manual reply is
|
||||
// appended, even when the user did not click one of its buttons.
|
||||
if (role === 'user') removeAskUserCards(box);
|
||||
|
||||
var esc = uiModule.esc;
|
||||
const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
|
||||
|
||||
@@ -2001,6 +2179,7 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) {
|
||||
const roundTexts = metadata.round_texts || [];
|
||||
const toolEvents = metadata.tool_events;
|
||||
let pendingAskUser = null;
|
||||
let lastWrap = null;
|
||||
let firstMsgAi = null;
|
||||
let lastMsgAi = null;
|
||||
@@ -2077,6 +2256,7 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
box.appendChild(threadWrap);
|
||||
}
|
||||
for (const ev of roundTools) {
|
||||
if (ev.ask_user) pendingAskUser = ev.ask_user;
|
||||
const ok = (ev.exit_code === 0 || ev.exit_code == null);
|
||||
let outHtml = '';
|
||||
if (ev.output && ev.output.trim()) {
|
||||
@@ -2140,6 +2320,12 @@ export function addMessage(role, content, modelName, metadata) {
|
||||
box.querySelectorAll('pre code:not(.hljs)').forEach(b => window.hljs.highlightElement(b));
|
||||
}
|
||||
if (markdownModule.renderMermaid) markdownModule.renderMermaid(box);
|
||||
if (pendingAskUser) {
|
||||
// Session history is rendered oldest-to-newest. A later user message
|
||||
// removes this card; if there is none, the pending choice survives a
|
||||
// refresh. Avoid stealing focus while the history is loading.
|
||||
renderAskUserCard(pendingAskUser, { focus: false, scroll: false });
|
||||
}
|
||||
return lastWrap;
|
||||
}
|
||||
|
||||
@@ -2475,6 +2661,8 @@ const chatRenderer = {
|
||||
copyMessageText,
|
||||
safeToolScreenshotSrc,
|
||||
safeDisplayImageSrc,
|
||||
removeAskUserCards,
|
||||
renderAskUserCard,
|
||||
buildSourcesBox,
|
||||
buildFindingsBox,
|
||||
appendReportButton,
|
||||
|
||||
@@ -39,6 +39,7 @@ import spinnerModule from '../spinner.js';
|
||||
import themeModule from '../theme.js';
|
||||
import presetsModule from '../presets.js';
|
||||
import markdownModule from '../markdown.js';
|
||||
import { bindMenuDismiss } from '../escMenuStack.js';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -1062,6 +1063,7 @@ function _buildComparisonMarkdown() {
|
||||
}
|
||||
|
||||
let _exportMenuEl = null;
|
||||
let _closeExportMenu = () => {};
|
||||
function _toggleExportMenu(btn) {
|
||||
if (_exportMenuEl) { _closeExportMenu(); return; }
|
||||
const r = btn.getBoundingClientRect();
|
||||
@@ -1085,10 +1087,9 @@ function _toggleExportMenu(btn) {
|
||||
}
|
||||
document.body.appendChild(m);
|
||||
_exportMenuEl = m;
|
||||
setTimeout(() => document.addEventListener('click', _closeExportMenu, { once: true }), 0);
|
||||
}
|
||||
function _closeExportMenu() {
|
||||
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
|
||||
_closeExportMenu = bindMenuDismiss(m, () => {
|
||||
if (_exportMenuEl) { _exportMenuEl.remove(); _exportMenuEl = null; }
|
||||
}, (ev) => !m.contains(ev.target));
|
||||
}
|
||||
|
||||
async function _exportCopyMarkdown(_btn) {
|
||||
|
||||
+19
-22
@@ -31,7 +31,7 @@ import {
|
||||
} from './cookbook.js';
|
||||
import uiModule from './ui.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { _loadTasks, _tmuxGracefulKill } from './cookbookRunning.js';
|
||||
import { _loadTasks, _tmuxGracefulKill, _nextAvailablePort, _taskPort } from './cookbookRunning.js';
|
||||
import { openCookbookDependencies } from './cookbook-diagnosis.js';
|
||||
|
||||
// Map a serve-backend code (vllm / sglang / llamacpp) → the package name
|
||||
@@ -1526,36 +1526,34 @@ export function _expandModelRow(row, modelData) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Detect backend and port now — the pre-launch guard below needs them.
|
||||
const _qrBackendDetect = _detectBackend(modelData);
|
||||
const _qrRunBackend = _qrBackendDetect.backend || 'vllm';
|
||||
const _qrPort = _nextAvailablePort();
|
||||
|
||||
// ─── Pre-launch: stop the model already serving on this host ───────
|
||||
// Two servers can't share port 8000. Without this, the new launch
|
||||
// silently collided and the user saw no feedback. We surface the
|
||||
// conflict and offer to kill the running one first as the default
|
||||
// action (it's almost always what the user wants).
|
||||
// ─── Pre-launch: stop colliding serves on the same port ───────
|
||||
// Different ports coexist fine (e.g. vLLM on 8000 + Qwen VL on
|
||||
// 8001). Only block when the new model's port genuinely collides
|
||||
// with a running serve. (Issue #4507)
|
||||
try {
|
||||
const _qrHostStr = _envState.remoteHost || '';
|
||||
const _activeServes = _loadTasks().filter(t =>
|
||||
const _allServes = _loadTasks().filter(t =>
|
||||
t && t.type === 'serve'
|
||||
&& (t.remoteHost || '') === _qrHostStr
|
||||
&& (t.status === 'running' || t.status === 'ready' || t._serveReady)
|
||||
);
|
||||
if (_activeServes.length) {
|
||||
const _names = _activeServes.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean);
|
||||
const _clashing = _allServes.filter(t => _taskPort(t) === _qrPort);
|
||||
if (_clashing.length) {
|
||||
const _names = _clashing.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean);
|
||||
const _ok = await window.styledConfirm?.(
|
||||
`${_names.length} model${_names.length === 1 ? '' : 's'} already serving on ${_qrHostStr || 'local'} (${_names.join(', ')}). Port 8000 will collide. Stop the running model and launch this one?`,
|
||||
`${_clashing.length} model${_clashing.length === 1 ? '' : 's'} on port ${_qrPort} (${_names.join(', ')}). Stop it and launch this one?`,
|
||||
{ confirmText: 'Stop & launch', cancelText: 'Cancel' }
|
||||
);
|
||||
if (!_ok) return;
|
||||
// Mark + kill each running serve, then wait briefly for the
|
||||
// tmux session to actually go down before we kick off the new
|
||||
// launch. Otherwise vLLM still races against the dying socket.
|
||||
quickRunBtn.disabled = true;
|
||||
quickRunBtn.textContent = 'Stopping…';
|
||||
for (const t of _activeServes) {
|
||||
for (const t of _clashing) {
|
||||
try {
|
||||
// Use that task's own Stop button if it's rendered (handles
|
||||
// endpoint cleanup, Ollama unload, fade-out). Falls back to
|
||||
// a direct tmux kill if the Active tab isn't in the DOM yet.
|
||||
const _taskEl = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
|
||||
const _stopBtn = _taskEl?.querySelector('.cookbook-task-action-stop');
|
||||
if (_stopBtn) {
|
||||
@@ -1570,11 +1568,12 @@ export function _expandModelRow(row, modelData) {
|
||||
}
|
||||
} catch (_killErr) { /* best-effort */ }
|
||||
}
|
||||
// Give the OS a beat to release port 8000.
|
||||
await new Promise(r => setTimeout(r, 2500));
|
||||
}
|
||||
} catch (_e) { /* best-effort */ }
|
||||
|
||||
// -- Launch ───────────────────────────────────────────────────
|
||||
|
||||
// ─── Pre-launch driver check ─────────────────────────────────────
|
||||
// vLLM/SGLang need a working CUDA/ROCm driver. nvidia-smi failures
|
||||
// surface as system.gpu_error from our hardware probe; "no GPU
|
||||
@@ -1583,8 +1582,6 @@ export function _expandModelRow(row, modelData) {
|
||||
// user watches `pip install vllm` finish, then sees a cryptic CUDA
|
||||
// error 10 minutes later. (llama.cpp / Ollama have CPU fallbacks
|
||||
// so they skip this gate.)
|
||||
const _qrBackendDetect = _detectBackend(modelData);
|
||||
const _qrRunBackend = _qrBackendDetect.backend || 'vllm';
|
||||
if (_qrRunBackend === 'vllm' || _qrRunBackend === 'sglang') {
|
||||
const _sys = _hwfitCache?.system || {};
|
||||
if (_sys.gpu_error) {
|
||||
@@ -1691,7 +1688,7 @@ export function _expandModelRow(row, modelData) {
|
||||
|
||||
const host = _envState.remoteHost || '';
|
||||
const hostIp = host.includes('@') ? host.split('@').pop() : host;
|
||||
const port = '8000';
|
||||
const port = _qrPort;
|
||||
const detected = _detectBackend(modelData);
|
||||
const runBackend = detected.backend || 'vllm';
|
||||
|
||||
@@ -1706,7 +1703,7 @@ export function _expandModelRow(row, modelData) {
|
||||
} else if (runBackend === 'llamacpp') {
|
||||
const dir = `"$HOME/.cache/huggingface/hub/models--${modelData.name.replace(/\//g, '--')}/snapshots"`;
|
||||
const ggufPath = `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`;
|
||||
cmd = `llama-server --model "${ggufPath}" --host 0.0.0.0 --port 8080 -ngl 99 -c ${maxCtx} --flash-attn auto`;
|
||||
cmd = `llama-server --model "${ggufPath}" --host 0.0.0.0 --port ${port} -ngl 99 -c ${maxCtx} --flash-attn auto`;
|
||||
} else {
|
||||
cmd = `vllm serve ${modelData.name} --host 0.0.0.0 --port ${port}`;
|
||||
cmd += ` --tensor-parallel-size ${tp}`;
|
||||
|
||||
+39
-25
@@ -33,6 +33,9 @@ import {
|
||||
_fetchCachedModels, _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel,
|
||||
} from './cookbookServe.js';
|
||||
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
|
||||
const STORAGE_KEY = 'cookbook-presets';
|
||||
const LAST_STATE_KEY = 'cookbook-last-state';
|
||||
const SERVE_STATE_KEY = 'cookbook-serve-state';
|
||||
@@ -73,7 +76,7 @@ function _platformIcon(platform) {
|
||||
return '';
|
||||
}
|
||||
|
||||
export let _envState = { env: 'none', envPath: '', hfToken: '', hfTokenConfigured: false, hfTokenMasked: '', gpus: '', remoteHost: '', servers: [], modelPaths: [], platform: '', defaultServer: '' };
|
||||
export let _envState = { env: 'none', envPath: '', hfToken: '', hfTokenConfigured: false, hfTokenMasked: '', gpus: '', remoteHost: '', servers: [], modelPaths: [], platform: '', hostPlatform: '', defaultServer: '' };
|
||||
let _lastCacheHostVal = null;
|
||||
let _cookbookOpeningSpinners = [];
|
||||
export function _lastCacheHost() { return _lastCacheHostVal; }
|
||||
@@ -210,8 +213,13 @@ function _getPort(hostOrTask) {
|
||||
|
||||
/** Get platform for a given host (or task object). Returns 'windows', 'termux', 'linux', or '' */
|
||||
export function _getPlatform(hostOrTask) {
|
||||
if (!hostOrTask) return _envState.platform || '';
|
||||
if (typeof hostOrTask === 'object') return hostOrTask.platform || _getPlatform(hostOrTask.remoteServerKey || hostOrTask.remoteHost);
|
||||
if (hostOrTask === 'local') return _envState.hostPlatform || '';
|
||||
if (!hostOrTask) return _envState.remoteHost ? (_envState.platform || '') : (_envState.hostPlatform || '');
|
||||
if (typeof hostOrTask === 'object') {
|
||||
const taskHost = hostOrTask.remoteServerKey || hostOrTask.remoteHost || '';
|
||||
if (!taskHost || taskHost === 'local') return _envState.hostPlatform || '';
|
||||
return hostOrTask.platform || _getPlatform(taskHost);
|
||||
}
|
||||
const selected = hostOrTask === _envState.remoteHost ? _selectedServer() : null;
|
||||
const srv = selected || _serverByVal(hostOrTask);
|
||||
return srv?.platform || '';
|
||||
@@ -635,7 +643,12 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
// GPU list — read from gpus (button strip); fall back to gpu_id for
|
||||
// backward-compat with older saved presets that pre-date the removal.
|
||||
const gpuId = (f.gpus || f.gpu_id || '').toString().trim();
|
||||
const py = _isWindows() ? 'python' : 'python3';
|
||||
const _targetHost = Object.prototype.hasOwnProperty.call(f, 'host')
|
||||
? String(f.host || '').trim()
|
||||
: String(_envState.remoteHost || '').trim();
|
||||
const _isWin = _targetHost ? _isWindows(_targetHost) : _isWindows('local');
|
||||
const _localWindows = _isWin && !_targetHost;
|
||||
const py = _isWin ? 'python' : 'python3';
|
||||
// CPU-only serve (-ngl 0): drop the GPU-only flags, otherwise the command
|
||||
// mixes "zero GPU layers" with CUDA unified-memory + flash-attn and fails to
|
||||
// start (issue #1291). Only affects the ngl=0 path; GPU serving is unchanged.
|
||||
@@ -657,19 +670,19 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
// with misleading prefixes.
|
||||
const _sb = String(_hwfitCache?.system?.backend || '').toLowerCase();
|
||||
const _hwfitHost = String(_hwfitCache?._scannedHost || '');
|
||||
const _curHost = String(_envState.remoteHost || '');
|
||||
const _curHost = _targetHost;
|
||||
const _isCudaTarget = (_sb === 'cuda') && (_hwfitHost === _curHost);
|
||||
const lcPrefix = (() => {
|
||||
let p = '';
|
||||
if (f.unified_mem && !_cpuOnly && !_isWindows() && _isCudaTarget) p += `GGML_CUDA_ENABLE_UNIFIED_MEMORY=1 `;
|
||||
// No GPU env var in CPU mode — `-ngl 0` already disables offload
|
||||
if (f.unified_mem && !_cpuOnly && (!_isWin || _localWindows) && _isCudaTarget) p += `GGML_CUDA_ENABLE_UNIFIED_MEMORY=1 `;
|
||||
// No GPU env var in CPU mode - `-ngl 0` already disables offload
|
||||
// so CUDA_VISIBLE_DEVICES / HIP_VISIBLE_DEVICES would be misleading
|
||||
// clutter ("why is CUDA pinned for a CPU run?").
|
||||
if (!_isWindows() && !_cpuOnly) p += _gpuEnvPrefix(gpuId);
|
||||
if ((!_isWin || _localWindows) && !_cpuOnly) p += _gpuEnvPrefix(gpuId);
|
||||
return p;
|
||||
})();
|
||||
if (f.unified_mem && !_cpuOnly && _isWindows() && _isCudaTarget) cmd += `$env:GGML_CUDA_ENABLE_UNIFIED_MEMORY="1"; `;
|
||||
if (_isWindows() && !_cpuOnly) cmd += _gpuEnvPrefix(gpuId, true);
|
||||
if (f.unified_mem && !_cpuOnly && _isWin && !_localWindows && _isCudaTarget) cmd += `$env:GGML_CUDA_ENABLE_UNIFIED_MEMORY="1"; `;
|
||||
if (_isWin && !_localWindows && !_cpuOnly) cmd += _gpuEnvPrefix(gpuId, true);
|
||||
const needsGgufPrelude = /^\$\(\{\s*find\s/.test(String(ggufPath || ''));
|
||||
const modelArg = needsGgufPrelude ? '"$MODEL_FILE"' : `"${ggufPath}"`;
|
||||
// Prefer native llama-server. The backend bootstrap resolves/builds the
|
||||
@@ -741,11 +754,16 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
// llama-cpp-python takes the projector via --clip_model_path.
|
||||
_lcpExtra += ` --clip_model_path "${f._mmproj_path}"`;
|
||||
}
|
||||
if (_isWindows()) {
|
||||
const _lcpServer = `${lcPrefix}${py} -m llama_cpp.server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} --n_gpu_layers ${f.ngl || '99'} --n_ctx ${f.ctx || '8192'}${_lcpExtra}`;
|
||||
const _lcServer = `${lcPrefix}llama-server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} -ngl ${f.ngl || '99'} -c ${f.ctx || '8192'}${_lcExtra}`;
|
||||
const _lcpServer = `${lcPrefix}${py} -m llama_cpp.server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} --n_gpu_layers ${f.ngl || '99'} --n_ctx ${f.ctx || '8192'}${_lcpExtra}`;
|
||||
if (_localWindows) {
|
||||
// Local Windows serve is launched through Git Bash, so use the native
|
||||
// llama-server shape and let PATH resolve the CUDA Release wrapper.
|
||||
cmd += _lcServer;
|
||||
} else if (_isWin) {
|
||||
cmd += _lcpServer;
|
||||
} else {
|
||||
cmd += `${lcPrefix}llama-server --model ${modelArg} --host 0.0.0.0 --port ${f.port || '8080'} -ngl ${f.ngl || '99'} -c ${f.ctx || '8192'}${_lcExtra}`;
|
||||
cmd += _lcServer;
|
||||
}
|
||||
if (needsGgufPrelude) {
|
||||
cmd = `MODEL_FILE=${ggufPath} && { [ -n "$MODEL_FILE" ] && [ -f "$MODEL_FILE" ]; } || { echo "ERROR: No GGUF found on this host"; exit 1; } && ${cmd}`;
|
||||
@@ -1515,7 +1533,7 @@ async function _fetchDependencies() {
|
||||
|
||||
// Wire the installed-package menu.
|
||||
function _showDepMenu(anchor) {
|
||||
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
|
||||
document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove);
|
||||
const row = anchor.closest('.cookbook-dep-row');
|
||||
if (!row) return;
|
||||
const pipName = row.dataset.depPip;
|
||||
@@ -1528,7 +1546,7 @@ async function _fetchDependencies() {
|
||||
const minW = 150;
|
||||
let left = Math.min(rect.right - minW, window.innerWidth - minW - 8);
|
||||
left = Math.max(8, left);
|
||||
dropdown.style.cssText = `position:fixed;display:block;z-index:10001;top:${rect.bottom + 6}px;left:${left}px;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||
dropdown.style.cssText = `position:fixed;display:block;z-index:${topPortalZ()};top:${rect.bottom + 6}px;left:${left}px;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||
const upIco = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>';
|
||||
const it = document.createElement('div');
|
||||
it.className = 'dropdown-item-compact';
|
||||
@@ -1536,7 +1554,7 @@ async function _fetchDependencies() {
|
||||
it.title = `Update ${pkgName} to the latest version (pip install -U)`;
|
||||
it.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
close();
|
||||
await _installDep(pipName, pkgName, isLocalOnly, true, null);
|
||||
});
|
||||
dropdown.appendChild(it);
|
||||
@@ -1564,19 +1582,14 @@ async function _fetchDependencies() {
|
||||
dropdown.appendChild(source);
|
||||
}
|
||||
document.body.appendChild(dropdown);
|
||||
const close = (ev) => {
|
||||
if (!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target)) {
|
||||
dropdown.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) =>
|
||||
!dropdown.contains(ev.target) && ev.target !== anchor && !anchor.contains(ev.target));
|
||||
}
|
||||
list.querySelectorAll('.cookbook-dep-installed-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (document.querySelector('.cookbook-dep-menu')) {
|
||||
document.querySelectorAll('.cookbook-dep-menu').forEach(d => d.remove());
|
||||
document.querySelectorAll('.cookbook-dep-menu').forEach(dismissOrRemove);
|
||||
return;
|
||||
}
|
||||
_showDepMenu(btn);
|
||||
@@ -2616,13 +2629,14 @@ function _renderRecipes() {
|
||||
const isLocal = !s.host || s.host.toLowerCase() === 'local';
|
||||
if (isLocal) {
|
||||
s.host = '';
|
||||
s.platform = _envState.hostPlatform || '';
|
||||
if (_localSeen) return false;
|
||||
_localSeen = true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!_localSeen) {
|
||||
_es.servers.unshift({ host: '', env: _es.env || 'none', envPath: _es.envPath || '', modelDir: '~/.cache/huggingface/hub' });
|
||||
_es.servers.unshift({ host: '', env: _es.env || 'none', envPath: _es.envPath || '', modelDir: '~/.cache/huggingface/hub', platform: _envState.hostPlatform || '' });
|
||||
}
|
||||
if (_es.remoteHost && !_es.servers.some(s => s.host === _es.remoteHost)) {
|
||||
_es.servers.push({ host: _es.remoteHost, env: _es.env || 'none', envPath: _es.envPath || '', modelDir: '~/.cache/huggingface/hub' });
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Pure port helpers extracted so they're unit-testable without the
|
||||
// browser-bound rest of cookbookRunning.js (issue #4507 follow-up).
|
||||
|
||||
// Read the port out of a serve launch command. Handles --port 8000,
|
||||
// --port=8000, -p 8000, and -p=8000. Returns '' when none is present.
|
||||
export function portOf(cmd) {
|
||||
const s = cmd || '';
|
||||
const m = s.match(/--port[=\s]+(\d+)/) || s.match(/(?:^|\s)-p[=\s]+(\d+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
// Lowest free port >= start that isn't in usedPorts (array or Set of
|
||||
// numbers/strings). Returns a string to match the serve command format.
|
||||
export function nextFreePort(usedPorts, start = 8000) {
|
||||
const used = new Set([...usedPorts].map(p => parseInt(p, 10)));
|
||||
let port = start;
|
||||
while (used.has(port)) port++;
|
||||
return String(port);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import uiModule from './ui.js';
|
||||
import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis.js';
|
||||
import { registerMenuDismiss } from './escMenuStack.js';
|
||||
import { computeProgressSignal } from './cookbookProgressSignal.js';
|
||||
import { portOf, nextFreePort } from './cookbookPorts.js';
|
||||
|
||||
// Human-friendly badge label for a task's internal status. Avoids surfacing
|
||||
// the word "error" in the sidebar — a server the user stopped or one that
|
||||
@@ -266,9 +267,7 @@ function _taskHostLabel(task) {
|
||||
}
|
||||
|
||||
function _taskPort(task) {
|
||||
const cmd = task?.payload?._cmd || '';
|
||||
const match = cmd.match(/--port\s+(\d+)/);
|
||||
return match ? match[1] : '';
|
||||
return portOf(task?.payload?._cmd || '');
|
||||
}
|
||||
|
||||
function _buildCrashReport(task, outputText) {
|
||||
@@ -455,16 +454,14 @@ function _nextAvailablePort() {
|
||||
const usedPorts = new Set();
|
||||
tasks.forEach(t => {
|
||||
if (t.type === 'serve' && (t.status === 'running' || t.status === 'queued')) {
|
||||
const m = t.payload?._cmd?.match(/--port\s+(\d+)/);
|
||||
if (m) usedPorts.add(parseInt(m[1]));
|
||||
const p = _taskPort(t);
|
||||
if (p) usedPorts.add(parseInt(p));
|
||||
}
|
||||
});
|
||||
presets.forEach(p => {
|
||||
if (p.port) usedPorts.add(parseInt(p.port));
|
||||
});
|
||||
let port = 8000;
|
||||
while (usedPorts.has(port)) port++;
|
||||
return String(port);
|
||||
return nextFreePort(usedPorts);
|
||||
}
|
||||
|
||||
// ── Endpoint cleanup ──
|
||||
@@ -784,6 +781,7 @@ function _stripStateSecrets(state) {
|
||||
const safe = { ...state };
|
||||
if (safe.env && typeof safe.env === 'object') {
|
||||
const { hfToken, ...env } = safe.env;
|
||||
delete env.hostPlatform;
|
||||
safe.env = env;
|
||||
}
|
||||
if (Array.isArray(safe.tasks)) safe.tasks = safe.tasks.map(_redactTaskForStorage);
|
||||
@@ -1676,7 +1674,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
|
||||
|| _envState.servers.find(s => s.host === _host) || {};
|
||||
const _serverMetaKey = _targetKey || (_hsrv && _serverKey ? _serverKey(_hsrv) : '') || (_host || 'local');
|
||||
const _serverMetaName = targetMeta?.serverName || _hsrv.name || (_host ? _host : 'Local');
|
||||
const _hplatform = _host ? (_hsrv.platform || '') : (_envState.platform || '');
|
||||
const _hplatform = _host ? (_hsrv.platform || '') : (_envState.hostPlatform || '');
|
||||
const _replaceTaskId = fields?._replaceTaskId || '';
|
||||
if (_replaceTaskId) {
|
||||
try {
|
||||
@@ -1691,7 +1689,6 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Replace any serve already targeting this same host:port — you can't run two
|
||||
// servers on one port, so re-serving (or retrying) should stop & remove the
|
||||
// old one instead of leaving a dead duplicate behind. (The retry buttons
|
||||
@@ -4005,4 +4002,4 @@ export function initRunning(shared) {
|
||||
}
|
||||
|
||||
// Also export _retryDownload and _nextAvailablePort for use by other modules
|
||||
export { _retryDownload, _nextAvailablePort, _processQueue };
|
||||
export { _retryDownload, _nextAvailablePort, _processQueue, _taskPort };
|
||||
|
||||
+63
-34
@@ -11,6 +11,7 @@ import { modelColor } from './chatRenderer.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
import { openCookbookDependencies } from './cookbook-diagnosis.js';
|
||||
import { _hwfitCache } from './cookbook-hwfit.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
|
||||
// Shared state/functions injected by init()
|
||||
let _envState;
|
||||
@@ -577,7 +578,7 @@ function _selectedServeTarget(panel) {
|
||||
env: server?.env || '',
|
||||
port: host ? (server?.port || _getPort(host) || '') : '',
|
||||
venv,
|
||||
platform: server?.platform || _envState.platform || '',
|
||||
platform: host ? (server?.platform || '') : (_envState.hostPlatform || ''),
|
||||
label,
|
||||
};
|
||||
}
|
||||
@@ -708,6 +709,12 @@ function _selectedGgufSizeGb(model, relPath) {
|
||||
return bytes / (1024 ** 3);
|
||||
}
|
||||
|
||||
function _projectorGgufFiles(model) {
|
||||
return _ggufFilesForModel(model)
|
||||
.filter(f => (f.role || '') === 'projector' || /(^|\/)mmproj[^/]*\.gguf$/i.test(f.rel_path || f.name || ''))
|
||||
.sort((a, b) => String(a.rel_path || a.name || '').localeCompare(String(b.rel_path || b.name || '')));
|
||||
}
|
||||
|
||||
function _ggufFileLabel(file) {
|
||||
const base = (file.name || file.rel_path || '').split('/').pop();
|
||||
const size = _formatGgufSize(file.size_bytes);
|
||||
@@ -1070,7 +1077,7 @@ function _rerenderCachedModels() {
|
||||
cancelDiv.addEventListener('click', () => { closeDropdown(); });
|
||||
dropdown.appendChild(cancelDiv);
|
||||
const rect = btn.getBoundingClientRect();
|
||||
dropdown.style.cssText = `position:fixed;z-index:10001;visibility:hidden;top:0;right:${window.innerWidth-rect.right}px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.3);font-size:12px;`;
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};visibility:hidden;top:0;right:${window.innerWidth-rect.right}px;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:4px;box-shadow:0 8px 24px rgba(0,0,0,0.3);font-size:12px;`;
|
||||
document.body.appendChild(dropdown);
|
||||
// Clamp into the VISIBLE area (visualViewport, not innerHeight — they differ
|
||||
// on mobile under the dynamic toolbar). Flip above the button if there's no
|
||||
@@ -1249,6 +1256,7 @@ function _rerenderCachedModels() {
|
||||
: `This model's download isn't complete yet (${esc(m.size || 'partial')}). The serve will start but is likely to crash on a missing shard. Wait for the download to finish, or relaunch after it's done.`;
|
||||
panelHtml += `<div class="hwfit-serve-warn" style="margin:0 0 8px;padding:6px 10px;border-radius:5px;font-size:11px;background:color-mix(in srgb, var(--color-warning, #f0ad4e) 14%, transparent);border:1px solid color-mix(in srgb, var(--color-warning, #f0ad4e) 40%, transparent);color:var(--color-warning, #f0ad4e);display:flex;gap:6px;align-items:flex-start;line-height:1.4;"><span aria-hidden="true">⚠</span><span>${_warnText}</span></div>`;
|
||||
}
|
||||
panelHtml += `<div class="hwfit-serve-vision-warn" style="display:none;margin:0 0 8px;padding:6px 10px;border-radius:5px;font-size:11px;background:color-mix(in srgb, var(--color-warning, #f0ad4e) 14%, transparent);border:1px solid color-mix(in srgb, var(--color-warning, #f0ad4e) 40%, transparent);color:var(--color-warning, #f0ad4e);gap:6px;align-items:flex-start;line-height:1.4;"><span aria-hidden="true">⚠</span><span>Vision is enabled, but no mmproj GGUF projector was found in the cached model scan. Download an mmproj-*.gguf for this model, then refresh the cached model list before launching.</span></div>`;
|
||||
// Row 1: Engine + Server + Env
|
||||
panelHtml += `<div class="hwfit-serve-row">`;
|
||||
const backendOpts = _backendChoices.map(([v,l]) => `<option value="${v}"${defaultBackend===v?' selected':''}>${l}</option>`).join('');
|
||||
@@ -1574,6 +1582,11 @@ function _rerenderCachedModels() {
|
||||
if (el.type === 'checkbox') f[el.dataset.field] = el.checked;
|
||||
else f[el.dataset.field] = el.value;
|
||||
});
|
||||
const buildTarget = _selectedServeTarget(panel);
|
||||
f.host = buildTarget.host || '';
|
||||
f.platform = buildTarget.platform || '';
|
||||
const hostField = panel.querySelector('[data-field="host"]');
|
||||
if (hostField) hostField.value = f.host;
|
||||
const backend = f.backend || 'vllm';
|
||||
const serveModel = (f.model_path || '').trim() || (m.is_local_dir && m.path ? `${m.path}/${repo}` : repo);
|
||||
if (backend === 'llamacpp') {
|
||||
@@ -1593,11 +1606,11 @@ function _rerenderCachedModels() {
|
||||
: m.is_local_dir && m.path
|
||||
? `$({ find ${_ldir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${_ldir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`
|
||||
: `$({ find ${dir} -name '*-00001-of-*.gguf' 2>/dev/null | sort; find ${dir} -name '*.gguf' 2>/dev/null | sort; } | head -1)`;
|
||||
// Vision: auto-find the mmproj (CLIP/projector) file in the same dir.
|
||||
// Resolved at runtime so the toggle just works if an mmproj-*.gguf is
|
||||
// present (downloaded alongside the model). Empty if none → cmd omits it.
|
||||
const _vsearchdir = (m.is_local_dir && m.path) ? _ldir : dir;
|
||||
f._mmproj_path = `$(find ${_vsearchdir} -iname 'mmproj*.gguf' 2>/dev/null | sort | head -1)`;
|
||||
// Vision: use the scanned projector (CLIP/mmproj) file when present.
|
||||
// Keeping this as a printf path avoids generating a command substitution
|
||||
// that the backend serve-command validator must reject as unsafe.
|
||||
const selectedProjector = _projectorGgufFiles(m)[0];
|
||||
f._mmproj_path = selectedProjector ? _selectedGgufExpr(m, repo, selectedProjector.rel_path) : '';
|
||||
}
|
||||
if (f.reasoning_parser) {
|
||||
const _rpEl2 = panel.querySelector('[data-field="reasoning_parser"]');
|
||||
@@ -1613,6 +1626,10 @@ function _rerenderCachedModels() {
|
||||
}
|
||||
let cmd = _buildServeCmd(f, serveModel, backend);
|
||||
if (f.extra && f.extra.trim()) cmd += ' ' + f.extra.trim();
|
||||
const missingVisionProjector = backend === 'llamacpp' && !!f.vision && !f._mmproj_path;
|
||||
panel._visionMissingProjector = missingVisionProjector;
|
||||
const _visionWarn = panel.querySelector('.hwfit-serve-vision-warn');
|
||||
if (_visionWarn) _visionWarn.style.display = missingVisionProjector ? 'flex' : 'none';
|
||||
const _ce2 = panel.querySelector('.hwfit-serve-cmd'); _ce2.value = _formatServeCmdPreview(cmd); _ce2.style.height = 'auto'; _ce2.style.height = _ce2.scrollHeight + 'px';
|
||||
panel._cmd = cmd;
|
||||
panel._host = f.host || '';
|
||||
@@ -2218,7 +2235,7 @@ function _rerenderCachedModels() {
|
||||
// Cap width/height to the viewport and start hidden — we clamp the final
|
||||
// position after mount (below) using the menu's real measured size, so it
|
||||
// can't run off-screen on a narrow mobile viewport.
|
||||
dropdown.style.cssText = `position:fixed;display:block;visibility:hidden;z-index:10001;top:0;left:0;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||
dropdown.style.cssText = `position:fixed;display:block;visibility:hidden;z-index:${topPortalZ()};top:0;left:0;right:auto;min-width:${minW}px;max-width:calc(100vw - 16px);max-height:calc(100vh - 24px);overflow-y:auto;box-sizing:border-box;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||
|
||||
if (!modelSlots.length) {
|
||||
const empty = document.createElement('div');
|
||||
@@ -2989,12 +3006,16 @@ function _rerenderCachedModels() {
|
||||
});
|
||||
serveState.backend = serveState.backend || (_detectBackend(m).backend) || 'vllm';
|
||||
const launchTarget = _selectedServeTarget(panel);
|
||||
if (serveState.backend === 'llamacpp' && serveState.vision && !/(?:^|\s)(?:--mmproj|--clip_model_path)\b/.test(launchCmd)) {
|
||||
_restoreLaunchBtn();
|
||||
uiModule.showToast('Vision is checked, but no mmproj projector is in the launch command. Refresh cached models after downloading mmproj, or add --mmproj manually.', 8000);
|
||||
return;
|
||||
}
|
||||
if (serveState.backend === 'diffusers' && _remoteWindowsDiffusersUnsupported(launchTarget)) {
|
||||
_restoreLaunchBtn();
|
||||
uiModule.showToast('Diffusers serving is not supported on remote Windows servers yet. Use local Windows or a Linux server.', 9000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-launch: check our own task list for a serve already running
|
||||
// on this host. Offer to stop+launch as the default action — the
|
||||
// SSH-based port probe below is more thorough but it can miss
|
||||
@@ -3009,33 +3030,41 @@ function _rerenderCachedModels() {
|
||||
&& ((t.remoteHost || '') === _hostStr || (t.remoteServerKey || '') === _serverKeyStr)
|
||||
&& (t.status === 'running' || t.status === 'ready' || t._serveReady)
|
||||
);
|
||||
// Only block when the new model's port genuinely collides with
|
||||
// a running serve. Different ports coexist fine (issue #4507).
|
||||
if (_active.length) {
|
||||
const _names = _active.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean);
|
||||
const _ok = await window.styledConfirm(
|
||||
`${_active.length} model${_active.length === 1 ? '' : 's'} already serving on ${_hostStr || 'local'} (${_names.join(', ')}). Port 8000 will collide. Stop the running model and launch this one?`,
|
||||
{ title: 'Server already running', confirmText: 'Stop & launch', cancelText: 'Cancel' },
|
||||
);
|
||||
if (!_ok) { _restoreLaunchBtn(); return; }
|
||||
// Kill each active serve; prefer the rendered Stop button so
|
||||
// endpoint cleanup + Ollama unload run normally. Fall back to
|
||||
// a raw tmux kill when the Active tab isn't in the DOM.
|
||||
for (const t of _active) {
|
||||
try {
|
||||
const _el = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
|
||||
const _btn = _el?.querySelector('.cookbook-task-action-stop');
|
||||
if (_btn) {
|
||||
_btn.click();
|
||||
} else if (_runningMod._tmuxGracefulKill) {
|
||||
await fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _runningMod._tmuxGracefulKill(t) }),
|
||||
});
|
||||
}
|
||||
} catch (_killErr) { /* best-effort */ }
|
||||
const _newPort = (launchCmd.match(/--port[=\s]+(\d+)/) || [])[1] || '';
|
||||
const _clashing = _newPort
|
||||
? _active.filter(t => _runningMod._taskPort(t) === _newPort)
|
||||
: _active;
|
||||
if (_clashing.length) {
|
||||
const _names = _clashing.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean);
|
||||
const _portNote = _newPort ? ` on port ${_newPort}` : '';
|
||||
const _ok = await window.styledConfirm(
|
||||
`${_clashing.length} model${_clashing.length === 1 ? '' : 's'} already serving on ${_hostStr || 'local'} (${_names.join(', ')})${_portNote}. Stop it and launch this one?`,
|
||||
{ title: _newPort ? `Port ${_newPort} in use` : 'Server already running', confirmText: 'Stop & launch', cancelText: 'Cancel' },
|
||||
);
|
||||
if (!_ok) { _restoreLaunchBtn(); return; }
|
||||
// Kill each clashing serve; prefer the rendered Stop button so
|
||||
// endpoint cleanup + Ollama unload run normally. Fall back to
|
||||
// a raw tmux kill when the Active tab isn't in the DOM.
|
||||
for (const t of _clashing) {
|
||||
try {
|
||||
const _el = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
|
||||
const _btn = _el?.querySelector('.cookbook-task-action-stop');
|
||||
if (_btn) {
|
||||
_btn.click();
|
||||
} else if (_runningMod._tmuxGracefulKill) {
|
||||
await fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _runningMod._tmuxGracefulKill(t) }),
|
||||
});
|
||||
}
|
||||
} catch (_killErr) { /* best-effort */ }
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 2500));
|
||||
}
|
||||
// Give the OS a beat to release port 8000.
|
||||
await new Promise(r => setTimeout(r, 2500));
|
||||
}
|
||||
} catch (_e) { /* best-effort */ }
|
||||
|
||||
|
||||
+22
-38
@@ -16,6 +16,7 @@ import spinnerModule from './spinner.js';
|
||||
import { openLibrary, closeLibrary, isLibraryOpen, initLibrary } from './documentLibrary.js';
|
||||
import signatureModule from './signature.js';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
let API_BASE = '';
|
||||
let isOpen = false;
|
||||
@@ -673,7 +674,7 @@ import * as Modals from './modalManager.js';
|
||||
overlay.className = 'modal pdf-export-overlay';
|
||||
overlay.style.cssText = 'pointer-events:auto;background:rgba(0,0,0,0.5);backdrop-filter:blur(4px);';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-content" style="width:min(780px,94vw);max-height:86vh;">
|
||||
<div class="modal-content" style="width:min(780px,94vw);">
|
||||
<div class="modal-header">
|
||||
<h4>Export filled PDF</h4>
|
||||
<button id="pdf-export-close" class="modal-close" title="Close">×</button>
|
||||
@@ -3592,7 +3593,10 @@ import * as Modals from './modalManager.js';
|
||||
let _docAiReplyChoiceMenu = null;
|
||||
function _closeDocAiReplyChoice() {
|
||||
if (_docAiReplyChoiceMenu) {
|
||||
try { _docAiReplyChoiceMenu.remove(); } catch (_) {}
|
||||
// Tear down through the menu's registered dismiss (drops its outside-click
|
||||
// listener + Escape-stack entry) rather than orphaning them with a raw
|
||||
// remove(); the onClose below nulls the ref.
|
||||
try { dismissOrRemove(_docAiReplyChoiceMenu); } catch (_) {}
|
||||
_docAiReplyChoiceMenu = null;
|
||||
}
|
||||
}
|
||||
@@ -3643,6 +3647,14 @@ import * as Modals from './modalManager.js';
|
||||
const noteInput = menu.querySelector('[data-note-input]');
|
||||
setTimeout(() => noteInput?.focus(), 0);
|
||||
menu.addEventListener('mousedown', (ev) => ev.stopPropagation());
|
||||
document.body.appendChild(menu);
|
||||
_docAiReplyChoiceMenu = menu;
|
||||
// Outside-click AND Escape both route through the central esc-stack via
|
||||
// bindMenuDismiss; onClose owns the actual teardown (node removal + state).
|
||||
const close = bindMenuDismiss(menu, () => {
|
||||
try { menu.remove(); } catch (_) {}
|
||||
if (_docAiReplyChoiceMenu === menu) _docAiReplyChoiceMenu = null;
|
||||
});
|
||||
menu.addEventListener('click', async (ev) => {
|
||||
const choice = ev.target.closest('[data-mode]');
|
||||
if (!choice) return;
|
||||
@@ -3650,26 +3662,9 @@ import * as Modals from './modalManager.js';
|
||||
ev.stopPropagation();
|
||||
const mode = choice.getAttribute('data-mode') || 'ai-reply-fast';
|
||||
const noteHint = (noteInput?.value || '').trim();
|
||||
_closeDocAiReplyChoice();
|
||||
close();
|
||||
await _aiReply({ mode, noteHint });
|
||||
});
|
||||
document.body.appendChild(menu);
|
||||
_docAiReplyChoiceMenu = menu;
|
||||
const outsideClose = (ev) => {
|
||||
if (menu.contains(ev.target)) return;
|
||||
document.removeEventListener('click', outsideClose, true);
|
||||
_closeDocAiReplyChoice();
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', outsideClose, true), 0);
|
||||
// Esc to close.
|
||||
const escClose = (ev) => {
|
||||
if (ev.key === 'Escape') {
|
||||
ev.stopPropagation();
|
||||
document.removeEventListener('keydown', escClose, true);
|
||||
_closeDocAiReplyChoice();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', escClose, true);
|
||||
}
|
||||
|
||||
async function _aiReply(opts = {}) {
|
||||
@@ -8926,9 +8921,10 @@ import * as Modals from './modalManager.js';
|
||||
|
||||
function showExportMenu(e, anchorRect) {
|
||||
if (e) e.stopPropagation();
|
||||
// Remove existing menu if any
|
||||
// Remove existing menu if any (toggle off) — tear it down through its
|
||||
// registered dismiss so the outside-click listener + Escape-stack entry go.
|
||||
const existing = document.getElementById('doc-export-menu');
|
||||
if (existing) { existing.remove(); return; }
|
||||
if (existing) { dismissOrRemove(existing); return; }
|
||||
|
||||
// Position from provided rect, clicked element, or fallback to language select
|
||||
const rect = anchorRect
|
||||
@@ -8978,7 +8974,7 @@ import * as Modals from './modalManager.js';
|
||||
const item = document.createElement('button');
|
||||
item.className = 'doc-overflow-item';
|
||||
item.textContent = opt.label;
|
||||
item.addEventListener('click', (ev) => { ev.stopPropagation(); menu.remove(); opt.fn(); });
|
||||
item.addEventListener('click', (ev) => { ev.stopPropagation(); close(); opt.fn(); });
|
||||
menu.appendChild(item);
|
||||
if (opt._divider) {
|
||||
const sep = document.createElement('div');
|
||||
@@ -8996,21 +8992,9 @@ import * as Modals from './modalManager.js';
|
||||
menu.style.top = 'auto';
|
||||
menu.style.bottom = (window.innerHeight - rect.top + 2) + 'px';
|
||||
}
|
||||
const close = (ev) => {
|
||||
if (ev && ev.type === 'keydown') {
|
||||
if (ev.key !== 'Escape') return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ev.stopImmediatePropagation?.();
|
||||
} else if (ev && menu.contains(ev.target)) {
|
||||
return;
|
||||
}
|
||||
menu.remove();
|
||||
document.removeEventListener('click', close);
|
||||
document.removeEventListener('keydown', close, true);
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close), 100);
|
||||
document.addEventListener('keydown', close, true);
|
||||
// Outside-click AND Escape both route through the central esc-stack via
|
||||
// bindMenuDismiss; onClose owns the actual node removal.
|
||||
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||
}
|
||||
|
||||
function exportAsHtml() {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Extracted from document.js to reduce file size.
|
||||
*/
|
||||
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
import uiModule from './ui.js';
|
||||
import sessionModule from './sessions.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
@@ -227,7 +228,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
dd.style.right = (window.innerWidth - rect.right) + 'px';
|
||||
dd.style.top = (rect.bottom + 2) + 'px';
|
||||
dd.style.display = 'block';
|
||||
dd.style.zIndex = '100000';
|
||||
dd.style.zIndex = String(topPortalZ());
|
||||
requestAnimationFrame(() => {
|
||||
const mr = dd.getBoundingClientRect();
|
||||
if (mr.bottom > window.innerHeight - 8) {
|
||||
@@ -629,7 +630,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
const rect = menuBtn.getBoundingClientRect();
|
||||
document.body.appendChild(dropdown);
|
||||
dropdown.dataset.owner = doc.id;
|
||||
dropdown.style.cssText = 'position:fixed;z-index:10000;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;display:block;';
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};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;display:block;`;
|
||||
dropdown.style.top = (rect.bottom + 4) + 'px';
|
||||
dropdown.style.left = 'auto';
|
||||
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
|
||||
@@ -1595,7 +1596,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
modal.className = 'modal';
|
||||
modal.id = 'doclib-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content doclib-modal-content" style="width:min(640px, 92vw);max-height:85vh;background:var(--bg);">
|
||||
<div class="modal-content doclib-modal-content" style="width:min(640px, 92vw);background:var(--bg);">
|
||||
<div class="modal-header">
|
||||
<!-- Header title + icon mirror the currently-active sub-tab (Chats /
|
||||
Documents / Research / Archive) so the user sees ONE icon at
|
||||
|
||||
+6
-11
@@ -10,6 +10,7 @@ import * as Modals from './modalManager.js';
|
||||
import { applyEdgeDock } from './modalSnap.js';
|
||||
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
|
||||
import { emailApiUrl, emailAccountQuery } from './emailShared.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
const _acct = () => emailAccountQuery('&');
|
||||
@@ -1019,7 +1020,7 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
}
|
||||
|
||||
function _showEmailMenu(em, anchor, itemEl) {
|
||||
document.querySelectorAll('.email-dropdown').forEach(d => d.remove());
|
||||
document.querySelectorAll('.email-dropdown').forEach(dismissOrRemove);
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'dropdown email-dropdown show';
|
||||
@@ -1042,7 +1043,7 @@ function _showEmailMenu(em, anchor, itemEl) {
|
||||
_showRemindSubmenu(em, dropdown);
|
||||
return;
|
||||
}
|
||||
dropdown.remove();
|
||||
close();
|
||||
a.action();
|
||||
});
|
||||
dropdown.appendChild(menuItem);
|
||||
@@ -1050,13 +1051,7 @@ function _showEmailMenu(em, anchor, itemEl) {
|
||||
|
||||
anchor.appendChild(dropdown);
|
||||
|
||||
const close = (e) => {
|
||||
if (!dropdown.contains(e.target) && !anchor.contains(e.target)) {
|
||||
dropdown.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && !anchor.contains(ev.target));
|
||||
}
|
||||
|
||||
// ---- Reminder submenu (creates a Note with a reminder for this email) ----
|
||||
@@ -1091,7 +1086,7 @@ function _showRemindSubmenu(em, parentDropdown) {
|
||||
item.innerHTML = `<span>${p.label}</span><span style="margin-left:auto;opacity:0.5;font-size:10px;">${p.sub}</span>`;
|
||||
item.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
parentDropdown.remove();
|
||||
dismissOrRemove(parentDropdown);
|
||||
await _createReplyReminder(em, p.date);
|
||||
});
|
||||
parentDropdown.appendChild(item);
|
||||
@@ -1101,7 +1096,7 @@ function _showRemindSubmenu(em, parentDropdown) {
|
||||
customItem.innerHTML = '<span>Pick date and time…</span>';
|
||||
customItem.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
parentDropdown.remove();
|
||||
dismissOrRemove(parentDropdown);
|
||||
const tmp = document.createElement('input');
|
||||
tmp.type = 'datetime-local';
|
||||
const def = new Date(tomorrow);
|
||||
|
||||
+32
-50
@@ -8,6 +8,7 @@ import { styledConfirm, showToast, emptyStateIcon } from './ui.js';
|
||||
import { folderDisplayName, sortedFolders } from './emailInbox.js';
|
||||
import settingsModule from './settings.js';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import {
|
||||
_esc, _escLinkify, _extractName, _parseTurnMeta,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
import { state } from './emailLibrary/state.js';
|
||||
import { collapseSidebarToRail } from './modalSnap.js';
|
||||
import { emailApiUrl } from './emailShared.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
let _emailUnreadChipClickWired = false;
|
||||
@@ -1137,7 +1139,7 @@ export function openEmailLibrary(opts = {}) {
|
||||
modal.className = 'modal';
|
||||
modal.id = 'email-lib-modal';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content doclib-modal-content" style="width:min(720px, 92vw);max-height:85vh;background:var(--bg);">
|
||||
<div class="modal-content doclib-modal-content" style="width:min(720px, 92vw);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;">
|
||||
@@ -5653,7 +5655,7 @@ async function _openEmailAsTab(em, folder) {
|
||||
modal.className = 'modal email-reader-tab-modal';
|
||||
modal.id = modalId;
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content doclib-modal-content email-reader-tab-content" style="background:var(--bg);width:min(720px, 92vw);max-height:85vh;display:flex;flex-direction:column;">
|
||||
<div class="modal-content doclib-modal-content email-reader-tab-content" style="background:var(--bg);width:min(720px, 92vw);display:flex;flex-direction:column;">
|
||||
<div class="modal-header">
|
||||
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex:1;">
|
||||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-left:8px;">${_esc(em.subject || '(no subject)')}</span>
|
||||
@@ -5890,7 +5892,7 @@ async function _openEmailWindow(em, folder) {
|
||||
modal.id = winId;
|
||||
modal.style.cssText = 'pointer-events:none;background:transparent;';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content email-window-content" style="width:min(640px, 92vw);max-height:80vh;display:flex;flex-direction:column;background:var(--bg);">
|
||||
<div class="modal-content email-window-content" style="width:min(640px, 92vw);display:flex;flex-direction:column;background:var(--bg);">
|
||||
<div class="modal-header">
|
||||
<h4 style="display:flex;align-items:center;gap:6px;min-width:0;flex: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="flex-shrink:0;"><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>
|
||||
@@ -6368,23 +6370,19 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
// Toggle: if a dropdown for THIS anchor is already open, close it.
|
||||
const existing = document.querySelector('.email-card-dropdown');
|
||||
if (existing && existing._anchor === anchor) {
|
||||
existing.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
dismissOrRemove(existing);
|
||||
return;
|
||||
}
|
||||
// Otherwise close any other open dropdown (and clear its anchor's active
|
||||
// state) before opening a fresh one.
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(d => {
|
||||
if (d._anchor) d._anchor.classList.remove('reader-more-active');
|
||||
d.remove();
|
||||
});
|
||||
// Otherwise close any other open dropdown (its own teardown clears its
|
||||
// anchor's active state) before opening a fresh one.
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'email-card-dropdown';
|
||||
dropdown._anchor = anchor;
|
||||
anchor.classList.add('reader-more-active');
|
||||
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;right:${window.innerWidth - rect.right}px;`;
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};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;right:${window.innerWidth - rect.right}px;`;
|
||||
|
||||
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
|
||||
const _unreadIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
||||
@@ -6603,8 +6601,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
_showEmailTranslateSubmenu(reader, dropdown);
|
||||
return;
|
||||
}
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
close();
|
||||
a.action();
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
@@ -6617,30 +6614,25 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
|
||||
cancelItem.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
close();
|
||||
});
|
||||
dropdown.appendChild(cancelItem);
|
||||
|
||||
document.body.appendChild(dropdown);
|
||||
_fitEmailDropdown(dropdown, rect);
|
||||
const close = (ev) => {
|
||||
if (!dropdown.contains(ev.target) && !anchor.contains(ev.target)) {
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
const close = bindMenuDismiss(dropdown, () => {
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||
}
|
||||
|
||||
function _showCardMenu(em, anchor) {
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove());
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
|
||||
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'email-card-dropdown';
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:140px;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;right:${window.innerWidth - rect.right}px;`;
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:140px;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;right:${window.innerWidth - rect.right}px;`;
|
||||
|
||||
const _icon = (svg) => `<span class="dropdown-icon">${svg}</span>`;
|
||||
const _replyIcon = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>';
|
||||
@@ -6806,8 +6798,7 @@ function _showCardMenu(em, anchor) {
|
||||
_showLibRemindSubmenu(em, dropdown);
|
||||
return;
|
||||
}
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
close();
|
||||
a.action();
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
@@ -6820,30 +6811,25 @@ function _showCardMenu(em, anchor) {
|
||||
cancelItem.innerHTML = _icon(_cancelIco) + '<span>Cancel</span>';
|
||||
cancelItem.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
close();
|
||||
});
|
||||
dropdown.appendChild(cancelItem);
|
||||
|
||||
document.body.appendChild(dropdown);
|
||||
_fitEmailDropdown(dropdown, rect);
|
||||
const close = (ev) => {
|
||||
if (!dropdown.contains(ev.target) && !anchor.contains(ev.target)) {
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
const close = bindMenuDismiss(dropdown, () => {
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||
}
|
||||
|
||||
// Bulk "Actions" dropdown for select mode — Delete is a separate visible button.
|
||||
function _showBulkActionsMenu(anchor) {
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(d => d.remove());
|
||||
document.querySelectorAll('.email-card-dropdown').forEach(dismissOrRemove);
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'email-card-dropdown email-bulk-menu';
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
dropdown.style.cssText = `position:fixed;z-index:10001;min-width:160px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:${rect.left}px;`;
|
||||
dropdown.style.cssText = `position:fixed;z-index:${topPortalZ()};min-width:160px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:4px;font-size:12px;top:${rect.bottom + 4}px;left:${rect.left}px;`;
|
||||
const _readIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4 20-7z"/></svg>';
|
||||
const _unreadIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor"/></svg>';
|
||||
const _doneIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
@@ -6856,7 +6842,7 @@ function _showBulkActionsMenu(anchor) {
|
||||
const it = document.createElement('div');
|
||||
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
||||
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
|
||||
it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); });
|
||||
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
|
||||
dropdown.appendChild(it);
|
||||
}
|
||||
// Mobile-only Cancel — matches the per-card and sidebar dropdowns.
|
||||
@@ -6866,7 +6852,7 @@ function _showBulkActionsMenu(anchor) {
|
||||
cancelIt.innerHTML = `<span class="dropdown-icon">${_cancelIco2}</span><span>Cancel</span>`;
|
||||
cancelIt.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.remove();
|
||||
close();
|
||||
// Cancel inside the bulk-Actions menu also exits select mode — matches the
|
||||
// documents bulk dropdown.
|
||||
state._selectMode = false;
|
||||
@@ -6877,13 +6863,9 @@ function _showBulkActionsMenu(anchor) {
|
||||
dropdown.appendChild(cancelIt);
|
||||
document.body.appendChild(dropdown);
|
||||
_fitEmailDropdown(dropdown, rect);
|
||||
const close = (ev) => {
|
||||
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
|
||||
dropdown.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
const close = bindMenuDismiss(dropdown, () => {
|
||||
dropdown.remove();
|
||||
}, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||
}
|
||||
|
||||
function _updateBulkBar() {
|
||||
@@ -7142,7 +7124,7 @@ function _showAiReplyChoice(btn, em, data) {
|
||||
`max-height:${window.innerHeight - 16}px`,
|
||||
'overflow:auto',
|
||||
'box-sizing:border-box',
|
||||
'z-index:10060',
|
||||
`z-index:${topPortalZ()}`,
|
||||
'display:flex',
|
||||
'gap:6px',
|
||||
'padding:6px',
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* faces (😂, 👍, 😎) have no text form and are intentionally excluded.
|
||||
*/
|
||||
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
|
||||
// Each entry: [char, label, svgPath OR svg]
|
||||
// SVG icons matching Lucide style (24x24 viewBox, 2 stroke)
|
||||
const I = (path) => `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`;
|
||||
@@ -158,7 +160,7 @@ function togglePicker(anchor, target) {
|
||||
_pickerEl.style.position = 'fixed';
|
||||
_pickerEl.style.top = (rect.bottom + 4) + 'px';
|
||||
_pickerEl.style.left = rect.left + 'px';
|
||||
_pickerEl.style.zIndex = '10000';
|
||||
_pickerEl.style.zIndex = String(topPortalZ());
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const pr = _pickerEl.getBoundingClientRect();
|
||||
|
||||
+7
-11
@@ -6,6 +6,8 @@ import uiModule from './ui.js';
|
||||
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
let _open = false;
|
||||
@@ -2515,7 +2517,7 @@ export function openGallery() {
|
||||
// shares the exact same dropdown style/behaviour.
|
||||
const _bulkActionsBtn = document.getElementById('gallery-bulk-actions');
|
||||
function _showGalleryBulkMenu(anchor) {
|
||||
document.querySelectorAll('.gallery-bulk-menu').forEach(d => d.remove());
|
||||
document.querySelectorAll('.gallery-bulk-menu').forEach(dismissOrRemove);
|
||||
// Standard Odysseus dropdown (.dropdown + dropdown-item-compact) so it
|
||||
// matches every other menu in the app. Positioned fixed at the button.
|
||||
const dropdown = document.createElement('div');
|
||||
@@ -2524,7 +2526,7 @@ export function openGallery() {
|
||||
const left = Math.min(rect.left, window.innerWidth - 200);
|
||||
// Inline the standard dropdown look so it renders correctly even where the
|
||||
// `.dropdown` rule is scoped out (e.g. hover-only media queries on mobile).
|
||||
dropdown.style.cssText = `position:fixed;display:block;z-index:10001;top:${rect.bottom + 6}px;left:${Math.max(8, left)}px;right:auto;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||
dropdown.style.cssText = `position:fixed;display:block;z-index:${topPortalZ()};top:${rect.bottom + 6}px;left:${Math.max(8, left)}px;right:auto;min-width:180px;background:var(--panel,var(--bg));border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,0.3);padding:6px;font-size:11px;`;
|
||||
const _favIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 21s-6.7-4.35-9.33-8.04C.9 10.3 1.4 6.9 4.1 5.6c1.9-.9 4 .03 5 1.7 1-1.67 3.1-2.6 5-1.7 2.7 1.3 3.2 4.7 1.43 7.36C18.7 16.65 12 21 12 21z"/></svg>';
|
||||
const _tagIco = '<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.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>';
|
||||
const _dlIco = '<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="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>';
|
||||
@@ -2549,17 +2551,11 @@ export function openGallery() {
|
||||
const it = document.createElement('div');
|
||||
it.className = 'dropdown-item-compact' + (a.danger ? ' dropdown-item-danger' : '');
|
||||
it.innerHTML = `<span class="dropdown-icon">${a.icon}</span><span>${a.label}</span>`;
|
||||
it.addEventListener('click', (e) => { e.stopPropagation(); dropdown.remove(); a.action(); });
|
||||
it.addEventListener('click', (e) => { e.stopPropagation(); close(); a.action(); });
|
||||
dropdown.appendChild(it);
|
||||
}
|
||||
document.body.appendChild(dropdown);
|
||||
const close = (ev) => {
|
||||
if (!dropdown.contains(ev.target) && ev.target !== anchor) {
|
||||
dropdown.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 10);
|
||||
const close = bindMenuDismiss(dropdown, () => { dropdown.remove(); }, (ev) => !dropdown.contains(ev.target) && ev.target !== anchor);
|
||||
}
|
||||
|
||||
_bulkActionsBtn?.addEventListener('click', (e) => {
|
||||
@@ -2568,7 +2564,7 @@ export function openGallery() {
|
||||
// should close it. The outside-click handler explicitly skips clicks on
|
||||
// the anchor, so the button itself has to do its own dismiss.
|
||||
const existing = document.querySelector('.gallery-bulk-menu');
|
||||
if (existing) { existing.remove(); return; }
|
||||
if (existing) { dismissOrRemove(existing); return; }
|
||||
if (!_selectedIds().length) { uiModule.showToast('Select photos first'); return; }
|
||||
_showGalleryBulkMenu(e.currentTarget);
|
||||
});
|
||||
|
||||
+72
-13
@@ -6,7 +6,7 @@ import markdownModule from './markdown.js';
|
||||
import chatRenderer from './chatRenderer.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { PROMPT_TEMPLATES, getAllPresets } from './presets.js';
|
||||
import { PROMPT_TEMPLATES, getUserTemplates } from './presets.js';
|
||||
import { sortModelObjects } from './modelSort.js';
|
||||
import Storage from './storage.js';
|
||||
|
||||
@@ -89,12 +89,16 @@ function _initGroupTab() {
|
||||
|
||||
const charSel = document.createElement('select');
|
||||
charSel.className = 'preset-input';
|
||||
// add an identifier that this is a character selection
|
||||
charSel.dataset.selectionType = "character"
|
||||
charSel.style.cssText = 'font-size:11px;flex:1;height:26px;';
|
||||
charSel.innerHTML = '<option value="">Empty...</option>' +
|
||||
characters.map(c => '<option value="' + c.id + '">' + uiModule.esc(c.name) + '</option>').join('');
|
||||
|
||||
const modelSel = document.createElement('select');
|
||||
modelSel.className = 'preset-input';
|
||||
// add an identifier that this is a model selection
|
||||
modelSel.dataset.selectionType = "model"
|
||||
modelSel.style.cssText = 'font-size:11px;flex:1;height:26px;';
|
||||
modelSel.innerHTML = '<option value="">Model…</option>' +
|
||||
models.map(m => '<option value="' + m.mid + '">' + uiModule.esc(m.display) + '</option>').join('');
|
||||
@@ -196,15 +200,67 @@ function _initGroupTab() {
|
||||
});
|
||||
|
||||
const groupTab = document.querySelector('.preset-tab[data-chartab="group"]');
|
||||
// whenever a user navigates to the Group tab
|
||||
if (groupTab) groupTab.addEventListener('click', () => {
|
||||
_modelsCache = null;
|
||||
if (startBtn) startBtn.textContent = 'Start Group';
|
||||
_loadGroupPresets();
|
||||
if (_groupParticipants.length === 0) {
|
||||
|
||||
const isGroupTabUnInitialized =
|
||||
_groupParticipants.length === 0 && participantsEl.children.length === 0;
|
||||
|
||||
if (isGroupTabUnInitialized) {
|
||||
setTimeout(() => addBtn.click(), 100);
|
||||
} else {
|
||||
// queue this asynchronously since repopulating the selection drop-downs
|
||||
// do not need to be visible right away; it can be safely delayed before
|
||||
// the next event loop
|
||||
queueMicrotask(() => {
|
||||
repopulateExistingSelections();
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
async function repopulateExistingSelections() {
|
||||
const EMPTY = "";
|
||||
|
||||
const characterSelections = participantsEl.querySelectorAll("select.preset-input[data-selection-type=character]");
|
||||
const modelSelections = participantsEl.querySelectorAll("select.preset-input[data-selection-type=model]");
|
||||
|
||||
if (characterSelections.length !== 0) {
|
||||
const characters = await _getCharacterList();
|
||||
|
||||
characterSelections.forEach((characterSelection) => {
|
||||
|
||||
const chosenCharacter = characterSelection.value;
|
||||
const isChosenCharacterExisting = chosenCharacter !== EMPTY
|
||||
&& characters.findIndex((char) => char.id === chosenCharacter) !== -1;
|
||||
|
||||
characterSelection.innerHTML = '<option value="">Empty...</option>' +
|
||||
characters.map(c => '<option value="' + c.id + '">' + uiModule.esc(c.name) + '</option>').join('');
|
||||
if (isChosenCharacterExisting) {
|
||||
characterSelection.value = chosenCharacter;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (modelSelections.length !== 0) {
|
||||
const models = await _getModels();
|
||||
|
||||
modelSelections.forEach((modelSelection) => {
|
||||
const chosenModel = modelSelection.value;
|
||||
const isChosenModelExisting = chosenModel !== EMPTY
|
||||
&& models.findIndex((model) => model.mid === chosenModel) !== -1;
|
||||
|
||||
modelSelection.innerHTML = '<option value="">Model…</option>' +
|
||||
models.map(m => '<option value="' + m.mid + '">' + uiModule.esc(m.display) + '</option>').join('');
|
||||
if (isChosenModelExisting) {
|
||||
modelSelection.value = chosenModel;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load and render saved group presets
|
||||
async function _loadGroupPresets() {
|
||||
try {
|
||||
@@ -288,17 +344,6 @@ async function _getCharacterList() {
|
||||
const chars = PROMPT_TEMPLATES.filter(t => t.isCharacter).map(t => ({
|
||||
id: t.id, name: t.name, prompt: t.prompt,
|
||||
}));
|
||||
// User-created characters from presets
|
||||
try {
|
||||
const allPresets = getAllPresets();
|
||||
if (allPresets && allPresets.custom && allPresets.custom.character_name) {
|
||||
chars.push({
|
||||
id: 'custom',
|
||||
name: allPresets.custom.character_name,
|
||||
prompt: allPresets.custom.system_prompt || allPresets.custom.prompt || '',
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
// 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.
|
||||
@@ -306,12 +351,26 @@ async function _getCharacterList() {
|
||||
const r = await fetch(API_BASE + '/api/presets/templates', { credentials: 'same-origin' });
|
||||
const data = await r.json();
|
||||
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) {}
|
||||
|
||||
// Also merge in-memory templates from presets.js — these may include
|
||||
// newly created characters whose async save-to-API hasn't completed yet.
|
||||
const memTemplates = getUserTemplates();
|
||||
|
||||
if (Array.isArray(memTemplates)) {
|
||||
memTemplates.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 || '' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return chars;
|
||||
}
|
||||
|
||||
|
||||
+22
-6
@@ -483,6 +483,7 @@ export function processWithThinking(text) {
|
||||
export function mdToHtml(src, opts) {
|
||||
const allowedHtmlBlocks = [];
|
||||
const codeBlocks = [];
|
||||
const inlineCodeBlocks = [];
|
||||
const mermaidBlocks = [];
|
||||
let s = (src ?? '');
|
||||
|
||||
@@ -521,6 +522,19 @@ export function mdToHtml(src, opts) {
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// Extract inline code spans before the link/autolink/HTML passes, mirroring
|
||||
// the fenced-block handling above. A URL inside `inline code` (e.g.
|
||||
// `irm http://127.0.0.1:3000/x`) is preceded by a space, so the bare-URL
|
||||
// autolink matches it, wraps it in an <a> tag, and swaps that for an
|
||||
// ___ALLOWED_HTML_ placeholder — corrupting the command. The old inline-code
|
||||
// pass ran after those passes, too late to protect it.
|
||||
s = s.replace(/`([^`]+?)`/g, (match, code) => {
|
||||
if (code.startsWith('___CODE_BLOCK_') || code.startsWith('___MERMAID_BLOCK_')) return match;
|
||||
const placeholder = `___INLINE_CODE_${inlineCodeBlocks.length}___`;
|
||||
inlineCodeBlocks.push(`<code>${escapeHtml(code)}</code>`);
|
||||
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.
|
||||
@@ -678,12 +692,6 @@ export function mdToHtml(src, opts) {
|
||||
return html;
|
||||
});
|
||||
|
||||
// Inline code (but not placeholders)
|
||||
s = s.replace(/`([^`]+?)`/g, (match, code) => {
|
||||
if (code.startsWith('___CODE_BLOCK_') || code.startsWith('___ALLOWED_HTML_')) return match;
|
||||
return `<code>${code}</code>`;
|
||||
});
|
||||
|
||||
// Horizontal rules (must come before bold/italic to avoid * conflicts)
|
||||
s = s.replace(/^(?:---|\*\*\*|___)\s*$/gm, '<hr>');
|
||||
|
||||
@@ -756,6 +764,14 @@ export function mdToHtml(src, opts) {
|
||||
s = s.replace(`___CODE_BLOCK_${index}___`, block);
|
||||
});
|
||||
|
||||
// Restore inline code spans last, so placeholders carried inside restored
|
||||
// <a>/allowed-HTML blocks are resolved too. The function replacer keeps the
|
||||
// escaped code literal — e.g. a shell snippet like `echo $1` is not treated
|
||||
// as a regex back-reference.
|
||||
inlineCodeBlocks.forEach((block, index) => {
|
||||
s = s.replace(`___INLINE_CODE_${index}___`, () => block);
|
||||
});
|
||||
|
||||
return _useSvgEmoji() ? svgifyEmoji(s, opts) : s;
|
||||
}
|
||||
|
||||
|
||||
+8
-1
@@ -6,6 +6,7 @@ import sessionModule from './sessions.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { snapModalToZone } from './tileManager.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
|
||||
var escapeHtml = uiModule.esc;
|
||||
|
||||
@@ -865,7 +866,13 @@ export function renderMemoryList() {
|
||||
dropdown.style.top = rect.bottom + 2 + 'px';
|
||||
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
|
||||
dropdown.style.left = 'auto';
|
||||
dropdown.style.zIndex = '10001';
|
||||
// Portaled to <body>, so it must outrank the Brain modal it belongs to.
|
||||
// Tool modals get a monotonically increasing z-index from modalManager's
|
||||
// bring-to-front counter, which climbs unbounded over a long session —
|
||||
// once it passed the old hardcoded 10001 the menu rendered behind the
|
||||
// panel (#4720). topPortalZ() derives the value from the live tool-window
|
||||
// stack so the menu always sits just above, however high it has climbed.
|
||||
dropdown.style.zIndex = String(topPortalZ());
|
||||
dropdown.style.display = 'block';
|
||||
document.body.appendChild(dropdown);
|
||||
// Keep on-screen (mobile): flip above the button if it overflows the
|
||||
|
||||
+93
-18
@@ -10,7 +10,8 @@ import { attachColorPicker } from './colorPicker.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { snapModalToZone } from './tileManager.js';
|
||||
import { applyEdgeDock, clearDockSide } from './modalSnap.js';
|
||||
import { topToolWindowZ } from './toolWindowZOrder.js';
|
||||
import { topToolWindowZ, topPortalZ } from './toolWindowZOrder.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
let _open = false;
|
||||
@@ -1834,6 +1835,9 @@ function _renderNotes() {
|
||||
<button class="note-checkbox-agent${agentDoneClass}" data-note-id="${_attrEsc(note.id)}" data-idx="${i}"${agentSessionAttr} data-agent-title="${_attrEsc(agentMenuTitle)}" title="${_attrEsc(agentTitle)}">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M15 13v2M9 13v2"/></svg>
|
||||
</button>
|
||||
<button class="note-checkbox-edit" data-note-id="${note.id}" data-idx="${i}" title="Edit item">
|
||||
<svg width="10" height="10" 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>
|
||||
<button class="note-checkbox-rm" data-note-id="${note.id}" data-idx="${i}" title="Delete item">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
@@ -2517,6 +2521,85 @@ function _bindCardEvents(body) {
|
||||
});
|
||||
});
|
||||
|
||||
function _startChecklistItemEdit(noteId, idx, span) {
|
||||
if (span.isContentEditable) return;
|
||||
const note = _notes.find(n => n.id === noteId);
|
||||
if (!note || !Array.isArray(note.items) || !note.items[idx]) return;
|
||||
|
||||
span.textContent = note.items[idx].text || '';
|
||||
span.contentEditable = "true";
|
||||
span.spellcheck = false;
|
||||
span.focus();
|
||||
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(span);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
const save = () => {
|
||||
if (!span.isContentEditable) return;
|
||||
span.contentEditable = "false";
|
||||
const newText = span.textContent.trim();
|
||||
const oldText = (note.items[idx].text || '').trim();
|
||||
|
||||
if (newText === oldText) {
|
||||
_renderNotes();
|
||||
return;
|
||||
}
|
||||
|
||||
const oldItem = note.items[idx];
|
||||
if (!newText) {
|
||||
note.items.splice(idx, 1);
|
||||
} else {
|
||||
note.items[idx].text = newText;
|
||||
}
|
||||
|
||||
_patchNote(noteId, { items: note.items }).catch(() => {
|
||||
if (!newText) note.items.splice(idx, 0, oldItem);
|
||||
else note.items[idx].text = oldText;
|
||||
_renderNotes();
|
||||
uiModule.showError('Failed to update item');
|
||||
});
|
||||
_renderNotes();
|
||||
};
|
||||
|
||||
const onKeydown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
save();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
span.contentEditable = "false";
|
||||
_renderNotes();
|
||||
}
|
||||
};
|
||||
|
||||
span.addEventListener('blur', save, { once: true });
|
||||
span.addEventListener('keydown', onKeydown);
|
||||
}
|
||||
|
||||
// Edit a single checklist item (hover Edit button)
|
||||
body.querySelectorAll('.note-checkbox-edit').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (_selectMode) return;
|
||||
const noteId = btn.dataset.noteId;
|
||||
const idx = parseInt(btn.dataset.idx);
|
||||
const span = btn.parentElement.querySelector('.note-check-text');
|
||||
if (span) _startChecklistItemEdit(noteId, idx, span);
|
||||
});
|
||||
});
|
||||
|
||||
// Prevent clicks from toggling the row while actively editing inline
|
||||
body.querySelectorAll('.note-check-text').forEach(span => {
|
||||
span.addEventListener('click', (e) => {
|
||||
if (span.isContentEditable) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Per-item agent solve (hover button next to the X). Scoped to one todo
|
||||
// item — uses the note title as context if present, but only the single
|
||||
// item's text as the work. Mirrors the per-note _agentSolveNote pattern.
|
||||
@@ -3364,7 +3447,7 @@ function _buildForm(note = null) {
|
||||
|
||||
function _pickCustomDate() {
|
||||
// Replace the dropdown menu with a small inline picker
|
||||
document.querySelectorAll('.note-reminder-menu').forEach(m => m.remove());
|
||||
document.querySelectorAll('.note-reminder-menu').forEach(dismissOrRemove);
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'note-reminder-menu';
|
||||
const initial = dueInput.value || _toLocalDatetimeStr(_tomorrowDate());
|
||||
@@ -3398,14 +3481,11 @@ function _buildForm(note = null) {
|
||||
if (typeof dInput.showPicker === 'function') {
|
||||
try { dInput.showPicker(); } catch {}
|
||||
}
|
||||
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||
menu.querySelector('.note-reminder-menu-confirm').addEventListener('click', () => {
|
||||
if (dInput.value) _setReminder(dInput.value);
|
||||
menu.remove();
|
||||
close();
|
||||
});
|
||||
setTimeout(() => {
|
||||
const close = (e) => { if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', close); } };
|
||||
document.addEventListener('click', close);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
if (remindBtn) remindBtn.addEventListener('click', (e) => { e.stopPropagation(); _openReminderMenu(remindBtn, !!dueInput.value); });
|
||||
@@ -4315,7 +4395,7 @@ function _serializeNoteForCopy(note) {
|
||||
// toast. Shared by the corner-copy button click and the Ctrl/Cmd+C shortcut.
|
||||
// ── ⋯ corner menu (Copy + Agent) ───────────────────────────────────
|
||||
function _openNoteCornerMenu(btn) {
|
||||
document.querySelectorAll('.note-corner-menu-dropdown').forEach(d => d.remove());
|
||||
document.querySelectorAll('.note-corner-menu-dropdown').forEach(dismissOrRemove);
|
||||
const id = btn.dataset.noteId;
|
||||
const note = _notes.find(n => n.id === id);
|
||||
if (!note) return;
|
||||
@@ -4341,15 +4421,10 @@ function _openNoteCornerMenu(btn) {
|
||||
const mh = menu.offsetHeight || 96;
|
||||
const below = window.innerHeight - r.bottom;
|
||||
const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
|
||||
menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;`;
|
||||
const close = (ev) => {
|
||||
if (ev && menu.contains(ev.target)) return;
|
||||
menu.remove();
|
||||
document.removeEventListener('click', close, true);
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
||||
menu.querySelector('[data-act="copy"]').addEventListener('click', () => { menu.remove(); _copyNote(id, btn); });
|
||||
menu.querySelector('[data-act="agent"]').addEventListener('click', () => { menu.remove(); _agentSolveNote(id); });
|
||||
menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;`;
|
||||
const close = bindMenuDismiss(menu, () => { menu.remove(); });
|
||||
menu.querySelector('[data-act="copy"]').addEventListener('click', () => { close(); _copyNote(id, btn); });
|
||||
menu.querySelector('[data-act="agent"]').addEventListener('click', () => { close(); _agentSolveNote(id); });
|
||||
}
|
||||
|
||||
function _positionNoteMenu(menu, btn, width = 196) {
|
||||
@@ -4360,7 +4435,7 @@ function _positionNoteMenu(menu, btn, width = 196) {
|
||||
const mh = menu.offsetHeight || 112;
|
||||
const below = window.innerHeight - r.bottom;
|
||||
const top = (below < mh + 8 && r.top > mh + 8) ? (r.top - mh - 4) : (r.bottom + 4);
|
||||
menu.style.cssText += `position:fixed;z-index:11000;top:${Math.round(top)}px;left:${Math.round(left)}px;min-width:${width}px;`;
|
||||
menu.style.cssText += `position:fixed;z-index:${topPortalZ()};top:${Math.round(top)}px;left:${Math.round(left)}px;min-width:${width}px;`;
|
||||
const close = (ev) => {
|
||||
if (ev && menu.contains(ev.target)) return;
|
||||
menu.remove();
|
||||
|
||||
+48
-7
@@ -830,15 +830,48 @@ export async function saveCustomPreset(showToast, showError) {
|
||||
const _selVal = document.getElementById('char-template-select')?.value || '';
|
||||
const isBuiltinPreset = PROMPT_TEMPLATES.some(t => t.isPreset && (t.name === name || t.name === _selVal));
|
||||
const saveName = isBuiltinPreset ? null : (name || null);
|
||||
|
||||
if (saveName) {
|
||||
fetch(`${API_BASE}/api/presets/templates`, {
|
||||
method: 'POST',
|
||||
const _existing = userTemplates.find(t => t.name === saveName);
|
||||
let clone;
|
||||
const _entry = {
|
||||
id: _existing && _existing.id
|
||||
|| 'user-' + Math.random().toString(16).slice(2, 10),
|
||||
name: saveName,
|
||||
// use ?? since it's more semantic for null-coalescing
|
||||
system_prompt: system_prompt ?? '',
|
||||
temperature: config.temperature,
|
||||
max_tokens: config.max_tokens,
|
||||
}
|
||||
const ENDPOINT = `${API_BASE}/api/presets/templates`;
|
||||
|
||||
// Optimistically update the in-memory templates list by @michaelxer
|
||||
if (_existing) {
|
||||
// slow but works for now
|
||||
clone = JSON.parse(JSON.stringify(_existing));
|
||||
|
||||
Object.assign(_existing, _entry);
|
||||
} else {
|
||||
userTemplates.push(_entry);
|
||||
}
|
||||
|
||||
fetch(ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: (userTemplates.find(t => t.name === saveName) || {}).id || '',
|
||||
name: saveName, system_prompt, temperature: config.temperature, max_tokens: config.max_tokens,
|
||||
}),
|
||||
}).then(r => { if (r.ok) loadUserTemplates(); }).catch(() => {});
|
||||
body: JSON.stringify(_entry)
|
||||
}).then((r) => {
|
||||
if (r.ok) {
|
||||
loadUserTemplates();
|
||||
}
|
||||
}).catch(() => {
|
||||
if (clone) {
|
||||
Object.assign(_existing, clone);
|
||||
}
|
||||
|
||||
if (showError) {
|
||||
showError(_isInjectStart ? "Something went wrong. Saved prompt has been undone." : "Something went wrong. Saved persona has been undone.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (showToast) {
|
||||
@@ -883,6 +916,13 @@ export function getAllPresets() {
|
||||
return presets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the in-memory user templates list (may be stale; call loadUserTemplates first if freshness matters).
|
||||
*/
|
||||
export function getUserTemplates() {
|
||||
return [...userTemplates];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the character name (if set)
|
||||
*/
|
||||
@@ -1099,6 +1139,7 @@ const presetsModule = {
|
||||
getSelectedPreset,
|
||||
getPreset,
|
||||
getAllPresets,
|
||||
getUserTemplates,
|
||||
getCharacterName,
|
||||
onSessionSwitch,
|
||||
isPersistentChat,
|
||||
|
||||
+12
-3
@@ -133,11 +133,20 @@ export function providerLabel(endpointUrl) {
|
||||
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];
|
||||
// Not a full URL (e.g. bare host[:port]) — strip scheme/path best-effort.
|
||||
const stripped = endpointUrl.replace(/^[a-z]+:\/\//i, "").split("/")[0];
|
||||
const colonIdx = stripped.lastIndexOf(":");
|
||||
host = colonIdx >= 0 ? stripped.slice(0, colonIdx) : stripped;
|
||||
}
|
||||
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)) {
|
||||
const isLoopback = /^(localhost|127\.|0\.0\.0\.0|::1)/.test(host);
|
||||
if (isLoopback) {
|
||||
// Don't name the serving tool from the port — it isn't authoritative
|
||||
// (vLLM/SGLang/llama.cpp share 8000/8080). Discovery identifies the tool by
|
||||
// probing /props and stores the result as the endpoint's name instead.
|
||||
return "Local";
|
||||
}
|
||||
if (/^(192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\.)/i.test(host)) {
|
||||
return "Local";
|
||||
}
|
||||
for (const [re, label] of _ENDPOINT_LABELS) {
|
||||
|
||||
@@ -2286,6 +2286,7 @@ async function _onSessionListKeydown(e) {
|
||||
}
|
||||
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (item.querySelector('.session-rename-input')) return;
|
||||
e.preventDefault();
|
||||
const sid = item.dataset.sessionId;
|
||||
const s = sessions.find(x => x.id === sid);
|
||||
|
||||
+16
-9
@@ -8,6 +8,7 @@ import { clearDockSide } from './modalSnap.js';
|
||||
import { sortModelIds } from './modelSort.js';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { isAltGrEvent } from './platform.js';
|
||||
import { bindMenuDismiss } from './escMenuStack.js';
|
||||
|
||||
let initialized = false;
|
||||
let modalEl = null;
|
||||
@@ -3845,7 +3846,10 @@ async function initUnifiedIntegrations() {
|
||||
if (lbl) lbl.textContent = text;
|
||||
if (ico) ico.innerHTML = _apiIconFor(k);
|
||||
};
|
||||
const _close = () => { menu.style.display = 'none'; };
|
||||
// Menu is reused (hidden, not recreated). close() hides it and tears down
|
||||
// its outside-click listener + Escape-stack entry; bindMenuDismiss is
|
||||
// re-registered fresh on each open (see _open).
|
||||
let _close = () => { menu.style.display = 'none'; };
|
||||
const _open = () => {
|
||||
menu.style.display = 'block';
|
||||
const tRect = trig.getBoundingClientRect();
|
||||
@@ -3854,8 +3858,7 @@ async function initUnifiedIntegrations() {
|
||||
const above = tRect.top;
|
||||
if (mRect.height > below && above > below) { menu.style.top = 'auto'; menu.style.bottom = 'calc(100% + 2px)'; }
|
||||
else { menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto'; }
|
||||
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trig) { _close(); document.removeEventListener('click', onDoc, true); } };
|
||||
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
|
||||
_close = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trig);
|
||||
};
|
||||
trig.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _close() : _open(); });
|
||||
menu.querySelectorAll('.ufapi-option').forEach(btn => {
|
||||
@@ -4591,7 +4594,10 @@ async function initUnifiedIntegrations() {
|
||||
if (labelEl) labelEl.textContent = lbl;
|
||||
if (iconEl) iconEl.innerHTML = PROV_LOGO[k] || _customLogo;
|
||||
};
|
||||
const _closeMenu = () => { menu.style.display = 'none'; };
|
||||
// Menu is reused (hidden, not recreated). _closeMenu hides it and tears
|
||||
// down its outside-click listener + Escape-stack entry; bindMenuDismiss is
|
||||
// re-registered fresh on each open (see _openMenu).
|
||||
let _closeMenu = () => { menu.style.display = 'none'; };
|
||||
const _openMenu = () => {
|
||||
menu.style.display = 'block';
|
||||
// Drop-up when there's not enough room below the trigger.
|
||||
@@ -4604,8 +4610,7 @@ async function initUnifiedIntegrations() {
|
||||
} else {
|
||||
menu.style.top = 'calc(100% + 2px)'; menu.style.bottom = 'auto';
|
||||
}
|
||||
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== trigger) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
|
||||
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
|
||||
_closeMenu = bindMenuDismiss(menu, () => { menu.style.display = 'none'; }, (ev) => !menu.contains(ev.target) && ev.target !== trigger);
|
||||
};
|
||||
trigger.addEventListener('click', (e) => { e.stopPropagation(); menu.style.display === 'block' ? _closeMenu() : _openMenu(); });
|
||||
menu.querySelectorAll('.ufp-option').forEach(btn => {
|
||||
@@ -5657,8 +5662,11 @@ async function initUnifiedIntegrations() {
|
||||
addBtn.parentElement.style.position = 'relative';
|
||||
addBtn.parentElement.classList.add('uf-add-anchor');
|
||||
}
|
||||
// Menu is created per open and removed on close. _closeMenu routes through
|
||||
// the bindMenuDismiss close() bound when the menu opens, so the outside-click
|
||||
// listener + Escape-stack entry are torn down alongside the node removal.
|
||||
let _menuEl = null;
|
||||
const _closeMenu = () => { if (_menuEl) { _menuEl.remove(); _menuEl = null; } };
|
||||
let _closeMenu = () => {};
|
||||
addBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (_menuEl) { _closeMenu(); return; }
|
||||
@@ -5690,8 +5698,7 @@ async function initUnifiedIntegrations() {
|
||||
showForm(k, 'new');
|
||||
});
|
||||
});
|
||||
const onDoc = (ev) => { if (!menu.contains(ev.target) && ev.target !== addBtn) { _closeMenu(); document.removeEventListener('click', onDoc, true); } };
|
||||
setTimeout(() => document.addEventListener('click', onDoc, true), 0);
|
||||
_closeMenu = bindMenuDismiss(menu, () => { menu.remove(); _menuEl = null; }, (ev) => !menu.contains(ev.target) && ev.target !== addBtn);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+12
-7
@@ -7,6 +7,8 @@
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import * as spinnerModule from './spinner.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
|
||||
const API = window.location.origin;
|
||||
let skills = [];
|
||||
@@ -391,14 +393,14 @@ function _svg(paths, { fill = 'none', size = 13 } = {}) {
|
||||
// Kebab dropdown for a collapsed skill card — same actions + icons as the
|
||||
// expanded footer (Publish/Unpublish · Edit · Delete).
|
||||
function _openSkillMenu(btn, card, sk, name, isPublished) {
|
||||
document.querySelectorAll('.skill-kebab-menu').forEach(m => m.remove());
|
||||
document.querySelectorAll('.skill-kebab-menu').forEach(dismissOrRemove);
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'skill-kebab-menu';
|
||||
const mk = (paths, label, opts, onClick) => {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'skill-kebab-item' + (opts && opts.danger ? ' danger' : '');
|
||||
item.innerHTML = _svg(paths, opts) + `<span>${label}</span>`;
|
||||
item.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); onClick(); });
|
||||
item.addEventListener('click', (e) => { e.stopPropagation(); close(); onClick(); });
|
||||
menu.appendChild(item);
|
||||
};
|
||||
if (isPublished) mk(_ICON.unpublish, 'Unpublish', {}, () => _setSkillStatus(name, 'draft'));
|
||||
@@ -410,7 +412,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
|
||||
selItem.innerHTML = '<svg class="memory-select-btn-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/></svg><span>Select</span>';
|
||||
selItem.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
menu.remove();
|
||||
close();
|
||||
if (!_selectMode) _enterSelectMode();
|
||||
_selectedNames.add(name);
|
||||
renderSkillsList();
|
||||
@@ -432,10 +434,14 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
|
||||
const cancelItem = document.createElement('button');
|
||||
cancelItem.className = 'skill-kebab-item dropdown-cancel-mobile';
|
||||
cancelItem.innerHTML = '<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>Cancel</span>';
|
||||
cancelItem.addEventListener('click', (e) => { e.stopPropagation(); menu.remove(); });
|
||||
cancelItem.addEventListener('click', (e) => { e.stopPropagation(); close(); });
|
||||
menu.appendChild(cancelItem);
|
||||
|
||||
document.body.appendChild(menu);
|
||||
// Override the CSS z-index (100002) with a value derived from the live
|
||||
// tool-window stack so the kebab menu stays above its modal even after the
|
||||
// bring-to-front counter climbs past the static value (#4720).
|
||||
menu.style.zIndex = String(topPortalZ());
|
||||
const r = btn.getBoundingClientRect();
|
||||
menu.style.top = (r.bottom + 4) + 'px';
|
||||
menu.style.right = Math.max(6, window.innerWidth - r.right) + 'px';
|
||||
@@ -453,8 +459,7 @@ function _openSkillMenu(btn, card, sk, name, isPublished) {
|
||||
menu.style.maxHeight = Math.max(80, window.innerHeight - 12 - mr2.top) + 'px';
|
||||
menu.style.overflowY = 'auto';
|
||||
}
|
||||
const close = (ev) => { if (!menu.contains(ev.target)) { menu.remove(); document.removeEventListener('click', close, true); } };
|
||||
setTimeout(() => document.addEventListener('click', close, true), 0);
|
||||
const close = bindMenuDismiss(menu, () => { menu.remove(); }, (ev) => !menu.contains(ev.target));
|
||||
}
|
||||
|
||||
// Cards for the agent's built-in tool capabilities (from
|
||||
@@ -1802,7 +1807,7 @@ async function _showSkillSource(name) {
|
||||
wrap.className = 'modal';
|
||||
wrap.style.display = 'block';
|
||||
wrap.innerHTML = `
|
||||
<div class="modal-content" style="max-width:760px;max-height:85vh;display:flex;flex-direction:column">
|
||||
<div class="modal-content" style="max-width:760px;display:flex;flex-direction:column">
|
||||
<div class="modal-header">
|
||||
<h4>SKILL.md — <code>${esc(name)}</code></h4>
|
||||
<span style="flex:1"></span>
|
||||
|
||||
@@ -101,6 +101,8 @@ function _setupProviderFromInput(input) {
|
||||
xai: 'xai',
|
||||
grok: 'xai',
|
||||
nvidia: 'nvidia',
|
||||
opencodezen: 'opencode-zen',
|
||||
opencodego: 'opencode-go',
|
||||
};
|
||||
return SETUP_PROVIDER_URLS[aliases[raw] || raw] || null;
|
||||
}
|
||||
@@ -129,6 +131,8 @@ function _extractSetupProviderCredential(input) {
|
||||
['google', 'gemini'], ['gemini', 'gemini'],
|
||||
['x ai', 'xai'], ['xai', 'xai'], ['grok', 'xai'],
|
||||
['nvidia', 'nvidia'],
|
||||
['opencode zen', 'opencode-zen'], ['opencode-zen', 'opencode-zen'],
|
||||
['opencode go', 'opencode-go'], ['opencode-go', 'opencode-go'],
|
||||
];
|
||||
for (const [alias, key] of providerAliases) {
|
||||
const re = new RegExp('(^|\\s|[,;:])(' + alias.replace(/\s+/g, '\\s+') + ')(?=$|\\s|[,;:])', 'i');
|
||||
@@ -204,6 +208,8 @@ function _showSetupEndpointChoices() {
|
||||
'<pre style="margin:4px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">http://localhost:11434/v1</code></pre>' +
|
||||
'<div style="margin-top:4px;">or</div>' +
|
||||
'<pre style="margin:2px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">http://llm-host.local:8000/v1</code></pre>' +
|
||||
'<div style="margin-top:4px;">or llama.cpp (llama-server):</div>' +
|
||||
'<pre style="margin:2px 0 0;"><code class="setup-clickable-code" style="cursor:pointer;text-decoration:underline;" title="Click to fill in chat">http://localhost:8080/v1</code></pre>' +
|
||||
'</div>' +
|
||||
'<div style="border:1px solid var(--border);border-radius:8px;padding:10px 12px;background:color-mix(in srgb,var(--bg) 88%,var(--fg) 12%);">' +
|
||||
'<div style="font-weight:700;margin-bottom:6px;">' + SETUP_API_ICON + 'API setup</div>' +
|
||||
@@ -234,6 +240,12 @@ function _showSetupEndpointChoicesStreamed(options = {}) {
|
||||
text: 'http://llm-host.local:8000/v1',
|
||||
copyText: 'http://llm-host.local:8000/v1',
|
||||
},
|
||||
{ kind: 'p', text: 'or llama.cpp (llama-server):' },
|
||||
{
|
||||
kind: 'code',
|
||||
text: 'http://localhost:8080/v1',
|
||||
copyText: 'http://localhost:8080/v1',
|
||||
},
|
||||
{ kind: 'heading', html: SETUP_API_ICON + 'API setup' },
|
||||
{ kind: 'p', text: 'Paste provider name then API key (example):' },
|
||||
{
|
||||
|
||||
@@ -24,6 +24,7 @@ export const KEYS = {
|
||||
SECTION_ORDER: 'sidebar-section-order',
|
||||
ADMIN_LAST_TAB: 'admin-last-tab',
|
||||
DENSITY: 'odysseus-density',
|
||||
UI_SCALE: 'odysseus-ui-scale',
|
||||
WORKSPACE: 'odysseus-workspace'
|
||||
};
|
||||
|
||||
|
||||
+14
-12
@@ -6,8 +6,10 @@ import uiModule from './ui.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import * as spinnerModule from './spinner.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.js';
|
||||
import { sortModelIds } from './modelSort.js';
|
||||
import { ordinalSuffix } from './util/ordinal.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
let _open = false;
|
||||
@@ -982,7 +984,7 @@ function _showTaskDropdown(anchor, items) {
|
||||
}
|
||||
document.querySelectorAll('.task-dropdown').forEach(d => {
|
||||
if (typeof d._dismiss === 'function') d._dismiss();
|
||||
else d.remove();
|
||||
else dismissOrRemove(d);
|
||||
});
|
||||
const dd = document.createElement('div');
|
||||
dd.className = 'task-dropdown';
|
||||
@@ -999,10 +1001,14 @@ function _showTaskDropdown(anchor, items) {
|
||||
}
|
||||
btn.addEventListener('mouseenter', () => { btn.style.background = 'color-mix(in srgb, var(--fg) 8%, transparent)'; });
|
||||
btn.addEventListener('mouseleave', () => { btn.style.background = 'none'; });
|
||||
btn.addEventListener('click', (e) => { e.stopPropagation(); dd.remove(); item.action(); });
|
||||
btn.addEventListener('click', (e) => { e.stopPropagation(); close(); item.action(); });
|
||||
dd.appendChild(btn);
|
||||
});
|
||||
document.body.appendChild(dd);
|
||||
// Sit above the currently-raised tool modal at any stack depth (#4720): the
|
||||
// modal bring-to-front counter climbs unbounded, so a hardcoded z eventually
|
||||
// loses. topPortalZ() derives the value from the live tool-window stack.
|
||||
dd.style.zIndex = String(topPortalZ());
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
let top = rect.bottom + 4;
|
||||
let left = rect.right - dd.offsetWidth;
|
||||
@@ -1011,20 +1017,16 @@ function _showTaskDropdown(anchor, items) {
|
||||
dd.style.top = top + 'px';
|
||||
dd.style.left = left + 'px';
|
||||
const openedAt = performance.now();
|
||||
const close = (e) => {
|
||||
const close = bindMenuDismiss(dd, () => { dd.remove(); }, (ev) => {
|
||||
// Ignore any clicks that occur within 250ms of the open (covers touch
|
||||
// "ghost click" duplicates that were firing right after pointerup and
|
||||
// removing the dropdown before the user could see it).
|
||||
if (performance.now() - openedAt < 250) return;
|
||||
if (!dd.contains(e.target)) { dd.remove(); document.removeEventListener('click', close); }
|
||||
};
|
||||
// removing the dropdown before the user could see it) — treat as inside.
|
||||
if (performance.now() - openedAt < 250) return false;
|
||||
return !dd.contains(ev.target);
|
||||
});
|
||||
dd._dismiss = () => {
|
||||
dd.remove();
|
||||
document.removeEventListener('click', close);
|
||||
close();
|
||||
};
|
||||
// requestAnimationFrame so the listener is registered AFTER the current
|
||||
// pointer/click event cycle has finished bubbling.
|
||||
requestAnimationFrame(() => document.addEventListener('click', close));
|
||||
}
|
||||
|
||||
// ---- Presets ----
|
||||
|
||||
@@ -39,6 +39,7 @@ const FONT_MAP = {
|
||||
mono: "'Fira Code', monospace",
|
||||
sans: "system-ui, -apple-system, 'Segoe UI', sans-serif",
|
||||
serif: "Georgia, 'Times New Roman', serif",
|
||||
opendyslexic: "'OpenDyslexic', sans-serif",
|
||||
};
|
||||
const DEFAULT_FONT = 'mono';
|
||||
const DEFAULT_DENSITY = 'comfortable';
|
||||
@@ -387,6 +388,20 @@ export function applyFontDensity(font, density) {
|
||||
if (d !== 'comfortable') document.documentElement.classList.add('density-' + d);
|
||||
}
|
||||
|
||||
// UI text-size scale (accessibility). Global and independent of the active
|
||||
// theme, so the chosen size persists across theme switches. Stored as a plain
|
||||
// percentage string ('100' | '110' | '125' | '150').
|
||||
const UI_SCALE_KEY = 'odysseus-ui-scale';
|
||||
const DEFAULT_UI_SCALE = '100';
|
||||
|
||||
export function applyUiScale(scale) {
|
||||
const s = scale || DEFAULT_UI_SCALE;
|
||||
// Only one non-default scale ('125'). Remove any legacy classes too so an
|
||||
// older stored value can't leave a stale zoom applied.
|
||||
document.documentElement.classList.remove('ui-scale-110', 'ui-scale-125', 'ui-scale-140');
|
||||
if (s === '125') document.documentElement.classList.add('ui-scale-125');
|
||||
}
|
||||
|
||||
const _BG_CLASSES = ['bg-pattern-dots',
|
||||
'bg-pattern-synapse', 'bg-pattern-rain', 'bg-pattern-constellations',
|
||||
'bg-pattern-perlin-flow',
|
||||
@@ -1133,6 +1148,18 @@ export function initThemeUI() {
|
||||
const s = getSaved(); if (s) _saveFull(s.name, s.colors);
|
||||
});
|
||||
}
|
||||
const textSizeSelect = document.getElementById('theme-text-size-select');
|
||||
if (textSizeSelect) {
|
||||
const nts = textSizeSelect.cloneNode(true); textSizeSelect.parentNode.replaceChild(nts, textSizeSelect);
|
||||
let initScale = DEFAULT_UI_SCALE;
|
||||
try { initScale = localStorage.getItem(UI_SCALE_KEY) || DEFAULT_UI_SCALE; } catch (e) {}
|
||||
nts.value = initScale;
|
||||
applyUiScale(initScale);
|
||||
nts.addEventListener('change', () => {
|
||||
applyUiScale(nts.value);
|
||||
try { localStorage.setItem(UI_SCALE_KEY, nts.value); } catch (e) {}
|
||||
});
|
||||
}
|
||||
if (patternSelect) {
|
||||
const np = patternSelect.cloneNode(true); patternSelect.parentNode.replaceChild(np, patternSelect);
|
||||
np.value = _initPattern;
|
||||
|
||||
@@ -27,3 +27,20 @@ export function nextToolWindowZ(options = {}) {
|
||||
if (Number.isFinite(currentZ) && currentZ > top) return currentZ;
|
||||
return top + 1;
|
||||
}
|
||||
|
||||
// Dock chips pinned by the minimized-dock drag interactions reach z 10030
|
||||
// (free-drag) / 10020 (mobile rest) — see modalManager.js. A body-portaled
|
||||
// dropdown has to clear those too, not just the open tool-window stack, so this
|
||||
// floor keeps it above a chip even when no modal is currently raised.
|
||||
const DOCK_OVERLAY_FLOOR = 10030;
|
||||
|
||||
// The z a body-portaled dropdown/menu needs so it always sits just above every
|
||||
// open tool window (and the dock chips) right now. Tool modals get a
|
||||
// monotonically increasing z from the bring-to-front counter (modalManager),
|
||||
// which climbs unbounded over a long session — so the hardcoded `z-index: 10001`
|
||||
// these dropdowns historically used eventually rendered them BEHIND their own
|
||||
// modal (#4720). Derive the value from the live stack instead, sharing the same
|
||||
// single source of truth as nextToolWindowZ().
|
||||
export function topPortalZ(options = {}) {
|
||||
return Math.max(topToolWindowZ(options), DOCK_OVERLAY_FLOOR) + 1;
|
||||
}
|
||||
|
||||
+40
-13
@@ -340,19 +340,12 @@ export function showToast(msg, durationOrOpts) {
|
||||
stack.style.cssText = 'display:inline-flex;flex-direction:column;align-items:center;gap:1px;margin-left:10px;line-height:1;';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
// If the caller supplied an SVG icon, prepend it. We trust the icon string
|
||||
// (only set internally) — never accept caller-controlled HTML otherwise.
|
||||
if (actionIcon) {
|
||||
btn.innerHTML = `<span style="display:inline-flex;align-items:center;gap:5px;">${actionIcon}<span></span></span>`;
|
||||
btn.querySelector('span span').textContent = actionLabel;
|
||||
} else {
|
||||
btn.textContent = actionLabel;
|
||||
}
|
||||
// The toast itself is `pointer-events: none` so it doesn't block clicks
|
||||
// beneath it. With an action button we need to flip both the toast AND
|
||||
// the button so the user can actually click Undo. The flag is reset on
|
||||
// the next plain showToast / showError call (those overwrite textContent
|
||||
// which strips the button + we clear inline style at the top below).
|
||||
btn.style.cssText = 'padding:2px 10px;border:1px solid var(--fg);border-radius:4px;background:none;color:var(--fg);cursor:pointer;font-size:12px;pointer-events:auto;display:inline-flex;align-items:center;';
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -362,8 +355,6 @@ export function showToast(msg, durationOrOpts) {
|
||||
});
|
||||
stack.appendChild(btn);
|
||||
|
||||
// Keyboard-shortcut hints (Ctrl+Z / ⌘Z) are meaningless on touch devices —
|
||||
// skip them on mobile so the toast just shows the Undo button.
|
||||
if (actionHint && window.innerWidth > 768) {
|
||||
const hint = document.createElement('span');
|
||||
hint.textContent = actionHint;
|
||||
@@ -372,13 +363,28 @@ export function showToast(msg, durationOrOpts) {
|
||||
}
|
||||
|
||||
toastEl.appendChild(stack);
|
||||
|
||||
toastEl.style.pointerEvents = 'auto';
|
||||
} else {
|
||||
// No action — restore the default non-blocking behavior.
|
||||
toastEl.style.pointerEvents = '';
|
||||
}
|
||||
|
||||
// Close button for all toasts — dismiss without waiting for timeout.
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'toast-close-btn';
|
||||
closeBtn.setAttribute('aria-label', 'Dismiss');
|
||||
closeBtn.title = 'Dismiss';
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
clearTimeout(toastEl._hideTimer);
|
||||
toastEl.classList.add('exiting');
|
||||
toastEl.classList.remove('show');
|
||||
toastEl.style.pointerEvents = '';
|
||||
});
|
||||
toastEl.appendChild(closeBtn);
|
||||
|
||||
// Pin to top-right via CSS — clear any legacy inline overrides so the
|
||||
// slide-in-from-right / slide-out-to-left transition can run cleanly.
|
||||
toastEl.style.left = '';
|
||||
@@ -409,17 +415,38 @@ export function showError(msg) {
|
||||
toastEl = document.getElementById('toast');
|
||||
}
|
||||
_wireToastSwipe(toastEl);
|
||||
toastEl.textContent = msg;
|
||||
toastEl.textContent = '';
|
||||
toastEl.classList.add('error');
|
||||
toastEl.style.left = '';
|
||||
toastEl.style.transform = '';
|
||||
toastEl.classList.remove('exiting');
|
||||
toastEl.classList.add('show');
|
||||
clearTimeout(toastEl._hideTimer);
|
||||
|
||||
const textSpan = document.createElement('span');
|
||||
textSpan.textContent = msg;
|
||||
toastEl.appendChild(textSpan);
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'toast-close-btn';
|
||||
closeBtn.setAttribute('aria-label', 'Dismiss');
|
||||
closeBtn.title = 'Dismiss';
|
||||
closeBtn.textContent = '×';
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
clearTimeout(toastEl._hideTimer);
|
||||
toastEl.classList.add('exiting');
|
||||
toastEl.classList.remove('show');
|
||||
toastEl.style.pointerEvents = '';
|
||||
});
|
||||
toastEl.appendChild(closeBtn);
|
||||
|
||||
toastEl._hideTimer = setTimeout(() => {
|
||||
toastEl.classList.add('exiting');
|
||||
toastEl.classList.remove('show');
|
||||
}, 3000);
|
||||
}, 6000);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+85
-4
@@ -114,6 +114,10 @@ body {
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'Fira Code'; font-weight: 600; font-style: normal; font-display: swap; src: url('/static/fonts/FiraCode-SemiBold.woff2') format('woff2'); }
|
||||
|
||||
/* Self-hosted OpenDyslexic — dyslexia-friendly accessibility font option (SIL OFL 1.1) */
|
||||
@font-face { font-family: 'OpenDyslexic'; font-weight: 400; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Regular.woff2') format('woff2'); }
|
||||
@font-face { font-family: 'OpenDyslexic'; font-weight: 700; font-style: normal; font-display: swap; src: url('/static/fonts/OpenDyslexic-Bold.woff2') format('woff2'); }
|
||||
|
||||
/* Code block baseline */
|
||||
pre, code, .hljs {
|
||||
font-size: 0.95em;
|
||||
@@ -158,6 +162,39 @@ html {
|
||||
:root.density-spacious .list-item { padding: 8px 12px; }
|
||||
:root.density-spacious .sidebar .section { padding: 0; }
|
||||
|
||||
/* ── UI text-size scale (accessibility) ──
|
||||
Density only changes the root font-size, which can't move the many
|
||||
hard-coded px sizes. `zoom` scales the whole UI uniformly (px text
|
||||
included) while keeping layout intact, unlike `transform: scale`. */
|
||||
:root.ui-scale-125 { zoom: 1.25; }
|
||||
/* `zoom` makes the 100dvh shell render taller than the real viewport, which
|
||||
pushes the bottom-pinned sidebar account/settings row below the fold (and
|
||||
body's overflow:hidden then clips it). Shrink the shell by the same factor
|
||||
so it fits the viewport exactly. */
|
||||
:root.ui-scale-125 body { height: calc(100dvh / 1.25); }
|
||||
/* Modals/panels under the 1.25x scale: zoom renders a centred, viewport-sized
|
||||
panel ~1.25x taller, pushing its draggable header + close button off-screen
|
||||
(a catch-22 — you can't reach the control to turn the size back down). Divide
|
||||
each max-height by the same factor to keep the original on-screen footprint.
|
||||
Desktop only — the mobile `!important` full-sheet rules win on small screens
|
||||
and stay top-anchored, so their headers are already visible. */
|
||||
:root.ui-scale-125 .modal-content { max-height: calc(85dvh / 1.25); }
|
||||
:root.ui-scale-125 .cal-modal-content { max-height: calc(88dvh / 1.25); }
|
||||
:root.ui-scale-125 .settings-modal-content { max-height: calc(85dvh / 1.25); }
|
||||
:root.ui-scale-125 #theme-popup { max-height: min(calc(85dvh / 1.25), 480px); }
|
||||
/* Cookbook is the one modal that set its height inline (94vh), which beat the
|
||||
.modal-content compensation above and overflowed the viewport at 1.25x
|
||||
(header + close button pushed off-screen). Own its height here so the same
|
||||
zoom compensation applies. */
|
||||
#cookbook-modal .modal-content { height: 94vh; max-height: 94vh; }
|
||||
:root.ui-scale-125 #cookbook-modal .modal-content { height: calc(94dvh / 1.25); max-height: calc(94dvh / 1.25); }
|
||||
/* PDF export modal also set its height inline (86vh) at v1.0; that inline cap
|
||||
beat the .modal-content compensation above and shifted ~1vh at Default when
|
||||
removed. Own its height here so Default is byte-for-byte 86vh and the same
|
||||
1.25x compensation applies. */
|
||||
.pdf-export-overlay .modal-content { max-height: 86vh; }
|
||||
:root.ui-scale-125 .pdf-export-overlay .modal-content { max-height: calc(86dvh / 1.25); }
|
||||
|
||||
/* ── Background Patterns ── */
|
||||
|
||||
:root { --bg-effect-intensity: 1; }
|
||||
@@ -4036,6 +4073,31 @@ body.bg-pattern-sparkles {
|
||||
@keyframes toastCheckDraw {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
.toast-close-btn {
|
||||
margin-left: 8px;
|
||||
padding: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--fg);
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.22s ease, opacity 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.toast-close-btn:hover {
|
||||
opacity: 1;
|
||||
transform: rotate(90deg);
|
||||
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
}
|
||||
.toast.exiting {
|
||||
opacity: 0;
|
||||
transform: translateX(-120%);
|
||||
@@ -8708,6 +8770,12 @@ button.hamburger {
|
||||
/* Hide thinking sections globally via settings toggle */
|
||||
body.hide-thinking .thinking-section { display: none !important; }
|
||||
|
||||
/* Widen chat area via settings toggle */
|
||||
body.fullwidth-chat .chat-history {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 12px !important;
|
||||
}
|
||||
|
||||
/* Thinking process styles — colors follow theme accent */
|
||||
.msg .body .stream-content {
|
||||
width: 100%;
|
||||
@@ -17120,7 +17188,8 @@ body:not(.email-doc-split-active) #email-lib-modal.email-lib-fullscreen:not(.mod
|
||||
/* Kebab dropdown */
|
||||
.skill-kebab-menu {
|
||||
position: fixed;
|
||||
z-index: 100002;
|
||||
/* z-index is set inline via topPortalZ() at open time (#4720); a static
|
||||
value here loses once the modal bring-to-front counter climbs past it. */
|
||||
min-width: 150px;
|
||||
padding: 4px;
|
||||
background: var(--panel, var(--bg));
|
||||
@@ -35063,7 +35132,7 @@ body.notes-mobile-mode.notes-drag-mode .note-card-pin.active {
|
||||
word-break: break-all;
|
||||
}
|
||||
.note-link:hover { opacity: 0.8; }
|
||||
.note-checkbox-rm {
|
||||
.note-checkbox-edit, .note-checkbox-rm {
|
||||
flex: 0 0 auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
@@ -35075,13 +35144,25 @@ body.notes-mobile-mode.notes-drag-mode .note-card-pin.active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
margin-right: 0;
|
||||
margin-left: 2px;
|
||||
transition: opacity 0.12s, background 0.12s, color 0.12s;
|
||||
}
|
||||
.note-checkbox-rm { margin-left: auto; }
|
||||
.note-checkbox-edit { margin-left: auto; }
|
||||
.note-checkbox:hover .note-checkbox-edit,
|
||||
.note-checkbox:hover .note-checkbox-rm { opacity: 0.55; }
|
||||
.note-checkbox-rm:hover { opacity: 1 !important; color: var(--red); background: color-mix(in srgb, var(--red) 12%, transparent); }
|
||||
.note-checkbox-edit:hover { opacity: 1 !important; color: var(--accent, var(--blue)); background: color-mix(in srgb, var(--accent, var(--blue)) 12%, transparent); }
|
||||
.note-card-selectmode .note-checkbox-edit,
|
||||
.note-card-selectmode .note-checkbox-rm { display: none; }
|
||||
.note-check-text[contenteditable="true"] {
|
||||
background: color-mix(in srgb, var(--fg) 8%, transparent);
|
||||
outline: 1px solid var(--accent, var(--blue));
|
||||
border-radius: 2px;
|
||||
cursor: text;
|
||||
padding: 0 2px;
|
||||
margin: 0 -2px;
|
||||
}
|
||||
.note-check-dot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
Reference in New Issue
Block a user