mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-28 07:35:27 -04:00
Merge dev into fix/native-agent-loop-guard-signals
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;
|
||||
}
|
||||
@@ -2418,7 +2418,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([]);
|
||||
@@ -2451,6 +2451,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.
Binary file not shown.
|
After Width: | Height: | Size: 174 B |
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
+40
-6
@@ -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;">
|
||||
@@ -879,7 +893,7 @@
|
||||
<span class="grow">Library</span>
|
||||
<button type="button" class="list-item-plus-btn" id="library-new-doc-btn" title="New document">
|
||||
<svg class="list-item-plus-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:11px;height:11px;"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
<span class="list-item-plus-label">new</span>
|
||||
<span class="list-item-plus-label">document</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="list-item" id="tool-notes-btn">
|
||||
@@ -1005,7 +1019,12 @@
|
||||
<button type="button" class="model-picker-btn" id="model-picker-btn" title="Switch model"><span id="model-picker-label">Select model</span> <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 15 12 9 18 15"/></svg></button>
|
||||
<div class="model-picker-menu hidden" id="model-picker-menu">
|
||||
<div class="model-picker-search-row">
|
||||
<input type="text" id="model-picker-search" placeholder="Search models..." autocomplete="off" aria-label="Search models">
|
||||
<div class="model-picker-search-wrap">
|
||||
<input type="text" id="model-picker-search" placeholder="Search models..." autocomplete="off" aria-label="Search models">
|
||||
<button type="button" class="model-picker-refresh-btn" id="model-picker-refresh-btn" title="Refresh model picker" aria-label="Refresh model picker">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="model-picker-action-btn primary" id="model-picker-add-models-btn" title="Add model endpoints" aria-label="Add model endpoints">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
||||
</button>
|
||||
@@ -1313,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>
|
||||
@@ -1801,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>
|
||||
@@ -1913,7 +1937,7 @@
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>Change Password</h2>
|
||||
<div class="settings-col">
|
||||
<input id="settings-pw-current" type="password" placeholder="Current password" autocomplete="current-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||
<input id="settings-pw-new" type="password" placeholder="New password (min 8)" autocomplete="new-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||
<input id="settings-pw-new" type="password" placeholder="New password" autocomplete="new-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||
<input id="settings-pw-confirm" type="password" placeholder="Confirm new password" autocomplete="new-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||
<div class="settings-row" style="margin-top:2px;justify-content:flex-end;">
|
||||
<span id="settings-pw-msg" style="font-size:11px;margin-right:auto;"></span>
|
||||
@@ -2041,6 +2065,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>
|
||||
@@ -2049,7 +2083,7 @@
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>Add User</h2>
|
||||
<div class="admin-add-form">
|
||||
<input id="adm-newUsername" type="text" placeholder="Username">
|
||||
<input id="adm-newPassword" type="password" placeholder="Password (min 8)">
|
||||
<input id="adm-newPassword" type="password" placeholder="Password">
|
||||
<div class="admin-switch-inline" title="Grant full admin access"><label class="admin-switch"><input type="checkbox" id="adm-newIsAdmin"><span class="admin-slider"></span></label> Admin</div>
|
||||
</div>
|
||||
<div class="settings-row" style="margin-top:6px;">
|
||||
|
||||
+55
-6
@@ -13,6 +13,7 @@ let modalEl = null;
|
||||
// the endpoints list can flash a glow on that row. Cleared once the
|
||||
// animation fires.
|
||||
let _recentlyAddedEpId = null;
|
||||
let _authPolicy = { password_min_length: 8, reserved_usernames: [] };
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
function esc(s) { return uiModule.esc(s); }
|
||||
@@ -342,7 +343,38 @@ 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)
|
||||
.then(policy => {
|
||||
if (!policy) return;
|
||||
_authPolicy = policy;
|
||||
const admPw = el('adm-newPassword');
|
||||
if (admPw) admPw.placeholder = `Password (min ${policy.password_min_length})`;
|
||||
})
|
||||
.catch(() => {});
|
||||
el('adm-addBtn').addEventListener('click', async () => {
|
||||
const msg = el('adm-addMsg');
|
||||
msg.textContent = ''; msg.className = '';
|
||||
@@ -350,7 +382,8 @@ function initAddUser() {
|
||||
const password = el('adm-newPassword').value;
|
||||
const is_admin = el('adm-newIsAdmin').checked;
|
||||
if (!username) { msg.textContent = 'Username required'; msg.className = 'admin-error'; return; }
|
||||
if (password.length < 8) { msg.textContent = 'Password must be at least 8 characters'; msg.className = 'admin-error'; return; }
|
||||
if (password.length < _authPolicy.password_min_length) { msg.textContent = `Password must be at least ${_authPolicy.password_min_length} characters`; msg.className = 'admin-error'; return; }
|
||||
if (_authPolicy.reserved_usernames.includes(username.toLowerCase())) { msg.textContent = 'This username is reserved'; msg.className = 'admin-error'; return; }
|
||||
el('adm-addBtn').disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/auth/users', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, is_admin }) });
|
||||
@@ -1570,8 +1603,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;
|
||||
@@ -1586,12 +1619,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');
|
||||
@@ -1605,7 +1650,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(' — ');
|
||||
@@ -1745,7 +1795,6 @@ const TOOL_META = {
|
||||
manage_skills: { name: 'Skills', desc: 'Learn and use procedures', cat: 'Knowledge', ctx: '~200' },
|
||||
manage_rag: { name: 'RAG / Docs', desc: 'Query indexed documents', cat: 'Knowledge', ctx: '~150' },
|
||||
chat_with_model: { name: 'Chat with Model', desc: 'Talk to another AI model', cat: 'Multi-Agent', ctx: '~200' },
|
||||
second_opinion: { name: 'Second Opinion', desc: 'Get another model\'s take', cat: 'Multi-Agent', ctx: '~150' },
|
||||
pipeline: { name: 'Pipeline', desc: 'Multi-step AI workflows', cat: 'Multi-Agent', ctx: '~200' },
|
||||
ask_teacher: { name: 'Ask Teacher', desc: 'Query a more capable model', cat: 'Multi-Agent', ctx: '~150' },
|
||||
send_to_session: { name: 'Send to Session', desc: 'Send message to another chat', cat: 'Sessions', ctx: '~100' },
|
||||
@@ -2976,7 +3025,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()
|
||||
];
|
||||
|
||||
@@ -125,7 +125,7 @@ const TOOL_GROUPS = {
|
||||
'Knowledge': ['web_search', 'read_file', 'manage_memory', 'manage_rag', 'search_chats'],
|
||||
'Code': ['bash', 'python', 'write_file'],
|
||||
'Documents': ['create_document', 'edit_document', 'update_document', 'suggest_document'],
|
||||
'AI & Models': ['chat_with_model', 'second_opinion', 'ask_teacher', 'pipeline', 'list_models', 'generate_image'],
|
||||
'AI & Models': ['chat_with_model', 'ask_teacher', 'pipeline', 'list_models', 'generate_image'],
|
||||
'System': ['manage_session', 'manage_endpoints', 'manage_mcp', 'manage_settings', 'manage_skills', 'manage_webhooks', 'manage_tokens', 'manage_documents', 'create_session', 'list_sessions', 'send_to_session', 'ui_control'],
|
||||
};
|
||||
|
||||
|
||||
+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';
|
||||
@@ -413,8 +414,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, "\\'");
|
||||
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() {
|
||||
@@ -470,7 +471,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');
|
||||
@@ -1260,8 +1261,8 @@ async function _renderWeek() {
|
||||
// events keep the original tinted treatment.
|
||||
let bgDecl;
|
||||
if (_isCalBgImage(ev.color)) {
|
||||
const _url = _calBgImageUrl(ev.color).replace(/'/g, "\\'");
|
||||
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));`;
|
||||
}
|
||||
@@ -2853,7 +2854,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)';
|
||||
}
|
||||
@@ -2928,7 +2929,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');
|
||||
@@ -2950,7 +2951,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)';
|
||||
}
|
||||
|
||||
+90
-208
@@ -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';
|
||||
@@ -571,6 +570,24 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
let timeoutId = null;
|
||||
let responseTimeoutCleared = false;
|
||||
let clearResponseTimeout = () => {};
|
||||
let firstTokenWaitTimers = [];
|
||||
const clearFirstTokenWaitTimers = () => {
|
||||
firstTokenWaitTimers.forEach(t => { try { clearTimeout(t); } catch (_) {} });
|
||||
firstTokenWaitTimers = [];
|
||||
};
|
||||
const scheduleFirstTokenWaitMessages = () => {
|
||||
clearFirstTokenWaitTimers();
|
||||
const steps = [
|
||||
[20000, 'Still waiting for first token'],
|
||||
[60000, 'Large local model is pre-filling context'],
|
||||
[120000, 'Still working - no tokens yet from the model'],
|
||||
];
|
||||
firstTokenWaitTimers = steps.map(([ms, text]) => setTimeout(() => {
|
||||
if (!accumulated && spinner && spinner.element && !(currentAbort && currentAbort.signal.aborted)) {
|
||||
spinner.updateMessage(text);
|
||||
}
|
||||
}, ms));
|
||||
};
|
||||
const clearProcessingProbe = () => {
|
||||
if (processingProbeTimer) {
|
||||
clearTimeout(processingProbeTimer);
|
||||
@@ -921,56 +938,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
setTimeout(() => spinner.updateMessage('Analyzing sources'), 1500);
|
||||
} else {
|
||||
spinner.updateMessage('Processing request');
|
||||
const endpointUrlForProbe = sessionModule.getCurrentEndpointUrl ? sessionModule.getCurrentEndpointUrl() : null;
|
||||
if (endpointUrlForProbe && modelName) {
|
||||
processingProbeTimer = setTimeout(async () => {
|
||||
processingProbeTimer = null;
|
||||
if (accumulated || !spinner || !spinner.element || (currentAbort && currentAbort.signal.aborted)) return;
|
||||
processingProbeAbort = new AbortController();
|
||||
try {
|
||||
spinner.updateMessage('Checking model endpoint');
|
||||
const status = await _probeCurrentEndpointStatus(endpointUrlForProbe, processingProbeAbort.signal);
|
||||
if (accumulated || !spinner || !spinner.element || (currentAbort && currentAbort.signal.aborted)) return;
|
||||
if (!status) {
|
||||
spinner.updateMessage('Still waiting for model');
|
||||
} else if (status.alive) {
|
||||
const latency = status.latency_ms ? ` (${status.latency_ms}ms)` : '';
|
||||
spinner.updateMessage(`Endpoint online${latency}; waiting for first token`);
|
||||
} else {
|
||||
// Probe confirms the endpoint isn't responding. Don't
|
||||
// sit on a hung fetch — give the user 5s to read the
|
||||
// status, then auto-abort with reason='offline' so the
|
||||
// catch handler shows a clean "switch model" message
|
||||
// instead of leaving the spinner spinning forever.
|
||||
if (status.error) console.warn('Model endpoint probe failed:', status.error);
|
||||
let _countdown = 5;
|
||||
spinner.updateMessage(`Endpoint offline — cancelling in ${_countdown}s`);
|
||||
const _tick = setInterval(() => {
|
||||
_countdown--;
|
||||
if (!spinner || !spinner.element || (currentAbort && currentAbort.signal.aborted) || accumulated) {
|
||||
clearInterval(_tick);
|
||||
return;
|
||||
}
|
||||
if (_countdown > 0) {
|
||||
spinner.updateMessage(`Endpoint offline — cancelling in ${_countdown}s`);
|
||||
} else {
|
||||
clearInterval(_tick);
|
||||
if (currentAbort && !currentAbort.signal.aborted) {
|
||||
currentAbort._reason = 'offline';
|
||||
currentAbort.abort();
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e && e.name !== 'AbortError' && spinner && spinner.element && !accumulated) {
|
||||
spinner.updateMessage('Still waiting for model');
|
||||
}
|
||||
} finally {
|
||||
processingProbeAbort = null;
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
scheduleFirstTokenWaitMessages();
|
||||
}
|
||||
|
||||
const researchBtn = el('research-toggle-btn');
|
||||
@@ -1150,6 +1118,11 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
uiModule.scrollHistory();
|
||||
}
|
||||
|
||||
function _replaceThinkingSpinner(label) {
|
||||
_removeThinkingSpinner();
|
||||
_showThinkingSpinner(label);
|
||||
}
|
||||
|
||||
// Auto-show thinking spinner after text stops streaming
|
||||
let _textPauseTimer = null;
|
||||
function _scheduleThinkingSpinner() {
|
||||
@@ -1173,10 +1146,24 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
let _liveThinkHeader = null;
|
||||
let _liveThinkSpinnerSlot = null;
|
||||
let _liveThinkTimerEl = null;
|
||||
let _liveThinkTokenCount = 0;
|
||||
let _liveThinkToggle = null;
|
||||
let _liveThinkDomId = null;
|
||||
|
||||
function _estimateThinkingTokens(text) {
|
||||
const clean = (text || '').trim();
|
||||
if (!clean) return 0;
|
||||
return Math.max(1, Math.ceil(clean.length / 4));
|
||||
}
|
||||
|
||||
function _formatThinkStats(seconds, tokenCount) {
|
||||
const time = seconds ? seconds + 's' : '';
|
||||
const tokens = tokenCount ? tokenCount + ' tok' : '';
|
||||
return time && tokens ? time + ' · ' + tokens : (time || tokens);
|
||||
}
|
||||
|
||||
function _replyAfterClosedThinking(text) {
|
||||
text = markdownModule.normalizeThinkingMarkup(text || '');
|
||||
const closeRe = /<\/(?:think(?:ing)?|thought)>|<channel\|>/gi;
|
||||
let match = null;
|
||||
let last = null;
|
||||
@@ -1187,7 +1174,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
|
||||
// Direct render helper for streaming text
|
||||
_renderStream = () => {
|
||||
let dt = stripToolBlocks(roundText);
|
||||
let dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(roundText));
|
||||
const bodyEl = roundHolder.querySelector('.body');
|
||||
const contentEl = _ensureStreamLayout(bodyEl);
|
||||
|
||||
@@ -1277,6 +1264,12 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
|
||||
let _nextIsError = false;
|
||||
let _streamSawDone = false;
|
||||
let _firstVisibleOutputSeen = false;
|
||||
const markFirstVisibleOutput = () => {
|
||||
if (_firstVisibleOutputSeen) return;
|
||||
_firstVisibleOutputSeen = true;
|
||||
clearFirstTokenWaitTimers();
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
@@ -1296,6 +1289,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
}
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data && data !== '[DONE]') markFirstVisibleOutput();
|
||||
|
||||
// (thinking spinner removal is handled in agent_step / tool_start / content handlers)
|
||||
|
||||
@@ -1357,7 +1351,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process';
|
||||
if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove();
|
||||
if (_liveThinkTimerEl && _elapsedDone) {
|
||||
_liveThinkTimerEl.textContent = _elapsedDone + 's';
|
||||
_liveThinkTimerEl.textContent = _formatThinkStats(_elapsedDone, _liveThinkTokenCount);
|
||||
_liveThinkTimerEl.style.marginLeft = 'auto';
|
||||
_liveThinkTimerEl.style.marginRight = '5px';
|
||||
var _hdrDone = _liveThinkTimerEl.closest('.thinking-header');
|
||||
@@ -1399,9 +1393,17 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
typewriterInto(roundHolder.querySelector('.body'), errMsg);
|
||||
break;
|
||||
}
|
||||
if (json.delta || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
|
||||
if (json.delta || json.type === 'agent_prep' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
|
||||
clearResponseTimeout();
|
||||
clearProcessingProbe();
|
||||
clearFirstTokenWaitTimers();
|
||||
}
|
||||
if (json.type === 'agent_prep') {
|
||||
if (!_isBg) {
|
||||
_cancelThinkingTimer();
|
||||
_replaceThinkingSpinner('Preparing agent');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (json.delta) {
|
||||
_cancelThinkingTimer();
|
||||
@@ -1464,12 +1466,13 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
// 1. Normal: <think>...no closing tag yet
|
||||
// 2. Malformed: <think></think>\n...text but no second </think> yet
|
||||
// 3. Qwen3.5: "Thinking Process:" without <think> tags
|
||||
let hasUnclosedThink = markdownModule.hasUnclosedThinkTag(roundText);
|
||||
const normalizedRoundText = markdownModule.normalizeThinkingMarkup(roundText);
|
||||
let hasUnclosedThink = markdownModule.hasUnclosedThinkTag(normalizedRoundText);
|
||||
// Detect non-tag thinking patterns: "Thinking:", "Thinking Process:", Gemma-style reasoning
|
||||
// These patterns don't use <think> tags, so we simulate unclosed thinking during streaming
|
||||
const _replyPrefixes = ['Hey', 'Hi ', 'Hi!', 'Hello', 'Sure', 'Yes', 'No ', 'No,', 'Yo', 'OK', 'Here', 'Absolutely', 'Of course', 'Great', 'Alright', 'Thanks', 'Welcome', 'Good ', "I'm happy", "I'd be"];
|
||||
if (!hasUnclosedThink && !/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought/i.test(roundText)) {
|
||||
const _trimmedRT = roundText.trimStart();
|
||||
if (!hasUnclosedThink && !/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>|<\|channel>thought/i.test(normalizedRoundText)) {
|
||||
const _trimmedRT = normalizedRoundText.trimStart();
|
||||
const _isReasoning = markdownModule.startsWithReasoningPrefix(_trimmedRT);
|
||||
if (_isReasoning) {
|
||||
// Check if we can see a reply boundary yet (newline then reply pattern)
|
||||
@@ -1494,9 +1497,9 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasUnclosedThink && /^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i.test(roundText)) {
|
||||
if (!hasUnclosedThink && /^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i.test(normalizedRoundText)) {
|
||||
// Empty <think></think> — the model likely put thinking outside the tags
|
||||
const afterEmpty = roundText.replace(/^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i, '').trim();
|
||||
const afterEmpty = normalizedRoundText.replace(/^<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>\s*<\/(?:think(?:ing)?|thought)>/i, '').trim();
|
||||
const closeTags = (afterEmpty.match(/<\/(?:think(?:ing)?|thought)>/gi) || []).length;
|
||||
if (closeTags === 0 && afterEmpty.length > 0) {
|
||||
hasUnclosedThink = true; // still waiting for real closing tag
|
||||
@@ -1506,10 +1509,10 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
// Only applies when there's a second </think> later (model leaked thinking outside tags)
|
||||
// Do NOT trigger if the text after </think> contains tool calls (that's real content)
|
||||
if (!hasUnclosedThink && isThinking) {
|
||||
const _thinkMatch = roundText.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i);
|
||||
const _thinkMatch = normalizedRoundText.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i);
|
||||
const _thinkLen = _thinkMatch ? _thinkMatch[1].trim().length : 0;
|
||||
if (_thinkLen < 20) {
|
||||
const _afterClose = roundText.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i, '').trim();
|
||||
const _afterClose = normalizedRoundText.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>([\s\S]*?)<\/(?:think(?:ing)?|thought)>/i, '').trim();
|
||||
// Only keep waiting if there's trailing text that looks like thinking (not tool calls)
|
||||
const _hasToolCall = /```(?:bash|python|web_search|read_file|write_file|create_document|edit_document|manage_|generate_image)/i.test(_afterClose);
|
||||
const _hasOrphanClose = /<\/(?:think(?:ing)?|thought)>/i.test(_afterClose);
|
||||
@@ -1554,7 +1557,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
function _tickThinkTimer() {
|
||||
if (!_liveThinkTimerEl || !_liveThinkTimerEl.isConnected) return;
|
||||
var s = ((Date.now() - _thinkTimerStart) / 1000).toFixed(1);
|
||||
_liveThinkTimerEl.textContent = s + 's';
|
||||
_liveThinkTimerEl.textContent = _formatThinkStats(s, _liveThinkTokenCount);
|
||||
_thinkTimerRAF = requestAnimationFrame(_tickThinkTimer);
|
||||
}
|
||||
_thinkTimerRAF = requestAnimationFrame(_tickThinkTimer);
|
||||
@@ -1570,13 +1573,18 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
} else if (hasUnclosedThink && isThinking) {
|
||||
if (_liveThinkInner) {
|
||||
// Extract raw thinking text (strip known thinking wrappers and prefixes)
|
||||
var thinkText = roundText
|
||||
var thinkText = markdownModule.normalizeThinkingMarkup(roundText)
|
||||
.replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '')
|
||||
.replace(/<\|channel>thought\s*\n?/gi, '')
|
||||
.replace(/<\|channel>response\s*\n?/gi, '')
|
||||
.replace(/<channel\|>/gi, '');
|
||||
thinkText = thinkText.replace(/^\s*Thinking(?:\s+Process)?:\s*/i, '');
|
||||
_liveThinkTokenCount = _estimateThinkingTokens(thinkText);
|
||||
_liveThinkInner.innerHTML = markdownModule.mdToHtml(thinkText);
|
||||
if (_liveThinkTimerEl) {
|
||||
var _elapsedLive = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : '';
|
||||
_liveThinkTimerEl.textContent = _formatThinkStats(_elapsedLive, _liveThinkTokenCount);
|
||||
}
|
||||
// Keep thinking box scrolled to bottom, but let user scroll up
|
||||
var thinkBox = _liveThinkInner.closest('.thinking-content');
|
||||
if (thinkBox) {
|
||||
@@ -1600,6 +1608,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
_liveThinkHeader = null;
|
||||
_liveThinkSpinnerSlot = null;
|
||||
_liveThinkTimerEl = null;
|
||||
_liveThinkTokenCount = 0;
|
||||
_liveThinkToggle = null;
|
||||
_liveThinkDomId = null;
|
||||
// Fall through to normal streaming
|
||||
@@ -1622,7 +1631,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove();
|
||||
// Move timer to right side of header
|
||||
if (_liveThinkTimerEl && elapsed) {
|
||||
_liveThinkTimerEl.textContent = elapsed + 's';
|
||||
_liveThinkTimerEl.textContent = _formatThinkStats(elapsed, _liveThinkTokenCount);
|
||||
_liveThinkTimerEl.style.marginLeft = 'auto';
|
||||
_liveThinkTimerEl.style.marginRight = '5px';
|
||||
var _hdrRow = _liveThinkTimerEl.closest('.thinking-header');
|
||||
@@ -2040,7 +2049,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
cancelAnimationFrame(_thinkTimerRAF);
|
||||
var _elapsed2 = thinkingStartTime ? ((Date.now() - thinkingStartTime) / 1000).toFixed(1) : null;
|
||||
if (_liveThinkHeader) _liveThinkHeader.textContent = 'View thinking process';
|
||||
if (_liveThinkTimerEl) _liveThinkTimerEl.textContent = _elapsed2 ? _elapsed2 + 's' : '';
|
||||
if (_liveThinkTimerEl) _liveThinkTimerEl.textContent = _elapsed2 ? _formatThinkStats(_elapsed2, _liveThinkTokenCount) : '';
|
||||
if (_liveThinkSpinnerSlot) _liveThinkSpinnerSlot.remove();
|
||||
// Assign stable IDs
|
||||
var _thinkId2 = 'think-' + Date.now();
|
||||
@@ -2054,7 +2063,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
if (!roundFinalized) {
|
||||
roundFinalized = true;
|
||||
if (spinner && spinner.element) spinner.destroy();
|
||||
const dt = stripToolBlocks(roundText);
|
||||
const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(roundText));
|
||||
if (dt.trim()) {
|
||||
var _body3 = roundHolder.querySelector('.body');
|
||||
var _contentEl3 = _ensureStreamLayout(_body3);
|
||||
@@ -2328,148 +2337,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;
|
||||
@@ -3035,6 +2907,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
} finally {
|
||||
clearResponseTimeout();
|
||||
clearProcessingProbe();
|
||||
clearFirstTokenWaitTimers();
|
||||
// Streaming done — let screen readers announce the settled response.
|
||||
const _chatLogDone = document.getElementById('chat-history');
|
||||
if (_chatLogDone) _chatLogDone.setAttribute('aria-busy', 'false');
|
||||
@@ -3413,7 +3286,7 @@ import { wireArrowUpRecall, getLastUserMessageFromChatHistory } from './composer
|
||||
};
|
||||
|
||||
const renderDelta = () => {
|
||||
const dt = stripToolBlocks(roundText);
|
||||
const dt = markdownModule.normalizeThinkingMarkup(stripToolBlocks(roundText));
|
||||
contentDiv.innerHTML = markdownModule.mdToHtml(markdownModule.squashOutsideCode(dt));
|
||||
uiModule.scrollHistory();
|
||||
};
|
||||
@@ -5025,7 +4898,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;
|
||||
}
|
||||
|
||||
+193
-5
@@ -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;
|
||||
@@ -635,8 +672,8 @@ export function applyModelColor(roleEl, modelName) {
|
||||
popup.className = 'ctx-popup';
|
||||
let html = '<div style="font-weight:600;margin-bottom:6px;color:var(--fg);display:flex;align-items:center;gap:6px;">';
|
||||
if (logoHtml) html += '<span class="role-provider-logo" style="opacity:0.7">' + logoHtml + '</span>';
|
||||
html += short + '</div>';
|
||||
html += '<div><span class="ctx-label">Model</span> ' + modelName.split('/').pop() + '</div>';
|
||||
html += uiModule.esc(short) + '</div>';
|
||||
html += '<div><span class="ctx-label">Model</span> ' + uiModule.esc(modelName.split('/').pop()) + '</div>';
|
||||
// Provider = the serving endpoint, distinct from the model vendor/logo
|
||||
// (e.g. the same model via OpenRouter vs Copilot vs Anthropic direct).
|
||||
const _epUrl = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
|
||||
@@ -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, '');
|
||||
@@ -1974,6 +2011,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.
|
||||
*/
|
||||
@@ -1983,6 +2156,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;
|
||||
|
||||
@@ -1990,6 +2168,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;
|
||||
@@ -2066,6 +2245,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()) {
|
||||
@@ -2129,6 +2309,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;
|
||||
}
|
||||
|
||||
@@ -2461,6 +2647,8 @@ const chatRenderer = {
|
||||
copyMessageText,
|
||||
safeToolScreenshotSrc,
|
||||
safeDisplayImageSrc,
|
||||
removeAskUserCards,
|
||||
renderAskUserCard,
|
||||
buildSourcesBox,
|
||||
buildFindingsBox,
|
||||
appendReportButton,
|
||||
|
||||
+78
-21
@@ -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;
|
||||
|
||||
@@ -73,6 +74,45 @@ function isCompareActive() {
|
||||
return state.isActive;
|
||||
}
|
||||
|
||||
function _compareModeLabel() {
|
||||
return ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models');
|
||||
}
|
||||
|
||||
function _setToolbarMode(mode, syncModeTools = !state.isActive) {
|
||||
const target = mode === 'agent' ? 'agent' : 'chat';
|
||||
const toggleState = Storage.loadToggleState();
|
||||
toggleState.mode = target;
|
||||
Storage.saveToggleState(toggleState);
|
||||
const agentBtn = document.getElementById('mode-agent-btn');
|
||||
const chatBtn = document.getElementById('mode-chat-btn');
|
||||
const modeToggle = agentBtn?.closest('.mode-toggle') || chatBtn?.closest('.mode-toggle') || document.querySelector('.mode-toggle');
|
||||
if (agentBtn && chatBtn) {
|
||||
agentBtn.classList.toggle('active', target === 'agent');
|
||||
chatBtn.classList.toggle('active', target === 'chat');
|
||||
agentBtn.setAttribute('aria-pressed', target === 'agent' ? 'true' : 'false');
|
||||
chatBtn.setAttribute('aria-pressed', target === 'chat' ? 'true' : 'false');
|
||||
}
|
||||
if (modeToggle) {
|
||||
modeToggle.classList.toggle('mode-chat', target === 'chat');
|
||||
modeToggle.classList.toggle('mode-right', target === 'chat');
|
||||
}
|
||||
if (syncModeTools) {
|
||||
document.querySelectorAll('[data-mode-tool]').forEach(b => { b.style.display = target === 'agent' ? '' : 'none'; });
|
||||
}
|
||||
}
|
||||
|
||||
function _syncCompareModeFromToolbar(mode) {
|
||||
if (!state.isActive) return;
|
||||
state._compareMode = mode === 'agent' ? 'agent' : 'chat';
|
||||
_setToolbarMode(state._compareMode, false);
|
||||
const headerLabel = document.querySelector('.compare-header-label');
|
||||
if (headerLabel) {
|
||||
headerLabel.textContent = 'Comparing' + _compareModeLabel() + (state._blindMode ? ' (blind)' : '') + ' · ' + state._timeout + 's timeout';
|
||||
}
|
||||
const evalWrap = document.getElementById('cmp-eval-wrap');
|
||||
if (evalWrap && typeof evalWrap._renderItems === 'function') evalWrap._renderItems();
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// ── closeCompare ──
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -170,12 +210,7 @@ async function deactivate(teardown) {
|
||||
});
|
||||
|
||||
// Restore agent/chat mode to what it was before compare
|
||||
const _ts = Storage.loadToggleState();
|
||||
_ts.mode = state._savedMode;
|
||||
Storage.saveToggleState(_ts);
|
||||
const _ab2 = document.getElementById('mode-agent-btn'), _cb2 = document.getElementById('mode-chat-btn');
|
||||
if (_ab2 && _cb2) { _ab2.classList.toggle('active', state._savedMode === 'agent'); _cb2.classList.toggle('active', state._savedMode === 'chat'); }
|
||||
document.querySelectorAll('[data-mode-tool]').forEach(b => { b.style.display = state._savedMode === 'agent' ? '' : 'none'; });
|
||||
_setToolbarMode(state._savedMode, true);
|
||||
|
||||
// Delete unsaved sessions, then reload
|
||||
if (teardown) {
|
||||
@@ -258,19 +293,30 @@ async function _buildCompareUI() {
|
||||
if (el) state._savedIndicatorDisplay[id] = el.style.display;
|
||||
});
|
||||
|
||||
// 5. Save current mode and lock to the right one for this compare type
|
||||
// 5. Save current mode and seed the toolbar for this compare type.
|
||||
const _toggleState = Storage.loadToggleState();
|
||||
state._savedMode = _toggleState.mode || 'chat';
|
||||
const _targetMode = (state._compareMode === 'agent') ? 'agent' : 'chat';
|
||||
_toggleState.mode = _targetMode;
|
||||
Storage.saveToggleState(_toggleState);
|
||||
_setToolbarMode(_targetMode, false);
|
||||
const _ab = document.getElementById('mode-agent-btn'), _cb = document.getElementById('mode-chat-btn');
|
||||
let _modeCleanup = null;
|
||||
const _onCompareModeClick = (ev) => {
|
||||
ev.stopPropagation();
|
||||
ev.stopImmediatePropagation();
|
||||
_syncCompareModeFromToolbar(ev.currentTarget === _ab ? 'agent' : 'chat');
|
||||
};
|
||||
if (_ab && _cb) {
|
||||
_ab.classList.toggle('active', _targetMode === 'agent');
|
||||
_cb.classList.toggle('active', _targetMode === 'chat');
|
||||
_ab.addEventListener('click', _onCompareModeClick, true);
|
||||
_cb.addEventListener('click', _onCompareModeClick, true);
|
||||
_modeCleanup = document.createElement('span');
|
||||
_modeCleanup.style.display = 'none';
|
||||
_modeCleanup._cleanup = () => {
|
||||
_ab.removeEventListener('click', _onCompareModeClick, true);
|
||||
_cb.removeEventListener('click', _onCompareModeClick, true);
|
||||
};
|
||||
}
|
||||
const _modeToggle = document.querySelector('.mode-toggle');
|
||||
if (_modeToggle) { _modeToggle.style.pointerEvents = 'none'; _modeToggle.style.opacity = '0.4'; }
|
||||
if (_modeToggle) { _modeToggle.style.pointerEvents = ''; _modeToggle.style.opacity = ''; }
|
||||
|
||||
// 6. Force tool toggles per compare mode
|
||||
disableToolToggles();
|
||||
@@ -289,6 +335,7 @@ async function _buildCompareUI() {
|
||||
// 7. Hide existing chat container children (preserves event listeners)
|
||||
const container = document.getElementById('chat-container');
|
||||
state._compareElements = [];
|
||||
if (_modeCleanup) state._compareElements.push(_modeCleanup);
|
||||
Array.from(container.children).forEach(child => {
|
||||
if (child.style.display === 'none') return;
|
||||
child.dataset.cmpHidden = '1';
|
||||
@@ -302,9 +349,9 @@ async function _buildCompareUI() {
|
||||
headerBar.className = 'compare-header-bar';
|
||||
headerBar.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:6px 10px;flex-shrink:0;';
|
||||
const headerLabel = document.createElement('span');
|
||||
headerLabel.className = 'compare-header-label';
|
||||
headerLabel.style.cssText = 'font-size:10px;font-weight:400;color:var(--fg);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;';
|
||||
const _modeLabel = ({ search: ' search providers', agent: ' agents', research: ' research models' }[state._compareMode] || ' models');
|
||||
headerLabel.textContent = 'Comparing' + _modeLabel + (state._blindMode ? ' (blind)' : '') + ' · ' + state._timeout + 's timeout';
|
||||
headerLabel.textContent = 'Comparing' + _compareModeLabel() + (state._blindMode ? ' (blind)' : '') + ' · ' + state._timeout + 's timeout';
|
||||
// Left side: the Compare tool icon (two side-by-side panes, matching the
|
||||
// rail/sidebar icon) + the label. Other tool headers carry their icon; this
|
||||
// one was missing it.
|
||||
@@ -475,7 +522,7 @@ async function _buildCompareUI() {
|
||||
}
|
||||
const msgTA = document.getElementById('message');
|
||||
if (msgTA) {
|
||||
msgTA.placeholder = 'Enter prompt for all models...';
|
||||
msgTA.placeholder = window.matchMedia('(max-width: 767px)').matches ? '' : 'Enter prompt for all models...';
|
||||
requestAnimationFrame(() => msgTA.focus());
|
||||
}
|
||||
|
||||
@@ -891,8 +938,7 @@ async function _executeCompare(message) {
|
||||
let sharedSearchContext = null;
|
||||
let sharedSearchSources = null;
|
||||
const webChk = document.getElementById('web-toggle');
|
||||
const toggleState = Storage.loadToggleState();
|
||||
const isAgentMode = (toggleState.mode || 'chat') === 'agent';
|
||||
const isAgentMode = state._compareMode === 'agent';
|
||||
const webOn = webChk && webChk.checked;
|
||||
// In agent mode, web_search is a tool (handled per-pane); in chat mode, pre-search and share
|
||||
if (webOn && !isAgentMode) {
|
||||
@@ -1017,6 +1063,7 @@ function _buildComparisonMarkdown() {
|
||||
}
|
||||
|
||||
let _exportMenuEl = null;
|
||||
let _closeExportMenu = () => {};
|
||||
function _toggleExportMenu(btn) {
|
||||
if (_exportMenuEl) { _closeExportMenu(); return; }
|
||||
const r = btn.getBoundingClientRect();
|
||||
@@ -1040,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) {
|
||||
@@ -1198,6 +1244,15 @@ function _setupEvalPicker() {
|
||||
|
||||
function _renderItems() {
|
||||
const mode = state._compareMode || 'chat';
|
||||
const label = btn.querySelector('.cmp-eval-label');
|
||||
if (label) {
|
||||
label.textContent = ({
|
||||
agent: 'Agent prompts',
|
||||
chat: 'Chat prompts',
|
||||
search: 'Search prompts',
|
||||
research: 'Research prompts'
|
||||
}[mode] || 'Eval prompts');
|
||||
}
|
||||
// research/html aren't first-class compare types — fall back gracefully
|
||||
const key = EVAL_PROMPTS[mode] ? mode
|
||||
: (mode === 'research' ? 'search' : 'chat');
|
||||
@@ -1258,8 +1313,10 @@ function _setupEvalPicker() {
|
||||
};
|
||||
document.addEventListener('click', _onDocClick);
|
||||
|
||||
_renderItems();
|
||||
wrap.appendChild(btn);
|
||||
wrap.appendChild(menu);
|
||||
wrap._renderItems = _renderItems;
|
||||
inputTop.appendChild(wrap);
|
||||
|
||||
// Expected-answer chip — placed above the chat-input-bar (outside it), so
|
||||
|
||||
@@ -551,23 +551,46 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
|
||||
footer.className = 'msg-footer';
|
||||
const span = document.createElement('span');
|
||||
span.className = 'response-metrics';
|
||||
let text = metrics.output_tokens + ' tokens | ' + metrics.tokens_per_second + ' tok/s';
|
||||
const outputTokens = metrics.output_tokens;
|
||||
const responseTime = metrics.response_time ?? metrics.total_time;
|
||||
const explicitTps = metrics.tokens_per_second ?? metrics.gen_tps ?? metrics.tps;
|
||||
const numericOutput = Number(outputTokens);
|
||||
const numericTime = Number(responseTime);
|
||||
const numericTps = Number(explicitTps);
|
||||
const derivedTps = Number.isFinite(numericTps)
|
||||
? numericTps
|
||||
: (Number.isFinite(numericOutput) && Number.isFinite(numericTime) && numericTime > 0)
|
||||
? numericOutput / numericTime
|
||||
: null;
|
||||
const tpsLabel = derivedTps != null
|
||||
? (derivedTps >= 100 ? String(Math.round(derivedTps)) : derivedTps.toFixed(2).replace(/\.?0+$/, ''))
|
||||
: null;
|
||||
const parts = [];
|
||||
if (outputTokens != null && outputTokens !== 'undefined') {
|
||||
parts.push(outputTokens + ' tokens');
|
||||
}
|
||||
if (tpsLabel != null) {
|
||||
parts.push(tpsLabel + ' tok/s');
|
||||
}
|
||||
if (responseTime != null && responseTime !== 'undefined' && parts.length === 0) {
|
||||
parts.push(responseTime + 's');
|
||||
}
|
||||
// Add per-request cost and cost per 1000
|
||||
const _model = metrics.model || (state._selectedModels[paneIdx] && state._selectedModels[paneIdx].model) || '';
|
||||
const _cost = getModelCost(_model, metrics.input_tokens || 0, metrics.output_tokens || 0);
|
||||
// Build the metrics span with optional cost and context
|
||||
span.textContent = text;
|
||||
span.textContent = parts.join(' | ');
|
||||
if (_cost !== null) {
|
||||
const _cost1k = _cost * 1000;
|
||||
const costSpan = document.createElement('span');
|
||||
costSpan.style.color = 'var(--color-success, #4caf50)';
|
||||
costSpan.title = 'Estimated cost per 1,000 responses like this one';
|
||||
costSpan.textContent = ' | $' + (_cost1k < 1 ? _cost1k.toFixed(2) : _cost1k.toFixed(0)) + '/1k';
|
||||
costSpan.textContent = (span.textContent ? ' | ' : '') + '$' + (_cost1k < 1 ? _cost1k.toFixed(2) : _cost1k.toFixed(0)) + '/1k';
|
||||
span.appendChild(costSpan);
|
||||
}
|
||||
if (metrics.context_percent > 0) {
|
||||
const ctx = document.createElement('span');
|
||||
ctx.textContent = ' | ' + metrics.context_percent + '% ctx';
|
||||
ctx.textContent = (span.textContent ? ' | ' : '') + metrics.context_percent + '% ctx';
|
||||
if (metrics.context_percent >= 85) ctx.style.color = 'var(--color-error)';
|
||||
else if (metrics.context_percent >= 70) ctx.style.color = '#ff9900';
|
||||
span.appendChild(ctx);
|
||||
|
||||
@@ -181,7 +181,7 @@ function handleVote(winnerIdx) {
|
||||
|
||||
let html = '';
|
||||
const caret = ' <span class="pane-title-caret">▾</span>';
|
||||
if (isWinner) html = '<span style="color:var(--red);margin-right:4px;">★</span><strong>' + escapeHtml(name) + '</strong> <span style="color:var(--red);font-size:0.82em;font-weight:800;text-transform:uppercase;letter-spacing:1px;position:relative;top:-2px;">Winner!</span>' + caret;
|
||||
if (isWinner) html = '<span style="color:var(--green, #50fa7b);margin-right:4px;">★</span><strong>' + escapeHtml(name) + '</strong> <span style="color:var(--green, #50fa7b);font-size:0.82em;font-weight:800;text-transform:uppercase;letter-spacing:1px;position:relative;top:0;">Winner!</span>' + caret;
|
||||
else if (isTie) html = '<span style="opacity:0.5;margin-right:4px;">=</span><strong>' + escapeHtml(name) + '</strong>' + caret;
|
||||
else html = '<strong>' + escapeHtml(name) + '</strong>' + caret;
|
||||
el.innerHTML = html;
|
||||
|
||||
@@ -56,7 +56,7 @@ const _RECIPES = [
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['CMAKE_ARGS="-DGGML_CUDA=on" uv pip install -U "llama-cpp-python[server]"'] },
|
||||
docker: { commands: ['docker pull ghcr.io/ggerganov/llama.cpp:server-cuda'] },
|
||||
docker: { commands: ['docker pull ghcr.io/ggml-org/llama.cpp:server-cuda'] },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -461,6 +461,40 @@ export const ERROR_PATTERNS = [
|
||||
{ label: 'Copy install command', action: () => _copyText('curl -fsSL https://ollama.com/install.sh | sh') },
|
||||
],
|
||||
},
|
||||
// System build deps must be checked BEFORE the llama-server catch-all:
|
||||
// a `cmake: command not found` failure ALSO produces `llama-server:
|
||||
// command not found` later in the script (the build aborts then the
|
||||
// run line fails) — pattern order is first-match-wins, so without
|
||||
// these specific entries the user gets the misleading "install
|
||||
// llama-cpp-python[server]" suggestion when the actual blocker is a
|
||||
// missing OS-package toolchain that pip can't ship.
|
||||
{
|
||||
pattern: /cmake: command not found|cmake.*not found.*Could not/i,
|
||||
message: 'cmake is required to compile llama.cpp from source, but it is not installed on this server.',
|
||||
suggestion: 'Suggested action: install cmake via the OS package manager — apt: cmake build-essential / pacman: cmake base-devel / dnf: cmake gcc-c++ make / brew: cmake. Cookbook can do this automatically on the next launch if your user has passwordless sudo for apt/pacman/dnf.',
|
||||
fixes: [
|
||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('llama_cpp') },
|
||||
{ label: 'Copy apt install', action: () => _copyText('sudo apt install -y cmake build-essential git') },
|
||||
{ label: 'Copy pacman install', action: () => _copyText('sudo pacman -Sy --needed cmake base-devel git') },
|
||||
{ label: 'Copy dnf install', action: () => _copyText('sudo dnf install -y cmake gcc gcc-c++ make git') },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /^(make|g\+\+|gcc): command not found|Could not find C\+\+ compiler/i,
|
||||
message: 'A C/C++ compiler (build-essential / base-devel) is required to compile llama.cpp.',
|
||||
fixes: [
|
||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('llama_cpp') },
|
||||
{ label: 'Copy apt install', action: () => _copyText('sudo apt install -y build-essential') },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /^git: command not found/i,
|
||||
message: 'git is required to clone the llama.cpp source tree.',
|
||||
fixes: [
|
||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('llama_cpp') },
|
||||
{ label: 'Copy apt install', action: () => _copyText('sudo apt install -y git') },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'/i,
|
||||
message: 'llama-cpp-python server is not installed. Run: pip install "llama-cpp-python[server]"',
|
||||
@@ -578,24 +612,50 @@ export const ERROR_PATTERNS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
// Tail-only + healthy-server suppression. tmux capture-pane returns the
|
||||
// entire scrollback every poll, so a one-shot startup traceback would
|
||||
// otherwise stick on the panel forever even while the server happily
|
||||
// serves /v1/models. Only fire if the traceback is in recent output AND
|
||||
// the server isn't currently logging healthy traffic.
|
||||
// Dependency-install (pip) build failure — a required package failed to
|
||||
// build its wheel (common when an old sdist's setup.py breaks on a newer
|
||||
// Python, e.g. basicsr on 3.13). This is an install problem, NOT a serve
|
||||
// problem, so it must never suggest killing vLLM.
|
||||
match: (text) => {
|
||||
const TAIL = text.slice(-6000);
|
||||
// A serve script can run a fallback build and then start serving fine —
|
||||
// don't flag a stale build error once the server is up.
|
||||
if (/Application startup complete|"(?:GET|POST)\s+\/v1\/[^"]+ HTTP\/[\d.]+"\s*2\d\d|Uvicorn running on|server is listening on https?:\/\//i.test(TAIL)) return false;
|
||||
return /Failed to build\b|subprocess-exited-with-error|Could not build wheels|metadata-generation-failed/i.test(TAIL);
|
||||
},
|
||||
message: 'A dependency failed to build during install — usually an older package whose build breaks on this Python version, not a server problem. The install did not finish.',
|
||||
suggestion: 'Suggested action: check the captured output for the package that failed to build; it may need a newer release or a patch to install on this Python version.',
|
||||
fixes: [],
|
||||
},
|
||||
{
|
||||
// vLLM-specific traceback: only offer the kill-processes recovery when the
|
||||
// output is actually about vLLM. Tail-only + healthy-server suppression so
|
||||
// a one-shot startup traceback doesn't stick on the panel forever while
|
||||
// the server happily serves /v1/models.
|
||||
match: (text) => {
|
||||
const TAIL = text.slice(-4096);
|
||||
if (!/Traceback \(most recent call last\)/i.test(TAIL)) return false;
|
||||
// Healthy markers in the tail mean whatever blew up has been recovered
|
||||
// from — the server is up and answering requests.
|
||||
if (/Application startup complete|"GET \/v1\/[^"]+ HTTP\/[\d.]+" 2\d\d|Uvicorn running on/i.test(TAIL)) return false;
|
||||
return true;
|
||||
return /vllm/i.test(TAIL);
|
||||
},
|
||||
message: 'Python traceback detected — may be a handled error, check logs.',
|
||||
message: 'A vLLM process hit a Python traceback and may be wedged.',
|
||||
fixes: [
|
||||
{ label: 'Kill vLLM processes', action: (panel) => _runQuickCmd(panel, 'pkill -f vllm') },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Generic traceback (not vLLM, not a pip build): surface it without
|
||||
// suggesting an unrelated vLLM kill. Same tail-only + healthy suppression.
|
||||
match: (text) => {
|
||||
const TAIL = text.slice(-4096);
|
||||
if (!/Traceback \(most recent call last\)/i.test(TAIL)) return false;
|
||||
if (/Application startup complete|"GET \/v1\/[^"]+ HTTP\/[\d.]+" 2\d\d|Uvicorn running on/i.test(TAIL)) return false;
|
||||
return true;
|
||||
},
|
||||
message: 'Python traceback detected — check the captured output below for the underlying error.',
|
||||
suggestion: 'Suggested action: read the captured output for the failing step; copy the troubleshooting bundle if you need help.',
|
||||
fixes: [],
|
||||
},
|
||||
];
|
||||
|
||||
export function _diagnose(text) {
|
||||
@@ -688,11 +748,15 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
|
||||
copyBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const bundle = _diagnosisCopyBundle(task, diagnosis, sourceText, suggestionText);
|
||||
try {
|
||||
await navigator.clipboard.writeText(bundle);
|
||||
// Use the shared helper which falls back to execCommand('copy') on
|
||||
// non-HTTPS origins (Tailscale IPs, LAN IPs, etc.) — navigator.clipboard
|
||||
// is silently a no-op on those, which is why the button appeared dead
|
||||
// for users on http://100.113.161.2:7011 over Tailscale/mobile.
|
||||
const ok = await _copyText(bundle);
|
||||
if (ok) {
|
||||
copyBtn.classList.add('copied');
|
||||
setTimeout(() => { if (copyBtn.isConnected) copyBtn.classList.remove('copied'); }, 1200);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
const dismissBtn = document.createElement('button');
|
||||
@@ -757,7 +821,7 @@ export function _showDiagnosis(panel, diagnosis, sourceText) {
|
||||
});
|
||||
row.appendChild(btn);
|
||||
}
|
||||
body.appendChild(row);
|
||||
diag.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-36
@@ -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
|
||||
@@ -578,7 +578,9 @@ export async function _hwfitFetch(fresh = false) {
|
||||
const _cached = fresh ? null : _readScanCache(_sig);
|
||||
const wp = spinnerModule.createWhirlpool(18);
|
||||
if (_cached) {
|
||||
_hwfitCache = _cached;
|
||||
// Tag the restored cache with its host too (scan-sig keys cache per
|
||||
// host, so a hit here is always for the current remoteHost).
|
||||
_hwfitCache = { ..._cached, _scannedHost: remoteHost || '' };
|
||||
_hwfitRenderHw(hw, _cached.system);
|
||||
if (!remoteHost && _cached.system && _cached.system.platform) {
|
||||
_envState.platform = _cached.system.platform;
|
||||
@@ -750,7 +752,11 @@ export async function _hwfitFetch(fresh = false) {
|
||||
: _olRows;
|
||||
data.models = (data.models || []).concat(_olFiltered);
|
||||
}
|
||||
_hwfitCache = data;
|
||||
// Tag the cache with the host this scan was for, so downstream
|
||||
// code (_gpuEnvVarName, backend-aware command builders) can avoid
|
||||
// trusting a stale scan when the user switches the server picker
|
||||
// to a different target without re-running hwfit.
|
||||
_hwfitCache = { ...data, _scannedHost: remoteHost || '' };
|
||||
_hwfitRenderHw(hw, data.system);
|
||||
// Propagate local platform from hardware probe so _isWindows(task) works
|
||||
// for local tasks (menu items, shell commands, etc.).
|
||||
@@ -1415,23 +1421,11 @@ export function _expandModelRow(row, modelData) {
|
||||
|
||||
const dlSource = _downloadSourceRepo(modelData, backend);
|
||||
const hfUrl = `https://huggingface.co/${dlSource.repo}`;
|
||||
// Official vendor recipe deep-links. These point to vLLM / SGLang's curated
|
||||
// hardware-specific launch-command pages. They 404 for uncatalogued models \u2014
|
||||
// a known tradeoff; user just gets the vendor's "model not found" page.
|
||||
const _recipeRepo = modelData.name || '';
|
||||
const _vllmUrl = _recipeRepo ? `https://recipes.vllm.ai/${_recipeRepo}` : '';
|
||||
const _sglangUrl = _recipeRepo ? `https://docs.sglang.io/cookbook/autoregressive/${_recipeRepo}${_sglangHashFor(modelData)}` : '';
|
||||
let html = `<div class="hwfit-action-panel" data-model-name="${esc(modelData.name)}">`;
|
||||
html += `<div class="hwfit-panel-header">`;
|
||||
html += `<span class="hwfit-panel-model">${esc(modelData.name)}${dlSource.kind ? ` <span style="opacity:0.5;font-size:10px;">(${esc(dlSource.kind)} ${esc(modelData.quant || '')})</span>` : (modelData.quant_repo ? ` <span style="opacity:0.5;font-size:10px;">(${esc(modelData.quant)})</span>` : '')}</span>`;
|
||||
html += `<span class="hwfit-panel-badge">${esc(label)}</span>`;
|
||||
html += `<a href="${esc(hfUrl)}" target="_blank" rel="noopener" class="hwfit-panel-hf-link" title="View download source on HuggingFace">HF \u2197</a>`;
|
||||
if (backend === 'vllm' && _vllmUrl) {
|
||||
html += `<a href="${esc(_vllmUrl)}" target="_blank" rel="noopener" class="hwfit-panel-hf-link" title="vLLM official recipe (curated launch command). 404s if this model isn't in vLLM's recipes catalog.">vLLM \u2197</a>`;
|
||||
}
|
||||
if (backend === 'sglang' && _sglangUrl) {
|
||||
html += `<a href="${esc(_sglangUrl)}" target="_blank" rel="noopener" class="hwfit-panel-hf-link" title="SGLang cookbook (hash pre-filled with your detected hardware). 404s if this model isn't in SGLang's cookbook catalog.">SGLang \u2197</a>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
html += `<div class="hwfit-panel-actions">`;
|
||||
html += `<button class="cookbook-btn hwfit-dl-btn">Download</button>`;
|
||||
@@ -1499,36 +1493,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) {
|
||||
@@ -1543,11 +1535,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
|
||||
@@ -1556,8 +1549,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) {
|
||||
@@ -1664,7 +1655,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';
|
||||
|
||||
@@ -1679,7 +1670,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 = `MODEL_FILE=${ggufPath} && { [ -n "$MODEL_FILE" ] && [ -f "$MODEL_FILE" ]; } || { echo "ERROR: No GGUF found on this host. Download a GGUF quant or switch backend."; exit 1; } && llama-server --model "$MODEL_FILE" --host 0.0.0.0 --port 8080 -ngl 99 -c ${maxCtx} || python3 -m llama_cpp.server --model "$MODEL_FILE" --host 0.0.0.0 --port 8080 --n_gpu_layers 99 --n_ctx ${maxCtx}`;
|
||||
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}`;
|
||||
|
||||
+678
-106
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,22 @@ function _ggufIncludePattern(model, source) {
|
||||
return '*.gguf';
|
||||
}
|
||||
|
||||
function _ggufDisplayPartFromInclude(include) {
|
||||
const clean = String(include || '').replace(/\*/g, '');
|
||||
const parts = clean.split('/').filter(Boolean);
|
||||
const file = parts[parts.length - 1] || clean;
|
||||
const dir = parts.length > 1 ? parts[parts.length - 2] : '';
|
||||
const quant = `${dir} ${file}`.match(/\b(?:UD-)?(?:IQ[1-8]_[A-Z0-9]+|Q[2-8]_K_[MLS]|Q[2-8]_[0-9A-Z]+|Q[2-8])\b/i);
|
||||
if (quant) return quant[0].toUpperCase().replace(/^UD-/, '');
|
||||
return file.replace(/\.gguf$/i, '').replace(/-\d{5}-of-\d{5}$/i, '');
|
||||
}
|
||||
|
||||
function _downloadTaskName(shortName, payload) {
|
||||
const include = payload?.include || '';
|
||||
const part = include ? _ggufDisplayPartFromInclude(include) : '';
|
||||
return part ? `${shortName} · ${part}` : shortName;
|
||||
}
|
||||
|
||||
function _missingGgufMessage(model) {
|
||||
const name = model?.name || 'this model';
|
||||
if (/\bnvfp4\b/i.test(name)) {
|
||||
@@ -519,6 +535,7 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
}
|
||||
|
||||
const shortName = (model.name || repo).split('/').pop();
|
||||
const taskName = _downloadTaskName(shortName, payload);
|
||||
const targetHost = host || 'local';
|
||||
|
||||
const tasks = _loadTasks();
|
||||
@@ -576,7 +593,7 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
if (activeOnHost) {
|
||||
const queueId = `queue-${Date.now().toString(36)}`;
|
||||
const allTasks = _loadTasks();
|
||||
allTasks.push({ id: queueId, sessionId: queueId, name: shortName, type: 'download', status: 'queued', output: '', ts: Date.now(), payload, remoteHost: host });
|
||||
allTasks.push({ id: queueId, sessionId: queueId, name: taskName, type: 'download', status: 'queued', output: '', ts: Date.now(), payload, remoteHost: host });
|
||||
_saveTasks(allTasks);
|
||||
_renderRunningTab();
|
||||
uiModule.showToast(`Queued ${shortName} — waiting for current download`);
|
||||
@@ -601,8 +618,8 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
|
||||
uiModule.showToast('Download failed: ' + (data.error || ''), 9000);
|
||||
return;
|
||||
}
|
||||
_addTask(data.session_id, shortName, 'download', payload);
|
||||
uiModule.showToast(`Downloading ${shortName}...`);
|
||||
_addTask(data.session_id, taskName, 'download', payload);
|
||||
uiModule.showToast(`Downloading ${taskName}...`);
|
||||
} catch (e) {
|
||||
uiModule.showToast('Download failed: ' + e.message, 9000);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+264
-94
@@ -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
|
||||
@@ -27,6 +28,9 @@ function _statusLabel(status, type) {
|
||||
// "cookbook-task-status" ('' = the neutral loading style).
|
||||
function _taskBadge(task) {
|
||||
if (task._unreachable && task.status === 'running') return { text: 'unreachable', cls: 'cookbook-task-error' };
|
||||
if (task.type === 'download' && task.status === 'running') {
|
||||
return { text: _statusLabel(task.status, task.type), cls: 'cookbook-task-downloading' };
|
||||
}
|
||||
if (task.type === 'serve' && task.status === 'running' && task.progress) {
|
||||
// Same green "running" pill — just with dynamic phase text, so it doesn't
|
||||
// read as a different status while the server is coming up.
|
||||
@@ -35,6 +39,47 @@ function _taskBadge(task) {
|
||||
return { text: _statusLabel(task.status, task.type), cls: 'cookbook-task-' + task.status };
|
||||
}
|
||||
|
||||
function _ggufDisplayPartFromPath(path) {
|
||||
const parts = String(path || '').split('/').filter(Boolean);
|
||||
const file = parts[parts.length - 1] || '';
|
||||
const dir = parts.length > 1 ? parts[parts.length - 2] : '';
|
||||
const text = `${dir} ${file}`;
|
||||
const quant = text.match(/\b(?:UD-)?(?:IQ[1-8]_[A-Z0-9]+|Q[2-8]_K_[MLS]|Q[2-8]_[0-9A-Z]+|Q[2-8])\b/i);
|
||||
if (quant) return quant[0].toUpperCase().replace(/^UD-/, '');
|
||||
return file.replace(/\.gguf$/i, '').replace(/-\d{5}-of-\d{5}$/i, '');
|
||||
}
|
||||
|
||||
function _downloadDisplayName(name, task) {
|
||||
const include = task?.payload?.include || '';
|
||||
if (!include || String(name || '').includes(' · ')) return name;
|
||||
const part = _ggufDisplayPartFromPath(include.replace(/\*/g, ''));
|
||||
return part ? `${name} · ${part}` : name;
|
||||
}
|
||||
|
||||
function _taskDisplayName(task) {
|
||||
const name = String(task?.name || '').trim();
|
||||
if (task?.type === 'download') return _downloadDisplayName(name, task);
|
||||
if (task?.type !== 'serve') return name;
|
||||
const gguf = task?.payload?._fields?.gguf_file || task?.payload?.gguf_file || '';
|
||||
if (!gguf || name.includes(' · ')) return name;
|
||||
const part = _ggufDisplayPartFromPath(gguf);
|
||||
return part ? `${name} · ${part}` : name;
|
||||
}
|
||||
|
||||
function _canLaunchDownloadedTask(task) {
|
||||
return task?.type === 'download' && ['done', 'completed'].includes(task.status || '') && !!(task.payload?.repo_id || task.name);
|
||||
}
|
||||
|
||||
function _downloadServeFields(task) {
|
||||
const include = String(task?.payload?.include || '').trim();
|
||||
if (!include) return null;
|
||||
return {
|
||||
backend: 'llamacpp',
|
||||
_forceBackend: true,
|
||||
_preferredGgufInclude: include,
|
||||
};
|
||||
}
|
||||
|
||||
// A download task whose tmux output still shows an active per-shard line
|
||||
// (e.g. "model-00012-of-00082.safetensors: 56%|") is NOT actually finished —
|
||||
// the cookbook just lost track. The clear pill becomes a "reconnect" affordance
|
||||
@@ -52,13 +97,13 @@ function _downloadOutputLooksActive(task) {
|
||||
|
||||
function _canClearTask(task) {
|
||||
if (!task || task.status === 'running') return false;
|
||||
if (task.type === 'serve' && (task.status === 'ready' || task._serveReady)) return false;
|
||||
if (task.type === 'serve' && (task.status === 'ready' || (task._serveReady && !['stopped', 'error', 'crashed', 'failed', 'completed'].includes(task.status)))) return false;
|
||||
// If the tmux output still shows an in-flight download, the task isn't
|
||||
// actually finished — hide the clear/check pill so it doesn't show on a
|
||||
// task that's still doing work. (The next render will reflect this and
|
||||
// ideally the self-heal flips status back to running.)
|
||||
if (_downloadOutputLooksActive(task)) return false;
|
||||
return ['done', 'stopped', 'error', 'crashed', 'failed'].includes(task.status);
|
||||
return ['done', 'completed', 'stopped', 'error', 'crashed', 'failed'].includes(task.status);
|
||||
}
|
||||
|
||||
function _clearPillLabel(task) {
|
||||
@@ -66,6 +111,13 @@ function _clearPillLabel(task) {
|
||||
return 'clear';
|
||||
}
|
||||
|
||||
function _venvRootFromPath(path) {
|
||||
let p = (path || '').toString().trim().replace(/\/+$/, '');
|
||||
if (!p) return '';
|
||||
p = p.replace(/\/bin\/(?:activate|python(?:3(?:\.\d+)?)?|vllm|pip(?:3)?)$/i, '');
|
||||
return p;
|
||||
}
|
||||
|
||||
// A pip dependency/driver install (payload._dep) reports success with the
|
||||
// runner's "=== Process exited with code 0 ===" sentinel and pip's
|
||||
// "Successfully installed" line — never the HuggingFace download markers
|
||||
@@ -141,6 +193,13 @@ async function _openDownloadForGgufTask(task) {
|
||||
function _terminalServeDiagnosis(task, outputText) {
|
||||
const out = String(outputText || task?.output || '');
|
||||
if (!task || task.type !== 'serve' || !['stopped', 'error', 'crashed', 'failed'].includes(task.status) || !out.trim()) return null;
|
||||
// Suppress the crash diagnosis when the output proves the server
|
||||
// actually became reachable — e.g. an early `exit 127` from a failed
|
||||
// build attempt was followed by the shim/Python fallback successfully
|
||||
// starting Uvicorn. Without this, the user sees a confusing "build
|
||||
// stopped before the server became reachable" toast while the server
|
||||
// is right there serving requests.
|
||||
if (_serveOutputLooksReady(task)) return null;
|
||||
// Pip tasks (Reinstall vLLM, Upgrade torch, etc.) ride on the serve task
|
||||
// type so they get a tmux session + show up in Running tab — but they are
|
||||
// NOT serve invocations. Their output is pip's own; the generic
|
||||
@@ -208,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) {
|
||||
@@ -256,6 +313,7 @@ let _copyText;
|
||||
let _persistEnvState;
|
||||
let _refreshDependencies;
|
||||
let _serverByVal;
|
||||
let _serverKey;
|
||||
let _selectedServer;
|
||||
let modelLogo;
|
||||
let esc;
|
||||
@@ -264,6 +322,40 @@ let _detectToolParser;
|
||||
let _detectModelOptimizations;
|
||||
let _buildServeCmd;
|
||||
|
||||
function _taskServerSelection(task) {
|
||||
const host = task?.remoteHost || task?.payload?.remote_host || '';
|
||||
const savedKey = task?.remoteServerKey || task?.payload?.remote_server_key || '';
|
||||
const server = (savedKey ? _serverByVal(savedKey) : null)
|
||||
|| (host ? _serverByVal(host) : null)
|
||||
|| (host ? _envState.servers.find(s => s.host === host) : null)
|
||||
|| null;
|
||||
const key = server ? (_serverKey ? _serverKey(server) : savedKey) : (savedKey || (host || 'local'));
|
||||
return { host, server, key };
|
||||
}
|
||||
|
||||
function _selectTaskServer(task) {
|
||||
const { host, server, key } = _taskServerSelection(task);
|
||||
_envState.remoteHost = host;
|
||||
_envState.remoteServerKey = key === 'local' ? '' : key;
|
||||
if (server) {
|
||||
_envState.env = server.env || 'none';
|
||||
_envState.envPath = server.envPath || '';
|
||||
_envState.platform = server.platform || '';
|
||||
} else if (!host) {
|
||||
_envState.env = 'none';
|
||||
_envState.envPath = '';
|
||||
_envState.platform = '';
|
||||
}
|
||||
document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => {
|
||||
if (!sel || sel.tagName !== 'SELECT') return;
|
||||
const wanted = key || (host || 'local');
|
||||
if ([...sel.options].some(o => o.value === wanted)) sel.value = wanted;
|
||||
else if (host && [...sel.options].some(o => o.value === host)) sel.value = host;
|
||||
else sel.value = host ? wanted : 'local';
|
||||
});
|
||||
return { host, server, key };
|
||||
}
|
||||
|
||||
// When a new action is started (download / dependency / serve), this holds the
|
||||
// new task's id so the next render collapses every other card and leaves only
|
||||
// the new one open. Consumed (cleared) by _renderRunningTab.
|
||||
@@ -362,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 ──
|
||||
@@ -526,7 +616,7 @@ async function _startQueuedDownload(task) {
|
||||
if (t.sessionId === data.session_id) return false;
|
||||
return !(key && t.type === 'download' && t.status === 'queued' && _downloadDedupeKey(t) === key);
|
||||
});
|
||||
if (!found) tasks.push(_stripTaskSecrets(launchedTask));
|
||||
if (!found) tasks.push(_redactTaskForStorage(launchedTask));
|
||||
_saveTasks(tasks);
|
||||
_renderRunningTab();
|
||||
_startBackgroundMonitor();
|
||||
@@ -636,28 +726,53 @@ function _loadPrunedTasks() {
|
||||
const _REMOVED_KEY = 'cookbook-removed-tasks';
|
||||
const _TOMBSTONE_TTL_MS = 24 * 3600 * 1000;
|
||||
function _loadTombstones() {
|
||||
try { return JSON.parse(localStorage.getItem(_REMOVED_KEY)) || {}; }
|
||||
try {
|
||||
const tomb = JSON.parse(localStorage.getItem(_REMOVED_KEY)) || {};
|
||||
const now = Date.now();
|
||||
let changed = false;
|
||||
for (const k in tomb) {
|
||||
if (now - tomb[k] > _TOMBSTONE_TTL_MS) {
|
||||
delete tomb[k];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) localStorage.setItem(_REMOVED_KEY, JSON.stringify(tomb));
|
||||
return tomb;
|
||||
}
|
||||
catch { return {}; }
|
||||
}
|
||||
function _saveTombstones(tomb) {
|
||||
localStorage.setItem(_REMOVED_KEY, JSON.stringify(tomb || {}));
|
||||
}
|
||||
function _tombstoneTask(id) {
|
||||
if (!id) return;
|
||||
const tomb = _loadTombstones();
|
||||
const now = Date.now();
|
||||
tomb[id] = now;
|
||||
for (const k in tomb) { if (now - tomb[k] > _TOMBSTONE_TTL_MS) delete tomb[k]; }
|
||||
localStorage.setItem(_REMOVED_KEY, JSON.stringify(tomb));
|
||||
_saveTombstones(tomb);
|
||||
}
|
||||
function _isTombstoned(id) {
|
||||
const ts = _loadTombstones()[id];
|
||||
return ts != null && (Date.now() - ts) <= _TOMBSTONE_TTL_MS;
|
||||
}
|
||||
|
||||
function _stripTaskSecrets(task) {
|
||||
function _redactStoredText(value) {
|
||||
return String(value || '')
|
||||
.replace(/hf_[A-Za-z0-9]{20,}/g, '[redacted-token]')
|
||||
.replace(/((?:api[_-]?key|token|authorization|password|passwd|secret)\s*[=:]\s*)(["']?)[^\s"']+/gi, '$1$2[redacted]');
|
||||
}
|
||||
|
||||
function _redactTaskForStorage(task) {
|
||||
if (!task || typeof task !== 'object') return task;
|
||||
const safe = { ...task };
|
||||
if (typeof safe.output === 'string') safe.output = _redactStoredText(safe.output);
|
||||
if (safe.payload && typeof safe.payload === 'object') {
|
||||
safe.payload = { ...safe.payload };
|
||||
delete safe.payload.hf_token;
|
||||
delete safe.payload.hfToken;
|
||||
if (typeof safe.payload._cmd === 'string') safe.payload._cmd = _redactStoredText(safe.payload._cmd);
|
||||
if (typeof safe.payload.cmd === 'string') safe.payload.cmd = _redactStoredText(safe.payload.cmd);
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
@@ -666,23 +781,24 @@ function _stripStateSecrets(state) {
|
||||
const safe = { ...state };
|
||||
if (safe.env && typeof safe.env === 'object') {
|
||||
const { hfToken, ...env } = safe.env;
|
||||
if (hfToken) env.hfToken = hfToken;
|
||||
safe.env = env;
|
||||
}
|
||||
if (Array.isArray(safe.tasks)) safe.tasks = safe.tasks.map(_stripTaskSecrets);
|
||||
if (Array.isArray(safe.tasks)) safe.tasks = safe.tasks.map(_redactTaskForStorage);
|
||||
return safe;
|
||||
}
|
||||
|
||||
export function _saveTasks(tasks) {
|
||||
localStorage.setItem(TASKS_KEY, JSON.stringify((tasks || []).map(_stripTaskSecrets)));
|
||||
localStorage.setItem(TASKS_KEY, JSON.stringify((tasks || []).map(_redactTaskForStorage)));
|
||||
_syncToServer();
|
||||
}
|
||||
|
||||
export function _addTask(sessionId, name, type, payload) {
|
||||
let tasks = _loadTasks();
|
||||
const remoteHost = (payload && payload.remote_host) || _envState.remoteHost || '';
|
||||
const sshPort = (payload && payload.ssh_port) || _getPort(remoteHost) || '';
|
||||
const platform = (payload && payload.platform) || _getPlatform(remoteHost) || '';
|
||||
const remoteServerKey = (payload && payload.remote_server_key) || '';
|
||||
const remoteServerName = (payload && payload.remote_server_name) || '';
|
||||
const sshPort = (payload && payload.ssh_port) || _getPort(remoteServerKey || remoteHost) || '';
|
||||
const platform = (payload && payload.platform) || _getPlatform(remoteServerKey || remoteHost) || '';
|
||||
// Serving a model supersedes its finished download — clear the matching
|
||||
// finished download card (covers serving directly from the Serve tab, not just
|
||||
// via the download card's "Serve →" button).
|
||||
@@ -697,7 +813,7 @@ export function _addTask(sessionId, name, type, payload) {
|
||||
return !(key && t.type === 'download' && t.status === 'queued' && _downloadDedupeKey(t) === key);
|
||||
});
|
||||
}
|
||||
const task = _stripTaskSecrets({ id: sessionId, sessionId, name, type, status: 'running', output: '', ts: Date.now(), payload: payload || null, remoteHost, sshPort, platform });
|
||||
const task = _redactTaskForStorage({ id: sessionId, sessionId, name, type, status: 'running', output: '', ts: Date.now(), payload: payload || null, remoteHost, remoteServerKey, remoteServerName, sshPort, platform });
|
||||
tasks.push(task);
|
||||
_saveTasks(tasks);
|
||||
// New action → collapse all other cards, leave only this one open.
|
||||
@@ -784,40 +900,47 @@ function _winSessionCmd(task, tmuxArgs) {
|
||||
const ps = host
|
||||
? `Get-Content '${sd}\\${sid}.log' -Tail ${lines} -ErrorAction SilentlyContinue`
|
||||
: `Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.log') -Tail ${lines} -ErrorAction SilentlyContinue`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
return _winPowerShellCmd(task, ps);
|
||||
}
|
||||
if (tmuxArgs.includes('has-session')) {
|
||||
const ps = host
|
||||
? `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Get-Process -Id $p -ErrorAction SilentlyContinue | Out-Null; if ($?) { exit 0 } else { exit 1 } } else { exit 1 }`
|
||||
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Get-Process -Id $p -ErrorAction SilentlyContinue | Out-Null; if ($?) { exit 0 } else { exit 1 } } else { exit 1 }`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
return _winPowerShellCmd(task, ps);
|
||||
}
|
||||
if (tmuxArgs.includes('kill-session')) {
|
||||
const stopTree = `function Stop-Tree([int]$Id) { Get-CimInstance Win32_Process -Filter "ParentProcessId = $Id" -ErrorAction SilentlyContinue | ForEach-Object { Stop-Tree ([int]$_.ProcessId) }; Stop-Process -Id $Id -Force -ErrorAction SilentlyContinue }`;
|
||||
const ps = host
|
||||
? `${stopTree}; $p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`
|
||||
: `${stopTree}; $p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.*') -Force -ErrorAction SilentlyContinue`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
const ps = _winSessionStopTreePs(task);
|
||||
return _winPowerShellCmd(task, ps);
|
||||
}
|
||||
if (tmuxArgs.includes('send-keys') && tmuxArgs.includes('C-c')) {
|
||||
const ps = host
|
||||
? `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -ErrorAction SilentlyContinue }`
|
||||
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -ErrorAction SilentlyContinue }`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
return _winPowerShellCmd(task, ps);
|
||||
}
|
||||
return host ? `ssh ${pf}${host} 'tmux ${tmuxArgs}' 2>/dev/null` : `tmux ${tmuxArgs} 2>/dev/null`;
|
||||
}
|
||||
|
||||
function _winPowerShellCmd(task, ps) {
|
||||
const command = `powershell -Command "${ps}"`;
|
||||
if (!task.remoteHost) return command;
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(command)}`;
|
||||
}
|
||||
|
||||
function _winSessionStopTreePs(task) {
|
||||
const host = task.remoteHost;
|
||||
const sd = host ? '$env:TEMP\\odysseus-sessions' : '$env:TEMP\\odysseus-tmux';
|
||||
const sid = task.sessionId;
|
||||
const stopTree = `function Stop-Tree([int]$Id) { Get-CimInstance Win32_Process -Filter ('ParentProcessId = ' + $Id) -ErrorAction SilentlyContinue | ForEach-Object { Stop-Tree ([int]$_.ProcessId) }; Stop-Process -Id $Id -Force -ErrorAction SilentlyContinue }`;
|
||||
return host
|
||||
? `${stopTree}; $p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`
|
||||
: `${stopTree}; $p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.*') -Force -ErrorAction SilentlyContinue`;
|
||||
}
|
||||
|
||||
export function _tmuxGracefulKill(task) {
|
||||
if (_isWindows(task)) {
|
||||
const host = task.remoteHost;
|
||||
const sd = host ? '$env:TEMP\\odysseus-sessions' : '$env:TEMP\\odysseus-tmux';
|
||||
const sid = task.sessionId;
|
||||
const pf = _sshPrefix(_getPort(task));
|
||||
const ps = host
|
||||
? `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`
|
||||
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.*') -Force -ErrorAction SilentlyContinue`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
const ps = _winSessionStopTreePs(task);
|
||||
return _winPowerShellCmd(task, ps);
|
||||
}
|
||||
if (task.remoteHost) {
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} 'tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null'`;
|
||||
@@ -985,14 +1108,24 @@ function _presetEnvFields(task) {
|
||||
};
|
||||
}
|
||||
|
||||
function _redactPresetForStorage(preset) {
|
||||
if (!preset || typeof preset !== 'object') return preset;
|
||||
const safe = { ...preset };
|
||||
if (typeof safe.cmd === 'string') safe.cmd = _redactStoredText(safe.cmd);
|
||||
if (typeof safe.command === 'string') safe.command = _redactStoredText(safe.command);
|
||||
delete safe.hf_token;
|
||||
delete safe.hfToken;
|
||||
return safe;
|
||||
}
|
||||
|
||||
function _saveTaskAsPreset(task, label) {
|
||||
const host = task.remoteHost || 'localhost';
|
||||
const portMatch = task.payload?._cmd?.match(/--port\s+(\d+)/);
|
||||
const port = portMatch ? portMatch[1] : '8000';
|
||||
const presets = _loadPresets();
|
||||
if (presets.some(p => p.cmd === task.payload._cmd)) return false;
|
||||
presets.push({ name: task.name, model: task.payload.repo_id, backend: 'vllm', host, port, cmd: task.payload._cmd, remoteHost: task.remoteHost || '', label: label || task.name, ..._presetEnvFields(task) });
|
||||
_savePresets(presets);
|
||||
presets.push(_redactPresetForStorage({ name: task.name, model: task.payload.repo_id, backend: 'vllm', host, port, cmd: task.payload._cmd, remoteHost: task.remoteHost || '', label: label || task.name, ..._presetEnvFields(task) }));
|
||||
_savePresets(presets.map(_redactPresetForStorage));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1035,7 +1168,7 @@ function _autoSaveWorkingConfig(task) {
|
||||
const existing = presets.find(p => p.cmd === cmd);
|
||||
if (existing) {
|
||||
task._autoSaved = true;
|
||||
if (!existing.confirmedWorking) { existing.confirmedWorking = true; _savePresets(presets); }
|
||||
if (!existing.confirmedWorking) { existing.confirmedWorking = true; _savePresets(presets.map(_redactPresetForStorage)); }
|
||||
return; // already saved → just confirm it, no duplicate, no toast
|
||||
}
|
||||
// Respect the per-model cap the manual save flow uses (max 5).
|
||||
@@ -1043,13 +1176,13 @@ function _autoSaveWorkingConfig(task) {
|
||||
const host = task.remoteHost || 'localhost';
|
||||
const portMatch = cmd.match(/--port[=\s]+(\d+)/);
|
||||
const port = portMatch ? portMatch[1] : '8000';
|
||||
presets.push({
|
||||
presets.push(_redactPresetForStorage({
|
||||
name: task.name, model, backend: 'vllm', host, port,
|
||||
cmd, remoteHost: task.remoteHost || '',
|
||||
label: _autoConfigLabel(task), confirmedWorking: true, autoSaved: true,
|
||||
..._presetEnvFields(task),
|
||||
});
|
||||
_savePresets(presets);
|
||||
}));
|
||||
_savePresets(presets.map(_redactPresetForStorage));
|
||||
task._autoSaved = true;
|
||||
uiModule.showToast('Saved working config');
|
||||
}
|
||||
@@ -1071,6 +1204,7 @@ function _syncToServer() {
|
||||
if (!_envState || !Array.isArray(_envState.servers) || _envState.servers.length === 0) return;
|
||||
const state = {
|
||||
tasks: _loadTasks(),
|
||||
removedTasks: _loadTombstones(),
|
||||
presets: _loadPresets(),
|
||||
env: _envState,
|
||||
serveState: null,
|
||||
@@ -1119,15 +1253,22 @@ export async function _syncFromServer() {
|
||||
|
||||
const localTasks = _loadTasks();
|
||||
const serverTasks = state.tasks || [];
|
||||
const serverTombstones = (state.removedTasks && typeof state.removedTasks === 'object') ? state.removedTasks : {};
|
||||
const localTombstones = _loadTombstones();
|
||||
const mergedTombstones = { ...serverTombstones, ...localTombstones };
|
||||
for (const [id, ts] of Object.entries(serverTombstones)) {
|
||||
if (localTombstones[id] == null || Number(ts) > Number(localTombstones[id])) mergedTombstones[id] = ts;
|
||||
}
|
||||
_saveTombstones(mergedTombstones);
|
||||
|
||||
const localIds = new Set(localTasks.map(t => t.sessionId));
|
||||
const merged = [...localTasks];
|
||||
const merged = localTasks.filter(t => !_isTombstoned(t.sessionId));
|
||||
for (const t of serverTasks) {
|
||||
if (!localIds.has(t.sessionId) && !_isTombstoned(t.sessionId)) {
|
||||
merged.push(t);
|
||||
}
|
||||
}
|
||||
localStorage.setItem(TASKS_KEY, JSON.stringify(merged.map(_stripTaskSecrets)));
|
||||
localStorage.setItem(TASKS_KEY, JSON.stringify(merged.map(_redactTaskForStorage)));
|
||||
|
||||
if (state.env) {
|
||||
// The active server selection (remoteHost + its env/path/platform) is a
|
||||
@@ -1138,6 +1279,18 @@ export async function _syncFromServer() {
|
||||
const { remoteHost: _rh, env: _e, envPath: _ep, platform: _pf, ...settings } = state.env;
|
||||
delete settings.hfToken;
|
||||
Object.assign(_envState, settings);
|
||||
const selected = (_envState.remoteServerKey && _serverByVal?.(_envState.remoteServerKey))
|
||||
|| (_envState.remoteHost ? (_envState.servers || []).find(s => s.host === _envState.remoteHost) : null);
|
||||
if (selected) {
|
||||
_envState.env = selected.env || 'none';
|
||||
_envState.envPath = selected.envPath || '';
|
||||
_envState.platform = selected.platform || '';
|
||||
} else if (!_envState.remoteHost) {
|
||||
const local = (_envState.servers || []).find(s => !s.host || s.host === 'local');
|
||||
_envState.env = local?.env || 'none';
|
||||
_envState.envPath = local?.envPath || '';
|
||||
_envState.platform = local?.platform || '';
|
||||
}
|
||||
const { hfToken, ...safeState } = _envState;
|
||||
localStorage.setItem('cookbook-last-state', JSON.stringify(safeState));
|
||||
}
|
||||
@@ -1147,6 +1300,7 @@ export async function _syncFromServer() {
|
||||
if (state.serveState) {
|
||||
localStorage.setItem(SERVE_STATE_KEY, JSON.stringify(state.serveState));
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('cookbook:state-synced', { detail: state }));
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
@@ -1305,17 +1459,11 @@ async function _openServeEditForTask(task, cmdOverride, fieldOverrides = null) {
|
||||
if (fieldOverrides && typeof fieldOverrides === 'object') {
|
||||
fields = { ...(fields || {}), ...fieldOverrides };
|
||||
}
|
||||
// Switch the active server to the one this serve ran on (mirrors _openEdit).
|
||||
const _tHost = task.remoteHost || '';
|
||||
_envState.remoteHost = _tHost;
|
||||
const _tSrv = _serverByVal(_envState.remoteServerKey || _tHost)
|
||||
|| _envState.servers.find(s => s.host === _tHost);
|
||||
if (_tSrv) { _envState.env = _tSrv.env || 'none'; _envState.envPath = _tSrv.envPath || ''; _envState.platform = _tSrv.platform || ''; }
|
||||
else if (!_tHost) { _envState.env = 'none'; _envState.envPath = ''; _envState.platform = ''; }
|
||||
document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => {
|
||||
if (!sel || sel.tagName !== 'SELECT') return;
|
||||
sel.value = _tHost || 'local';
|
||||
});
|
||||
fields = { ...(fields || {}), _replaceTaskId: task.sessionId };
|
||||
// Switch the active server to the exact profile this serve ran on. The
|
||||
// dropdown stores stable srv: keys, not raw host strings, so preserving only
|
||||
// task.remoteHost can relaunch against the local container by accident.
|
||||
_selectTaskServer(task);
|
||||
try {
|
||||
const { openServePanelForRepo } = await import('./cookbookServe.js');
|
||||
await openServePanelForRepo(repo, fields);
|
||||
@@ -1513,15 +1661,33 @@ function _parseServeCmdToFields(cmd) {
|
||||
return fields;
|
||||
}
|
||||
|
||||
export async function _launchServeTask(shortName, repo, cmd, fields, hostOverride) {
|
||||
export async function _launchServeTask(shortName, repo, cmd, fields, hostOverride, targetMeta = null) {
|
||||
// Host resolution mirrors the download path: when the caller passes an explicit
|
||||
// host (resolved from the dropdown the user actually picked), use it and look
|
||||
// up that server's port/platform from the shared servers list. Only fall back
|
||||
// to _envState.remoteHost for legacy callers (diagnosis/pip-update).
|
||||
const _host = (hostOverride !== undefined) ? (hostOverride || '') : (_envState.remoteHost || '');
|
||||
const _hsrv = _serverByVal(_envState.remoteServerKey || _host)
|
||||
const _targetKey = targetMeta?.serverKey || '';
|
||||
const _hsrv = (_targetKey && _targetKey !== 'local' ? _serverByVal(_targetKey) : null)
|
||||
|| (hostOverride === undefined ? _serverByVal(_envState.remoteServerKey || _host) : null)
|
||||
|| _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 _replaceTaskId = fields?._replaceTaskId || '';
|
||||
if (_replaceTaskId) {
|
||||
try {
|
||||
const _old = _loadTasks().find(t => t.sessionId === _replaceTaskId);
|
||||
if (_old && _old.type === 'serve') {
|
||||
await fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxGracefulKill(_old) }),
|
||||
});
|
||||
_removeTask(_old.sessionId);
|
||||
}
|
||||
} 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
|
||||
@@ -1565,7 +1731,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
|
||||
}
|
||||
} else {
|
||||
if (_envState.env === 'venv' && _envState.envPath) {
|
||||
const p = _envState.envPath;
|
||||
const p = _venvRootFromPath(_envState.envPath);
|
||||
envPrefix = 'source ' + (p.endsWith('/bin/activate') ? p : p + '/bin/activate');
|
||||
} else if (_envState.env === 'conda' && _envState.envPath) {
|
||||
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _envState.envPath;
|
||||
@@ -1576,7 +1742,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
|
||||
repo_id: repo,
|
||||
cmd: cmd,
|
||||
remote_host: _host || undefined,
|
||||
ssh_port: _getPort(_host) || undefined,
|
||||
ssh_port: _getPort(_serverMetaKey || _host) || undefined,
|
||||
env_prefix: envPrefix || undefined,
|
||||
hf_token: _envState.hfToken || undefined,
|
||||
gpus: _envState.gpus || undefined,
|
||||
@@ -1600,11 +1766,11 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
|
||||
return;
|
||||
}
|
||||
|
||||
const _sp = _getPort(_host);
|
||||
const _sp = _getPort(_serverMetaKey || _host);
|
||||
// _fields = the exact structured serve-form values used for this launch,
|
||||
// so the "Edit / relaunch" button can re-open the Serve panel pre-filled
|
||||
// with these precise settings (not just the last-used-for-repo state).
|
||||
const payload = { repo_id: repo, remote_host: _host || undefined, ssh_port: _sp || undefined, _cmd: cmd, _fields: fields || undefined, _env: _usedEnv, _envPath: _usedEnvPath, _gpus: _usedGpus };
|
||||
const payload = { repo_id: repo, remote_host: _host || undefined, remote_server_key: _serverMetaKey || undefined, remote_server_name: _serverMetaName || undefined, ssh_port: _sp || undefined, _cmd: cmd, _fields: fields || undefined, _env: _usedEnv, _envPath: _usedEnvPath, _gpus: _usedGpus };
|
||||
_addTask(data.session_id, shortName, 'serve', payload);
|
||||
uiModule.showToast(`Serving ${shortName}...`);
|
||||
// Auto-register may have enabled an existing (offline) endpoint for this
|
||||
@@ -1719,7 +1885,7 @@ export function _renderRunningTab() {
|
||||
'<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">' +
|
||||
'<h2 style="margin:0;padding:0;line-height:1;">Active <span id="running-count" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal">' + activeCount + '</span></h2>' +
|
||||
'</div>' +
|
||||
'<p class="memory-desc doclib-desc" style="margin-top:6px;">Active downloads and serving processes.</p>' +
|
||||
'<p class="memory-desc doclib-desc" style="margin-top:6px;">Active downloads, installs and model launches.</p>' +
|
||||
'</div>';
|
||||
const firstGroup = body.querySelector('.cookbook-group');
|
||||
if (firstGroup) body.insertBefore(group, firstGroup);
|
||||
@@ -1753,16 +1919,25 @@ export function _renderRunningTab() {
|
||||
}
|
||||
|
||||
// Group tasks by server
|
||||
const _serverName = (host) => {
|
||||
if (!host) return 'Local';
|
||||
const srv = _serverByVal(_envState.remoteServerKey || host)
|
||||
|| _envState.servers.find(s => s.host === host);
|
||||
return srv?.name || host;
|
||||
const _taskServerKey = (task) => task?.remoteServerKey || task?.remoteHost || '';
|
||||
const _serverName = (keyOrTask) => {
|
||||
if (keyOrTask && typeof keyOrTask === 'object') {
|
||||
const task = keyOrTask;
|
||||
if (task.remoteServerName) return task.remoteServerName;
|
||||
const srv = task.remoteServerKey ? _serverByVal(task.remoteServerKey) : null;
|
||||
if (srv?.name) return srv.name;
|
||||
if (!task.remoteHost) return 'Local';
|
||||
return (_envState.servers.find(s => s.host === task.remoteHost)?.name) || task.remoteHost;
|
||||
}
|
||||
const key = keyOrTask || '';
|
||||
if (!key || key === 'local') return 'Local';
|
||||
const srv = _serverByVal(key);
|
||||
return srv?.name || key;
|
||||
};
|
||||
const serverGroups = {};
|
||||
for (const t of tasks) {
|
||||
const key = t.remoteHost || '';
|
||||
if (!serverGroups[key]) serverGroups[key] = { name: _serverName(key), serve: [], download: [] };
|
||||
const key = _taskServerKey(t);
|
||||
if (!serverGroups[key]) serverGroups[key] = { name: _serverName(t), serve: [], download: [] };
|
||||
serverGroups[key][t.type === 'serve' ? 'serve' : 'download'].push(t);
|
||||
}
|
||||
|
||||
@@ -1809,12 +1984,12 @@ export function _renderRunningTab() {
|
||||
e.stopPropagation(); // don't toggle the section collapse (was an inline onclick, blocked by CSP)
|
||||
const host = btn.dataset.clearServer;
|
||||
const allTasks = _loadTasks();
|
||||
const toRemove = allTasks.filter(t => (t.remoteHost || '') === host && _canClearTask(t));
|
||||
const toRemove = allTasks.filter(t => _taskServerKey(t) === host && _canClearTask(t));
|
||||
// Bail with a clear message instead of silently doing nothing when
|
||||
// every task on this server is still running (nothing finished to
|
||||
// clear yet) — the previous behavior looked like the button was dead.
|
||||
if (!toRemove.length) {
|
||||
const stillRunning = allTasks.filter(t => (t.remoteHost || '') === host && t.status === 'running').length;
|
||||
const stillRunning = allTasks.filter(t => _taskServerKey(t) === host && t.status === 'running').length;
|
||||
const _msg = stillRunning
|
||||
? `No finished tasks on ${_serverName(host)} — ${stillRunning} still running. Stop them first to clear.`
|
||||
: `No finished tasks on ${_serverName(host)}.`;
|
||||
@@ -1823,7 +1998,8 @@ export function _renderRunningTab() {
|
||||
return;
|
||||
}
|
||||
if (!await window.styledConfirm(`Clear ${toRemove.length} finished task${toRemove.length === 1 ? '' : 's'} on ${_serverName(host)}?`, { confirmText: 'Clear' })) return;
|
||||
const remaining = allTasks.filter(t => (t.remoteHost || '') !== host || !_canClearTask(t));
|
||||
toRemove.forEach(t => _tombstoneTask(t.sessionId));
|
||||
const remaining = allTasks.filter(t => _taskServerKey(t) !== host || !_canClearTask(t));
|
||||
_saveTasks(remaining);
|
||||
// Fade/slide each finished card out (same exit as the per-card clear)
|
||||
// instead of yanking them instantly.
|
||||
@@ -1857,7 +2033,7 @@ export function _renderRunningTab() {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation(); // don't toggle the section collapse
|
||||
const host = btn.dataset.stopServer;
|
||||
const running = _loadTasks().filter(t => (t.remoteHost || '') === host && t.status === 'running');
|
||||
const running = _loadTasks().filter(t => _taskServerKey(t) === host && t.status === 'running');
|
||||
if (!running.length) { uiModule.showToast(`Nothing running on ${_serverName(host)}`); return; }
|
||||
if (!await window.styledConfirm(`Stop ${running.length} running task${running.length > 1 ? 's' : ''} on ${_serverName(host)}?`, { confirmText: 'Stop all' })) return;
|
||||
// Mark every task as user-stopped BEFORE firing the kills so that the
|
||||
@@ -1960,11 +2136,12 @@ export function _renderRunningTab() {
|
||||
|
||||
const _bdg = _taskBadge(task);
|
||||
const _bdgTitle = (task._unreachable && task.status === 'running') ? ' title="Server not responding — it may have crashed"' : '';
|
||||
const displayName = _taskDisplayName(task);
|
||||
el.innerHTML = `
|
||||
<div class="cookbook-task-header">
|
||||
<span class="cookbook-task-type${(task.status === 'done' && task.type === 'download') ? ' cookbook-task-type-done' : ''}" data-type="${esc(task.type)}">${esc((task.status === 'done' && task.type === 'download') ? 'finished' : task.type)}</span>
|
||||
<span class="cookbook-task-name">${modelLogo(task.name)}${esc(task.name)}</span>
|
||||
<span class="cookbook-task-indicator"><span class="cookbook-task-wave" style="display:${task.status === 'running' ? '' : 'none'}"></span><span class="cookbook-task-check" title="Clear" style="display:${_canClearTask(task) ? '' : 'none'}"><svg class="cookbook-task-check-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><svg class="cookbook-task-clear-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span class="cookbook-task-done-label">${esc(_clearPillLabel(task))}</span><span class="cookbook-task-clear-label">clear</span></span></span>
|
||||
<span class="cookbook-task-name">${modelLogo(task.name)}${esc(displayName)}</span>
|
||||
<span class="cookbook-task-indicator"><span class="cookbook-task-wave" style="display:${task.status === 'running' ? '' : 'none'}"></span>${_canLaunchDownloadedTask(task) ? '<button type="button" class="cookbook-task-serve-btn" title="Open in Launch"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg><span>Launch</span></button>' : ''}<span class="cookbook-task-check" title="Clear" style="display:${_canClearTask(task) ? '' : 'none'}"><svg class="cookbook-task-check-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#50fa7b" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg><svg class="cookbook-task-clear-ico" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span class="cookbook-task-done-label">${esc(_clearPillLabel(task))}</span><span class="cookbook-task-clear-label">clear</span></span></span>
|
||||
<button type="button" class="cookbook-task-start-now" title="Start this queued download now" style="display:${(task.type === 'download' && task.status === 'queued') ? '' : 'none'}"><svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><polygon points="8 5 19 12 8 19 8 5"/></svg><span>start now</span></button>
|
||||
<span class="cookbook-task-status ${_bdg.cls}"${_bdgTitle}>${esc(_bdg.text)}</span>
|
||||
<button class="cookbook-task-menu-btn" title="Actions">⋮</button>
|
||||
@@ -2036,19 +2213,11 @@ export function _renderRunningTab() {
|
||||
e.stopPropagation();
|
||||
const repo = task.payload?.repo_id || task.name;
|
||||
if (!repo) { uiModule.showToast('No model info on this task'); return; }
|
||||
// Point the active server at the one it downloaded to.
|
||||
const _tHost = task.remoteHost || '';
|
||||
_envState.remoteHost = _tHost;
|
||||
const _tSrv = _serverByVal(_envState.remoteServerKey || _tHost)
|
||||
|| _envState.servers.find(s => s.host === _tHost);
|
||||
if (_tSrv) { _envState.env = _tSrv.env || 'none'; _envState.envPath = _tSrv.envPath || ''; _envState.platform = _tSrv.platform || ''; }
|
||||
else if (!_tHost) { _envState.env = 'none'; _envState.envPath = ''; _envState.platform = ''; }
|
||||
document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => {
|
||||
if (sel && sel.tagName === 'SELECT') sel.value = _tHost || 'local';
|
||||
});
|
||||
// Point the active server at the exact profile it downloaded to.
|
||||
_selectTaskServer(task);
|
||||
try {
|
||||
const { openServePanelForRepo } = await import('./cookbookServe.js');
|
||||
await openServePanelForRepo(repo);
|
||||
await openServePanelForRepo(repo, _downloadServeFields(task));
|
||||
// Serving it supersedes the finished download — clear the card from
|
||||
// the Running tab (smooth exit) now that we've jumped to Serve.
|
||||
_animateOutThenRemove(el, task.sessionId);
|
||||
@@ -2170,9 +2339,6 @@ export function _renderRunningTab() {
|
||||
if (task.status !== 'running' && task.status !== 'queued') {
|
||||
items.push({ group: 'run', label: 'Reconnect tmux', action: 'reconnect' });
|
||||
}
|
||||
if (task.status === 'running') {
|
||||
items.push({ group: 'run', label: 'Stop', action: 'stop', danger: true });
|
||||
}
|
||||
items.push({ group: 'run', label: 'Restart', action: 'retry' });
|
||||
// ── Edit section ────────────────────────────────────────────
|
||||
// Merged "Edit & relaunch" — opens the structured serve panel
|
||||
@@ -2532,7 +2698,7 @@ export function _renderRunningTab() {
|
||||
});
|
||||
|
||||
// Route to the right server section body
|
||||
const serverBodyId = `server-body-${(task.remoteHost || 'local').replace(/[^a-zA-Z0-9-]/g, '_')}`;
|
||||
const serverBodyId = `server-body-${(_taskServerKey(task) || 'local').replace(/[^a-zA-Z0-9-]/g, '_')}`;
|
||||
const targetBody = document.getElementById(serverBodyId);
|
||||
if (targetBody) targetBody.appendChild(el);
|
||||
else group.appendChild(el);
|
||||
@@ -3386,7 +3552,8 @@ function _refreshServerDots() {
|
||||
let tasks;
|
||||
try { tasks = _loadTasks(); } catch { return; }
|
||||
const byKey = {};
|
||||
for (const t of tasks) { (byKey[t.remoteHost || ''] = byKey[t.remoteHost || ''] || []).push(t); }
|
||||
const _taskServerKeyForDot = (task) => task?.remoteServerKey || task?.remoteHost || '';
|
||||
for (const t of tasks) { (byKey[_taskServerKeyForDot(t)] = byKey[_taskServerKeyForDot(t)] || []).push(t); }
|
||||
document.querySelectorAll('.cookbook-section-header').forEach(header => {
|
||||
const dot = header.querySelector('.cookbook-srv-status');
|
||||
if (!dot) return;
|
||||
@@ -3520,7 +3687,9 @@ async function _probeEndpointUntilOnline(epId, host, port) {
|
||||
try {
|
||||
// Hit the probe endpoint — it re-probes server-side and updates
|
||||
// cached_models. We consume (and discard) the SSE stream.
|
||||
await fetch(`/api/model-endpoints/${epId}/probe`, { credentials: 'same-origin' }).then(r => r.text()).catch(() => {});
|
||||
const probeRes = await fetch(`/api/model-endpoints/${epId}/probe`, { credentials: 'same-origin' }).catch(() => null);
|
||||
if (probeRes && probeRes.status === 404) return;
|
||||
if (probeRes) await probeRes.text().catch(() => {});
|
||||
const eps = await fetch('/api/model-endpoints', { credentials: 'same-origin' }).then(r => r.json()).catch(() => []);
|
||||
const ep = (eps || []).find(e => e.id === epId);
|
||||
if (ep && (ep.models || []).length) {
|
||||
@@ -3558,7 +3727,7 @@ async function _pollBackgroundStatus() {
|
||||
}
|
||||
}
|
||||
if (added > 0) {
|
||||
localStorage.setItem(TASKS_KEY, JSON.stringify(merged.map(_stripTaskSecrets)));
|
||||
localStorage.setItem(TASKS_KEY, JSON.stringify(merged.map(_redactTaskForStorage)));
|
||||
_renderRunningTab();
|
||||
}
|
||||
}
|
||||
@@ -3791,6 +3960,7 @@ export function initRunning(shared) {
|
||||
_persistEnvState = shared._persistEnvState;
|
||||
_refreshDependencies = shared._refreshDependencies;
|
||||
_serverByVal = shared._serverByVal;
|
||||
_serverKey = shared._serverKey;
|
||||
_selectedServer = shared._selectedServer;
|
||||
modelLogo = shared.modelLogo;
|
||||
esc = shared.esc;
|
||||
@@ -3814,4 +3984,4 @@ export function initRunning(shared) {
|
||||
}
|
||||
|
||||
// Also export _retryDownload and _nextAvailablePort for use by other modules
|
||||
export { _retryDownload, _nextAvailablePort, _processQueue };
|
||||
export { _retryDownload, _nextAvailablePort, _processQueue, _taskPort };
|
||||
|
||||
+1355
-181
File diff suppressed because it is too large
Load Diff
+179
-44
@@ -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;
|
||||
@@ -24,6 +25,7 @@ import * as Modals from './modalManager.js';
|
||||
let _autoDetectDebounce = null;
|
||||
let _autoTitleDebounce = null;
|
||||
let _autoSaveDebounce = null;
|
||||
let _lastAutoSaveErrorAt = 0;
|
||||
let _animationInProgress = false;
|
||||
let _animationCancel = null; // function to cancel current animation
|
||||
let _htmlPreviewActive = false; // true when inline HTML preview iframe is showing
|
||||
@@ -154,6 +156,20 @@ import * as Modals from './modalManager.js';
|
||||
addDocToTabs,
|
||||
syncDocIndicator: _syncDocIndicator,
|
||||
});
|
||||
const sidebarNewDocBtn = document.getElementById('library-new-doc-btn');
|
||||
if (sidebarNewDocBtn && !sidebarNewDocBtn.dataset.docNewWired) {
|
||||
sidebarNewDocBtn.dataset.docNewWired = '1';
|
||||
sidebarNewDocBtn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await newDocument();
|
||||
} catch (err) {
|
||||
console.error('Failed to create document from sidebar button:', err);
|
||||
if (uiModule) uiModule.showError('Failed to create document');
|
||||
}
|
||||
});
|
||||
}
|
||||
_maybeOpenDocFromHash();
|
||||
window.addEventListener('hashchange', _maybeOpenDocFromHash);
|
||||
}
|
||||
@@ -284,8 +300,8 @@ import * as Modals from './modalManager.js';
|
||||
? langIcon(doc.language, 12, { style: 'opacity:0.65;flex-shrink:0;color:currentColor;margin-right:4px;' })
|
||||
: '';
|
||||
const langChip = `<span class="doc-tab-lang">${lic}</span>`;
|
||||
html += `<div class="doc-tab${isActive ? ' active' : ''}" draggable="true" data-doc-id="${id}" title="${title}">
|
||||
${verChip}${langChip}<span class="doc-tab-title">${shortTitle}</span>
|
||||
html += `<div class="doc-tab${isActive ? ' active' : ''}" draggable="true" data-doc-id="${id}" title="${_esc(title)}">
|
||||
${verChip}${langChip}<span class="doc-tab-title">${_esc(shortTitle)}</span>
|
||||
<button class="doc-tab-close" data-doc-id="${id}" title="Unlink from chat (kept in the Library)">×</button>
|
||||
</div>`;
|
||||
}
|
||||
@@ -651,7 +667,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>
|
||||
@@ -2686,6 +2702,104 @@ import * as Modals from './modalManager.js';
|
||||
await _uploadComposeFiles(files);
|
||||
}
|
||||
|
||||
function _isMarkdownImageFile(file) {
|
||||
if (!file) return false;
|
||||
if ((file.type || '').toLowerCase().startsWith('image/')) return true;
|
||||
return /\.(avif|bmp|gif|jpe?g|png|svg|webp)$/i.test(file.name || '');
|
||||
}
|
||||
|
||||
function _markdownImageAlt(name) {
|
||||
const base = String(name || 'image').replace(/\.[^.]+$/, '').trim() || 'image';
|
||||
return base.replace(/[\[\]\n\r]/g, ' ').replace(/\s+/g, ' ').trim() || 'image';
|
||||
}
|
||||
|
||||
function _activeDocLanguage() {
|
||||
const doc = activeDocId && docs.get(activeDocId);
|
||||
return ((doc && doc.language) || document.getElementById('doc-language-select')?.value || '').toLowerCase();
|
||||
}
|
||||
|
||||
function _scheduleMarkdownImageAutosave(ta) {
|
||||
updateLineNumbers(ta.value);
|
||||
const codeEl = document.getElementById('doc-editor-code');
|
||||
if (codeEl && !codeEl.dataset.hasDiff) {
|
||||
codeEl.textContent = ta.value + '\n';
|
||||
codeEl.style.minHeight = ta.scrollHeight + 'px';
|
||||
}
|
||||
clearTimeout(_hlDebounce);
|
||||
_hlDebounce = setTimeout(syncHighlighting, 80);
|
||||
clearTimeout(_autoTitleDebounce);
|
||||
_autoTitleDebounce = setTimeout(() => autoTitleFromContent(ta.value), 600);
|
||||
clearTimeout(_autoSaveDebounce);
|
||||
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
|
||||
}
|
||||
|
||||
function _insertMarkdownImages(uploadedFiles) {
|
||||
const ta = document.getElementById('doc-editor-textarea');
|
||||
if (!ta) return;
|
||||
const files = Array.isArray(uploadedFiles) ? uploadedFiles : [];
|
||||
if (!files.length) return;
|
||||
|
||||
const start = ta.selectionStart || 0;
|
||||
const end = ta.selectionEnd || start;
|
||||
const before = ta.value.slice(0, start);
|
||||
const after = ta.value.slice(end);
|
||||
const lines = files.map(file => {
|
||||
const id = encodeURIComponent(file.id || file.file_id || '');
|
||||
const alt = _markdownImageAlt(file.name || file.filename);
|
||||
return id ? `` : '';
|
||||
}).filter(Boolean);
|
||||
if (!lines.length) return;
|
||||
|
||||
const prefix = before && !before.endsWith('\n') ? '\n' : '';
|
||||
const suffix = after && !after.startsWith('\n') ? '\n' : '';
|
||||
const insert = `${prefix}${lines.join('\n\n')}${suffix}`;
|
||||
_replaceRange(ta, start, end, insert);
|
||||
const caret = start + insert.length;
|
||||
ta.selectionStart = caret;
|
||||
ta.selectionEnd = caret;
|
||||
ta.focus();
|
||||
_scheduleMarkdownImageAutosave(ta);
|
||||
_refreshMarkdownPreviewIfVisible(activeDocId, ta.value);
|
||||
}
|
||||
|
||||
async function _uploadMarkdownImages(files) {
|
||||
const images = Array.from(files || []).filter(_isMarkdownImageFile);
|
||||
if (!images.length) {
|
||||
if (uiModule) uiModule.showError('Choose an image file');
|
||||
return;
|
||||
}
|
||||
if (_activeDocLanguage() !== 'markdown') {
|
||||
if (uiModule) uiModule.showError('Switch the document to markdown before inserting images');
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
images.forEach(file => fd.append('files', file));
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/upload`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
body: fd,
|
||||
});
|
||||
let data = null;
|
||||
try { data = await res.json(); } catch (_) {}
|
||||
if (!res.ok) throw new Error((data && (data.error || data.detail)) || `HTTP ${res.status}`);
|
||||
const uploaded = Array.isArray(data?.files) ? data.files : [];
|
||||
if (!uploaded.length) throw new Error('No uploaded files returned');
|
||||
_insertMarkdownImages(uploaded);
|
||||
if (uiModule) uiModule.showToast(images.length === 1 ? 'Image inserted' : 'Images inserted');
|
||||
} catch (err) {
|
||||
console.error('Failed to insert markdown image:', err);
|
||||
if (uiModule) uiModule.showError('Failed to insert image');
|
||||
}
|
||||
}
|
||||
|
||||
async function _handleMarkdownImageUpload(e) {
|
||||
const files = e.target.files;
|
||||
e.target.value = '';
|
||||
await _uploadMarkdownImages(files);
|
||||
}
|
||||
|
||||
function _renderComposeAttachments() {
|
||||
const container = document.getElementById('doc-email-compose-atts');
|
||||
if (!container) return;
|
||||
@@ -3218,7 +3332,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;
|
||||
}
|
||||
}
|
||||
@@ -3269,6 +3386,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;
|
||||
@@ -3276,26 +3401,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 = {}) {
|
||||
@@ -3752,9 +3860,12 @@ import * as Modals from './modalManager.js';
|
||||
const res = await fetch(`${API_BASE}/api/document`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ session_id: sessionId, title: '', content }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Document create failed: HTTP ${res.status}`);
|
||||
const doc = await res.json();
|
||||
if (!doc || !doc.id) throw new Error('Document create failed: missing id');
|
||||
addDocToTabs(doc, sessionId);
|
||||
// Set the content into the map so switchToDoc preserves it
|
||||
const d = docs.get(doc.id);
|
||||
@@ -3981,6 +4092,7 @@ import * as Modals from './modalManager.js';
|
||||
<input type="hidden" id="doc-email-source-folder" />
|
||||
<input type="file" id="doc-email-file-input" multiple style="display:none" />
|
||||
</div>
|
||||
<input type="file" id="doc-md-image-input" accept="image/*" multiple style="display:none" />
|
||||
<div class="doc-md-toolbar" id="doc-md-toolbar" style="display:none">
|
||||
<div class="md-toolbar-items" id="md-toolbar-items">
|
||||
<span class="md-view-toggle" id="doc-md-view-toggle" style="display:none" role="group" aria-label="Edit or preview">
|
||||
@@ -4003,7 +4115,7 @@ import * as Modals from './modalManager.js';
|
||||
<button type="button" class="md-dd-toggle" data-dd="list" title="List"><span style="font-variant-numeric:tabular-nums;">1.</span><svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||
<span class="md-toolbar-sep"></span>
|
||||
<button type="button" data-md="link" title="Link"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>
|
||||
<button type="button" id="md-toolbar-attach-btn" class="md-toolbar-attach-btn" title="Attach files"><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.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></button>
|
||||
<button type="button" id="md-toolbar-attach-btn" class="md-toolbar-attach-btn" title="Insert image"><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.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg></button>
|
||||
<button type="button" class="md-dd-toggle md-toolbar-email-hide" data-dd="code" title="Code">\`<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||
<button type="button" data-md="hr" title="Horizontal rule">—</button>
|
||||
<span class="md-toolbar-sep"></span>
|
||||
@@ -4602,9 +4714,14 @@ import * as Modals from './modalManager.js';
|
||||
document.getElementById('doc-email-file-input')?.click();
|
||||
});
|
||||
document.getElementById('md-toolbar-attach-btn')?.addEventListener('click', () => {
|
||||
document.getElementById('doc-email-file-input')?.click();
|
||||
if (_activeDocLanguage() === 'email') {
|
||||
document.getElementById('doc-email-file-input')?.click();
|
||||
} else {
|
||||
document.getElementById('doc-md-image-input')?.click();
|
||||
}
|
||||
});
|
||||
document.getElementById('doc-email-file-input')?.addEventListener('change', _handleAttachUpload);
|
||||
document.getElementById('doc-md-image-input')?.addEventListener('change', _handleMarkdownImageUpload);
|
||||
|
||||
// Cc/Bcc toggle
|
||||
document.getElementById('doc-email-show-cc')?.addEventListener('click', () => {
|
||||
@@ -4840,6 +4957,26 @@ import * as Modals from './modalManager.js';
|
||||
clearTimeout(_autoSaveDebounce);
|
||||
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 2000);
|
||||
});
|
||||
ta.addEventListener('paste', (e) => {
|
||||
if (_activeDocLanguage() !== 'markdown') return;
|
||||
const files = Array.from(e.clipboardData?.files || []).filter(_isMarkdownImageFile);
|
||||
if (!files.length) return;
|
||||
e.preventDefault();
|
||||
_uploadMarkdownImages(files);
|
||||
});
|
||||
ta.addEventListener('dragover', (e) => {
|
||||
if (_activeDocLanguage() !== 'markdown') return;
|
||||
const items = Array.from(e.dataTransfer?.items || []);
|
||||
if (!items.some(item => item.kind === 'file' && /^image\//i.test(item.type || ''))) return;
|
||||
e.preventDefault();
|
||||
});
|
||||
ta.addEventListener('drop', (e) => {
|
||||
if (_activeDocLanguage() !== 'markdown') return;
|
||||
const files = Array.from(e.dataTransfer?.files || []).filter(_isMarkdownImageFile);
|
||||
if (!files.length) return;
|
||||
e.preventDefault();
|
||||
_uploadMarkdownImages(files);
|
||||
});
|
||||
ta.addEventListener('scroll', () => {
|
||||
const code = document.getElementById('doc-editor-code');
|
||||
if (code) code.style.minHeight = ta.scrollHeight + 'px';
|
||||
@@ -5548,7 +5685,7 @@ import * as Modals from './modalManager.js';
|
||||
// any dropdown that just opened. Preventing the default mousedown keeps the
|
||||
// textarea focused, so formatting hits the live selection and menus stay up.
|
||||
toolbar.addEventListener('mousedown', (e) => {
|
||||
if (e.target.closest('[data-md], .md-dd-toggle, .emoji-picker-btn')) e.preventDefault();
|
||||
if (e.target.closest('[data-md], .md-dd-toggle, .emoji-picker-btn, .md-toolbar-attach-btn')) e.preventDefault();
|
||||
});
|
||||
|
||||
toolbar.addEventListener('click', (e) => {
|
||||
@@ -5976,6 +6113,7 @@ import * as Modals from './modalManager.js';
|
||||
const res = await fetch(`${API_BASE}/api/document`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
title: '',
|
||||
@@ -5983,7 +6121,9 @@ import * as Modals from './modalManager.js';
|
||||
language: 'markdown',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Document create failed: HTTP ${res.status}`);
|
||||
const doc = await res.json();
|
||||
if (!doc || !doc.id) throw new Error('Document create failed: missing id');
|
||||
addDocToTabs(doc, sessionId);
|
||||
if (!isOpen) openPanel();
|
||||
// Re-enable editor if it was in empty state
|
||||
@@ -8266,8 +8406,10 @@ import * as Modals from './modalManager.js';
|
||||
const res = await fetch(`${API_BASE}/api/document/${activeDocId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ content: textarea.value }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Document save failed: HTTP ${res.status}`);
|
||||
const doc = await res.json();
|
||||
const badge = document.getElementById('doc-version-badge');
|
||||
if (badge) { const _v = doc.version_count || 1; badge.textContent = `v${_v}`; badge.style.display = _v > 1 ? '' : 'none'; }
|
||||
@@ -8280,7 +8422,11 @@ import * as Modals from './modalManager.js';
|
||||
if (!silent && uiModule) uiModule.showToast('Document saved');
|
||||
} catch (e) {
|
||||
console.error('Failed to save document:', e);
|
||||
if (!silent && uiModule) uiModule.showError('Failed to save document');
|
||||
const now = Date.now();
|
||||
if (uiModule && (!silent || now - _lastAutoSaveErrorAt > 10000)) {
|
||||
uiModule.showError(silent ? 'Autosave failed' : 'Failed to save document');
|
||||
_lastAutoSaveErrorAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8440,9 +8586,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
|
||||
@@ -8492,7 +8639,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');
|
||||
@@ -8510,21 +8657,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
|
||||
|
||||
@@ -12,8 +12,8 @@ export function canvasCoords(e, canvas) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||||
const clientX = e.touches && e.touches.length ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.touches && e.touches.length ? e.touches[0].clientY : e.clientY;
|
||||
return {
|
||||
x: (clientX - rect.left) * scaleX,
|
||||
y: (clientY - rect.top) * scaleY,
|
||||
|
||||
+6
-11
@@ -9,6 +9,7 @@ import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibO
|
||||
import * as Modals from './modalManager.js';
|
||||
import { applyEdgeDock } from './modalSnap.js';
|
||||
import { buildReplyAllCc } from './emailLibrary/replyRecipients.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
const _acct = () => window.__odysseusActiveEmailAccount
|
||||
@@ -915,7 +916,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';
|
||||
@@ -938,7 +939,7 @@ function _showEmailMenu(em, anchor, itemEl) {
|
||||
_showRemindSubmenu(em, dropdown);
|
||||
return;
|
||||
}
|
||||
dropdown.remove();
|
||||
close();
|
||||
a.action();
|
||||
});
|
||||
dropdown.appendChild(menuItem);
|
||||
@@ -946,13 +947,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) ----
|
||||
@@ -987,7 +982,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);
|
||||
@@ -997,7 +992,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);
|
||||
|
||||
+85
-62
@@ -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,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
} from './emailLibrary/signatureFold.js';
|
||||
import { state } from './emailLibrary/state.js';
|
||||
import { collapseSidebarToRail } from './modalSnap.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
let _emailUnreadChipClickWired = false;
|
||||
@@ -858,7 +860,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;">
|
||||
@@ -2936,6 +2938,20 @@ function _createCard(em) {
|
||||
titleRow.appendChild(att);
|
||||
}
|
||||
|
||||
const tags = Array.isArray(em.tags) ? em.tags : [];
|
||||
if (tags.length || em.is_spam_verdict) {
|
||||
const tagWrap = document.createElement('span');
|
||||
tagWrap.className = 'email-tags email-card-tags';
|
||||
tagWrap.innerHTML = tags.map(t => {
|
||||
const tag = String(t || '').trim().toLowerCase().replace(/_/g, '-');
|
||||
return tag ? `<span class="email-tag email-tag-${_esc(tag)}">${_esc(tag)}</span>` : '';
|
||||
}).join('');
|
||||
if (em.is_spam_verdict) {
|
||||
tagWrap.insertAdjacentHTML('beforeend', '<span class="email-tag email-tag-spam">spam</span>');
|
||||
}
|
||||
titleRow.appendChild(tagWrap);
|
||||
}
|
||||
|
||||
// Done check + unread dot stay next to the subject on the left.
|
||||
const isSentFolder = /sent/i.test(state._libFolder);
|
||||
if (!isSentFolder) {
|
||||
@@ -4560,11 +4576,12 @@ function _wireAttachmentHandlers(reader, folder) {
|
||||
const uid = openBtn.dataset.openUid;
|
||||
const index = openBtn.dataset.openIndex;
|
||||
const name = openBtn.dataset.openName || `attachment-${index}`;
|
||||
const sourceFolder = openBtn.dataset.openFolder || useFolder;
|
||||
if (!uid || index == null) return;
|
||||
const orig = openBtn.style.opacity;
|
||||
openBtn.style.opacity = '0.4';
|
||||
try {
|
||||
const folderQs = encodeURIComponent(useFolder);
|
||||
const folderQs = encodeURIComponent(sourceFolder);
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/email/attachment-as-doc/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${folderQs}${_acct()}`,
|
||||
{ method: 'POST', credentials: 'same-origin' }
|
||||
@@ -4618,8 +4635,9 @@ function _wireAttachmentHandlers(reader, folder) {
|
||||
const uid = chip.dataset.attUid;
|
||||
const index = chip.dataset.attIndex;
|
||||
const name = chip.dataset.attName || `attachment-${index}`;
|
||||
const sourceFolder = chip.dataset.attFolder || useFolder;
|
||||
if (!uid || index == null) return;
|
||||
const url = `${API_BASE}/api/email/attachment/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${encodeURIComponent(useFolder)}${_acct()}`;
|
||||
const url = `${API_BASE}/api/email/attachment/${encodeURIComponent(uid)}/${encodeURIComponent(index)}?folder=${encodeURIComponent(sourceFolder)}${_acct()}`;
|
||||
if (_isMobileUA) {
|
||||
window.open(url, '_blank');
|
||||
return;
|
||||
@@ -4698,25 +4716,50 @@ function _isLikelySignatureImage(a) {
|
||||
// Build the attachments header+chips HTML for an email read response. Pulled
|
||||
// out so both the initial-open and the swap-reader paths can render it.
|
||||
function _buildAttsHtmlFor(uid, data) {
|
||||
if (!data || !data.attachments || !data.attachments.length) return '';
|
||||
const _OPENABLE_RE = /\.(pdf|docx|txt|md|markdown)$/i;
|
||||
const visible = data.attachments.filter(a => !_isLikelySignatureImage(a));
|
||||
if (!visible.length) return '';
|
||||
const chips = visible.map(a => {
|
||||
if (!data) return '';
|
||||
const _OPENABLE_RE = /\.(pdf|docx|txt|md|markdown|eml)$/i;
|
||||
const currentAttachments = Array.isArray(data.attachments) ? data.attachments : [];
|
||||
const relatedAttachments = Array.isArray(data.related_attachments) ? data.related_attachments : [];
|
||||
if (!currentAttachments.length && !relatedAttachments.length) return '';
|
||||
const visible = currentAttachments.filter(a => !_isLikelySignatureImage(a));
|
||||
const hidden = currentAttachments.filter(a => _isLikelySignatureImage(a));
|
||||
const related = relatedAttachments.filter(a => !_isLikelySignatureImage(a));
|
||||
const renderChip = (a, extraClass = '') => {
|
||||
const openable = _OPENABLE_RE.test(a.filename || '');
|
||||
const chipUid = a.source_uid || a.uid || uid;
|
||||
const chipFolder = a.source_folder || data.folder || state._libFolder || 'INBOX';
|
||||
const openBtn = openable
|
||||
? `<span class="email-attachment-open" title="Open in document editor" data-open-uid="${_esc(uid)}" data-open-index="${a.index}" data-open-name="${_esc(a.filename)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/></svg><span class="email-attachment-open-label">Open</span></span>`
|
||||
? `<span class="email-attachment-open" title="Open in document editor" data-open-uid="${_esc(chipUid)}" data-open-index="${a.index}" data-open-name="${_esc(a.filename)}" data-open-folder="${_esc(chipFolder)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/></svg><span class="email-attachment-open-label">Open</span></span>`
|
||||
: '';
|
||||
return `<button type="button" class="email-attachment-chip" data-att-uid="${_esc(uid)}" data-att-index="${a.index}" data-att-name="${_esc(a.filename)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>${_esc(a.filename)}</span><span class="att-size">${Math.round((a.size||0)/1024)} KB</span>${openBtn}</button>`;
|
||||
}).join('');
|
||||
return `<button type="button" class="email-attachment-chip${extraClass}" data-att-uid="${_esc(chipUid)}" data-att-index="${a.index}" data-att-name="${_esc(a.filename)}" data-att-folder="${_esc(chipFolder)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg><span>${_esc(a.filename)}</span><span class="att-size">${Math.round((a.size||0)/1024)} KB</span>${openBtn}</button>`;
|
||||
};
|
||||
const chips = visible.map(a => renderChip(a)).join('');
|
||||
const hiddenChips = hidden.map(a => renderChip(a, ' email-attachment-chip-muted')).join('');
|
||||
const relatedChips = related.map(a => renderChip(a, ' email-attachment-chip-related')).join('');
|
||||
const visibleSection = visible.length
|
||||
? '<div class="email-reader-atts">' + chips + '</div>'
|
||||
: '';
|
||||
const relatedSection = related.length
|
||||
? '<div class="email-reader-atts-hidden-note">From earlier in this thread</div><div class="email-reader-atts email-reader-atts-related">' + relatedChips + '</div>'
|
||||
: '';
|
||||
const hiddenSection = hidden.length
|
||||
? '<div class="email-reader-atts-hidden-note">Filtered inline images / signature files</div><div class="email-reader-atts email-reader-atts-hidden">' + hiddenChips + '</div>'
|
||||
: '';
|
||||
const label = visible.length
|
||||
? `Attachments (${visible.length + related.length})`
|
||||
: related.length
|
||||
? `Thread attachments (${related.length})`
|
||||
: `Hidden inline attachments (${hidden.length})`;
|
||||
return (
|
||||
'<div class="email-reader-atts-wrap collapsed">'
|
||||
+ '<div class="email-reader-atts-header email-summary-toggle" role="button" tabindex="0">'
|
||||
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 17.93 8.8l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>'
|
||||
+ `<span>Attachments (${data.attachments.length})</span>`
|
||||
+ `<span>${label}</span>`
|
||||
+ '<svg class="email-summary-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="margin-left:auto;transition:transform .15s ease;"><polyline points="6 9 12 15 18 9"/></svg>'
|
||||
+ '</div>'
|
||||
+ '<div class="email-reader-atts">' + chips + '</div>'
|
||||
+ visibleSection
|
||||
+ relatedSection
|
||||
+ hiddenSection
|
||||
+ '</div>'
|
||||
);
|
||||
}
|
||||
@@ -4825,7 +4868,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>
|
||||
@@ -5060,7 +5103,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>
|
||||
@@ -5458,23 +5501,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>';
|
||||
@@ -5680,8 +5719,7 @@ function _showReaderMoreMenu(em, card, reader, anchor) {
|
||||
_showLibRemindSubmenu(em, dropdown);
|
||||
return;
|
||||
}
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
close();
|
||||
a.action();
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
@@ -5694,30 +5732,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) && ev.target !== anchor) {
|
||||
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>';
|
||||
@@ -5877,8 +5910,7 @@ function _showCardMenu(em, anchor) {
|
||||
_showLibRemindSubmenu(em, dropdown);
|
||||
return;
|
||||
}
|
||||
dropdown.remove();
|
||||
anchor.classList.remove('reader-more-active');
|
||||
close();
|
||||
a.action();
|
||||
});
|
||||
dropdown.appendChild(item);
|
||||
@@ -5891,30 +5923,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) && ev.target !== anchor) {
|
||||
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>';
|
||||
@@ -5927,7 +5954,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.
|
||||
@@ -5937,7 +5964,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;
|
||||
@@ -5948,13 +5975,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() {
|
||||
@@ -6199,7 +6222,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;
|
||||
@@ -2514,7 +2516,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');
|
||||
@@ -2523,7 +2525,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>';
|
||||
@@ -2548,17 +2550,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) => {
|
||||
@@ -2567,7 +2563,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);
|
||||
});
|
||||
|
||||
+44
-9
@@ -36,6 +36,14 @@ function linkHtml(text, url) {
|
||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer">${safeText}</a>`;
|
||||
}
|
||||
|
||||
function imageHtml(alt, url, title) {
|
||||
const safeUrl = safeLinkUrl(url);
|
||||
if (!safeUrl || safeUrl.startsWith('#')) return escapeHtml(alt || '');
|
||||
const safeAlt = escapeHtml(alt || '');
|
||||
const safeTitle = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
return `<img src="${escapeHtml(safeUrl)}" alt="${safeAlt}"${safeTitle} loading="lazy" decoding="async">`;
|
||||
}
|
||||
|
||||
function _isModelEndpointUrl(rawUrl) {
|
||||
try {
|
||||
const parsed = new URL(String(rawUrl || ''), window.location.origin);
|
||||
@@ -146,7 +154,7 @@ function sanitizeAllowedHtml(html) {
|
||||
* Check if text has unclosed think tag
|
||||
*/
|
||||
export function hasUnclosedThinkTag(text) {
|
||||
text = text || '';
|
||||
text = normalizeThinkingMarkup(text || '');
|
||||
const openCount =
|
||||
(text.match(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi) || []).length
|
||||
+ (text.match(/<\|channel>thought/gi) || []).length;
|
||||
@@ -163,6 +171,10 @@ export function startsWithReasoningPrefix(text) {
|
||||
export function normalizeThinkingMarkup(text) {
|
||||
if (!text) return text;
|
||||
let normalized = text;
|
||||
// MiniMax M-series can emit namespaced reasoning tags like
|
||||
// <mm:think>...</mm:think>. Normalize them into the shared thinking parser.
|
||||
normalized = normalized.replace(/<mm:think(\s+[^>]*)?>/gi, (_m, attrs = '') => `<think${attrs || ''}>`);
|
||||
normalized = normalized.replace(/<\/mm:think>/gi, '</think>');
|
||||
normalized = normalized.replace(/<thought(\s+[^>]*)?>/gi, (_m, attrs = '') => `<think${attrs || ''}>`);
|
||||
normalized = normalized.replace(/<\/thought>/gi, '</think>');
|
||||
normalized = normalized.replace(/<\|channel>thought\s*\n?([\s\S]*?)<channel\|>\s*/gi, (_m, content = '') => {
|
||||
@@ -471,6 +483,7 @@ export function processWithThinking(text) {
|
||||
export function mdToHtml(src, opts) {
|
||||
const allowedHtmlBlocks = [];
|
||||
const codeBlocks = [];
|
||||
const inlineCodeBlocks = [];
|
||||
const mermaidBlocks = [];
|
||||
let s = (src ?? '');
|
||||
|
||||
@@ -509,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.
|
||||
@@ -535,6 +561,12 @@ export function mdToHtml(src, opts) {
|
||||
'$1[#$2](#$2)',
|
||||
);
|
||||
|
||||
// Convert markdown images before links so  does not become
|
||||
// literal "!" plus a normal link.
|
||||
s = s.replace(/!\[([^\]\n]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (match, alt, url, title) => {
|
||||
return imageHtml(alt, url, title);
|
||||
});
|
||||
|
||||
// Convert markdown links [text](url) to clickable links
|
||||
// Internal #hash links navigate in-page; external links open in new tab
|
||||
s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
|
||||
@@ -573,8 +605,9 @@ export function mdToHtml(src, opts) {
|
||||
return placeholder;
|
||||
});
|
||||
|
||||
// ALSO preserve <a> tags the same way (they're now in the HTML from markdown conversion)
|
||||
s = s.replace(/<a\s+[^>]*>.*?<\/a>/gi, (match) => {
|
||||
// ALSO preserve <a>/<img> tags the same way (they're now in the HTML from
|
||||
// markdown conversion)
|
||||
s = s.replace(/<(?:a\s+[^>]*>.*?<\/a|img\s+[^>]*?)>/gi, (match) => {
|
||||
const placeholder = `___ALLOWED_HTML_${allowedHtmlBlocks.length}___`;
|
||||
allowedHtmlBlocks.push(sanitizeAllowedHtml(match));
|
||||
return placeholder;
|
||||
@@ -659,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>');
|
||||
|
||||
@@ -737,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
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
import { previewZoneAt, clearPreview, snapModalToZone } from './tileManager.js';
|
||||
import { suspendDock, resumeDock, clearRightDock, applyEdgeDock } from './modalSnap.js';
|
||||
import { dismissOrRemove } from './escMenuStack.js';
|
||||
import { nextToolWindowZ } from './toolWindowZOrder.js';
|
||||
|
||||
const _state = new Map(); // id -> { restoreFn, closeFn, railBtnId, isMinimized, restoreMinHeight }
|
||||
|
||||
@@ -63,7 +64,14 @@ function _applyRememberedDock(id) {
|
||||
// those statics and bump on every bring-to-front.
|
||||
let _modalTopZ = 300;
|
||||
function _bringToFront(modal) {
|
||||
if (modal) modal.style.setProperty('z-index', String(++_modalTopZ), 'important');
|
||||
if (!modal) return;
|
||||
const z = nextToolWindowZ({
|
||||
exclude: modal,
|
||||
current: getComputedStyle(modal).zIndex,
|
||||
floor: _modalTopZ,
|
||||
});
|
||||
_modalTopZ = Math.max(_modalTopZ, z);
|
||||
modal.style.setProperty('z-index', String(z), 'important');
|
||||
}
|
||||
|
||||
function _emitModalOpened(id, modal) {
|
||||
|
||||
+60
-19
@@ -77,6 +77,7 @@ function _handlePickerKeydown(e, listEl, itemSelector, closeFn) {
|
||||
// Dependencies injected via initModelPicker()
|
||||
let _deps = null;
|
||||
let _autoSelectingDefault = false;
|
||||
let _defaultChatPickInFlight = false;
|
||||
|
||||
function _modelExists(modelId, url) {
|
||||
if (!modelId || !window.modelsModule || !window.modelsModule.getCachedItems) return false;
|
||||
@@ -91,6 +92,43 @@ function _modelExists(modelId, url) {
|
||||
});
|
||||
}
|
||||
|
||||
async function _ensureDefaultPendingChat() {
|
||||
if (!_deps || _defaultChatPickInFlight) return;
|
||||
if (_deps.getCurrentSessionId && _deps.getCurrentSessionId()) return;
|
||||
const pending = _deps.getPendingChat && _deps.getPendingChat();
|
||||
if (pending && pending.modelId) return;
|
||||
_defaultChatPickInFlight = true;
|
||||
try {
|
||||
let dc = null;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/default-chat`, { credentials: 'same-origin' });
|
||||
if (res.ok) dc = await res.json();
|
||||
} catch (_) {}
|
||||
if (dc && dc.endpoint_url && dc.model) {
|
||||
_deps.setPendingChat({
|
||||
url: dc.endpoint_url,
|
||||
modelId: dc.model,
|
||||
endpointId: dc.endpoint_id || '',
|
||||
});
|
||||
try { window.__odysseusDefaultChat = dc; } catch (_) {}
|
||||
updateModelPicker();
|
||||
return;
|
||||
}
|
||||
// No configured default: preserve the old convenience fallback.
|
||||
if (window.modelsModule && window.modelsModule.getCachedItems) {
|
||||
const items = window.modelsModule.getCachedItems();
|
||||
const first = items.find(item => !item.offline && ((item.models || []).length || (item.models_extra || []).length));
|
||||
if (first) {
|
||||
const models = (first.models || []).concat(first.models_extra || []);
|
||||
_deps.setPendingChat({ url: first.url, modelId: models[0], endpointId: first.endpoint_id });
|
||||
updateModelPicker();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_defaultChatPickInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the model picker dropdown.
|
||||
* @param {Object} deps
|
||||
@@ -112,6 +150,7 @@ function _initModelPickerDropdown() {
|
||||
const search = document.getElementById('model-picker-search');
|
||||
const listEl = document.getElementById('model-picker-list');
|
||||
const searchRow = menu ? menu.querySelector('.model-picker-search-row') : null;
|
||||
const refreshBtn = document.getElementById('model-picker-refresh-btn');
|
||||
if (!wrap || !btn || !menu || !search || !listEl) return;
|
||||
|
||||
function _close() {
|
||||
@@ -608,6 +647,26 @@ function _initModelPickerDropdown() {
|
||||
|
||||
search.addEventListener('input', () => _populate(search.value));
|
||||
search.addEventListener('click', (e) => e.stopPropagation());
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
refreshBtn.disabled = true;
|
||||
refreshBtn.classList.add('spinning');
|
||||
try {
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
await window.modelsModule.refreshModels(true);
|
||||
}
|
||||
await _refreshLocalProbe();
|
||||
if (!menu.classList.contains('hidden')) _populate(search.value || '');
|
||||
updateModelPicker();
|
||||
} catch (_) {
|
||||
uiModule.showToast('Model refresh failed');
|
||||
} finally {
|
||||
refreshBtn.disabled = false;
|
||||
refreshBtn.classList.remove('spinning');
|
||||
}
|
||||
});
|
||||
}
|
||||
search.addEventListener('keydown', (e) => {
|
||||
_handlePickerKeydown(e, listEl, '.model-switch-item', _close);
|
||||
});
|
||||
@@ -689,25 +748,7 @@ export function updateModelPicker() {
|
||||
}
|
||||
}
|
||||
if (!modelId && !_autoSelectingDefault && window.modelsModule && window.modelsModule.getCachedItems) {
|
||||
const items = window.modelsModule.getCachedItems();
|
||||
const first = items.find(item => !item.offline && ((item.models || []).length || (item.models_extra || []).length));
|
||||
if (first) {
|
||||
const models = (first.models || []).concat(first.models_extra || []);
|
||||
modelId = models[0];
|
||||
if (!currentSessionId) {
|
||||
_deps.setPendingChat({ url: first.url, modelId, endpointId: first.endpoint_id });
|
||||
} else {
|
||||
if (s) { s.model = modelId; s.endpoint_url = first.url; }
|
||||
_autoSelectingDefault = true;
|
||||
const fd = new FormData();
|
||||
fd.append('model', modelId);
|
||||
fd.append('endpoint_url', first.url || '');
|
||||
if (first.endpoint_id) fd.append('endpoint_id', first.endpoint_id);
|
||||
fetch(`${API_BASE}/api/session/${currentSessionId}`, { method: 'PATCH', body: fd })
|
||||
.catch(() => {})
|
||||
.finally(() => { _autoSelectingDefault = false; });
|
||||
}
|
||||
}
|
||||
_ensureDefaultPendingChat();
|
||||
}
|
||||
|
||||
const displayName = modelId ? modelId.split('/').pop() : 'Select model';
|
||||
|
||||
+184
-31
@@ -10,6 +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, topPortalZ } from './toolWindowZOrder.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
let _open = false;
|
||||
@@ -200,6 +202,23 @@ function _restoreNotesSidebarDock(pane) {
|
||||
applyEdgeDock(pane, 'right');
|
||||
}
|
||||
|
||||
// Notes is not a `.modal`; its backdrop is the top-level stacking surface.
|
||||
function _topToolWindowZ(exclude = null) {
|
||||
return topToolWindowZ({ exclude });
|
||||
}
|
||||
|
||||
function _bringNotesToFront(pane = document.getElementById('notes-pane')) {
|
||||
if (!pane) return;
|
||||
const backdrop = document.getElementById('notes-pane-backdrop') || pane.parentElement;
|
||||
const z = _topToolWindowZ(backdrop) + 1;
|
||||
if (backdrop) backdrop.style.setProperty('z-index', String(z), 'important');
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('odysseus:modal-opened', {
|
||||
detail: { id: 'notes-panel', modal: pane },
|
||||
}));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function _loadPendingHighlights() {
|
||||
try { return new Set(JSON.parse(localStorage.getItem(REMINDER_PENDING_HIGHLIGHT_KEY) || '[]')); }
|
||||
catch { return new Set(); }
|
||||
@@ -590,7 +609,7 @@ function _isNoteFullyDone(note) {
|
||||
// A "checklist note" — todo or goal — has structured items[] that the cards
|
||||
// render as checkboxes and that "fully done" / progress logic reads from.
|
||||
function _hasItems(note) {
|
||||
return note && (note.note_type === 'todo' || note.note_type === 'goal');
|
||||
return note && (note.note_type === 'todo' || note.note_type === 'goal' || note.note_type === 'checklist');
|
||||
}
|
||||
|
||||
// Compact " N/M" progress string for a goal's checklist. Empty when the goal
|
||||
@@ -1096,11 +1115,12 @@ export async function refreshDueBadge(opts = {}) {
|
||||
// ---- Panel ----
|
||||
|
||||
export function openPanel() {
|
||||
if (_open) return;
|
||||
if (_open) {
|
||||
_bringNotesToFront();
|
||||
return;
|
||||
}
|
||||
_open = true;
|
||||
_editingId = null;
|
||||
// Reset the search filter — the rebuilt pane's search input renders empty, so a
|
||||
// stale _searchQuery would silently hide non-matching notes after a reopen.
|
||||
_searchQuery = '';
|
||||
_clearViewedReminderGlows();
|
||||
_firedDotDismissedAt = Date.now();
|
||||
@@ -1192,6 +1212,7 @@ export function openPanel() {
|
||||
document.body.appendChild(backdrop);
|
||||
_wireNotesWindow(pane);
|
||||
_restoreNotesSidebarDock(pane);
|
||||
_bringNotesToFront(pane);
|
||||
|
||||
// Events
|
||||
// (Close chevron removed — swipe down on mobile, tool-rail toggle on desktop.)
|
||||
@@ -1202,6 +1223,9 @@ export function openPanel() {
|
||||
_wireNotesSwipeDismiss(pane.querySelector('.notes-mobile-grabber'), pane);
|
||||
_wireNotesSwipeDismiss(pane.querySelector('.notes-pane-header'), pane);
|
||||
|
||||
pane.addEventListener('pointerdown', () => _bringNotesToFront(pane), true);
|
||||
pane.addEventListener('focusin', () => _bringNotesToFront(pane), true);
|
||||
|
||||
const minBtn = document.getElementById('notes-minimize-btn');
|
||||
if (minBtn) minBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
@@ -1797,10 +1821,20 @@ function _renderNotes() {
|
||||
for (let i = 0; i < note.items.length; i++) {
|
||||
const item = note.items[i];
|
||||
const doneClass = item.done ? ' done' : '';
|
||||
const agentStatus = (item.agent_status || '').toLowerCase();
|
||||
const agentDoneClass = agentStatus === 'stream_complete' ? ' is-agent-stream-complete' : '';
|
||||
const agentTitle = agentStatus === 'stream_complete'
|
||||
? 'Agent stream finished for this todo'
|
||||
: (agentStatus === 'running' ? 'Agent is working on this todo' : 'Solve this todo with the agent');
|
||||
const agentSessionAttr = item.agent_session_id ? ` data-session-id="${_attrEsc(item.agent_session_id)}"` : '';
|
||||
const agentMenuTitle = item.agent_session_title || `Agent: ${(item.text || '').slice(0, 40)}`;
|
||||
const indent = Math.min(item.indent || 0, 3);
|
||||
contentHtml += `<div class="note-checkbox${doneClass}" data-note-id="${note.id}" data-idx="${i}" style="padding-left:${indent * 16}px">
|
||||
<span class="note-check-dot" title="Mark done"></span>
|
||||
<span class="note-check-text">${_linkify(item.text)}</span>
|
||||
<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-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>
|
||||
@@ -1864,10 +1898,6 @@ function _renderNotes() {
|
||||
${_hasItems(note) ? `<div class="note-cl-quickadd"><input type="text" class="note-cl-quickadd-input" placeholder="+ Add item" data-note-id="${note.id}" /></div>` : ''}
|
||||
${reminderTagHtml}
|
||||
${noteTags.length ? `<div class="note-card-label">${noteTags.map(t => `<button type="button" class="note-card-label-chip" data-note-label-filter="${_esc(t)}" title="Filter #${_esc(t)}">#${_esc(t)}</button>`).join(' ')}</div>` : ''}
|
||||
${note.agent_session_id ? `<button class="note-agent-tag" data-note-id="${note.id}" data-session-id="${_esc(note.agent_session_id)}" title="Open the agent's chat for this note">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.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>
|
||||
<span>Agent</span>
|
||||
</button>` : ''}
|
||||
<div class="note-card-actions">
|
||||
<div class="note-card-colors">${colorDots}</div>
|
||||
<span style="flex:1"></span>
|
||||
@@ -2152,7 +2182,7 @@ function _bindCardEvents(body) {
|
||||
// Click empty area of checklist preview (not on checkbox/X) — edit
|
||||
body.querySelectorAll('.note-checklist-preview').forEach(el => {
|
||||
el.addEventListener('click', (e) => {
|
||||
if (e.target.closest('.note-checkbox, .note-checkbox-rm, .note-cl-quickadd, input')) return;
|
||||
if (e.target.closest('.note-checkbox, .note-checkbox-rm, .note-checkbox-agent, .note-cl-quickadd, input')) return;
|
||||
e.stopPropagation();
|
||||
tapToEditOrSelect(el.closest('.note-card'));
|
||||
});
|
||||
@@ -2178,7 +2208,7 @@ function _bindCardEvents(body) {
|
||||
// title / content preview triggered edit, so padding + empty gutters were
|
||||
// dead zones that felt broken on mobile.
|
||||
if (_isNotesMobileMode() && !_selectMode) {
|
||||
const _INTERACTIVE = 'button, a, input, label, .note-card-color-dot, .note-checkbox, .note-checkbox-rm, .note-cl-quickadd, .note-agent-tag, .note-card-pin, .note-card-corner-trash, .note-card-corner-menu, .note-card-corner-unarchive, .note-card-edit-corner, .note-card-reminder, .note-card-cb';
|
||||
const _INTERACTIVE = 'button, a, input, label, .note-card-color-dot, .note-checkbox, .note-checkbox-rm, .note-checkbox-agent, .note-cl-quickadd, .note-agent-tag, .note-card-pin, .note-card-corner-trash, .note-card-corner-menu, .note-card-corner-unarchive, .note-card-edit-corner, .note-card-reminder, .note-card-cb';
|
||||
body.querySelectorAll('.note-card').forEach(card => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.closest(_INTERACTIVE)) return;
|
||||
@@ -2272,16 +2302,6 @@ function _bindCardEvents(body) {
|
||||
_openNoteCornerMenu(btn);
|
||||
});
|
||||
});
|
||||
// Agent tag — opens the chat session the agent ran for this note.
|
||||
body.querySelectorAll('.note-agent-tag').forEach(tag => {
|
||||
tag.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const sid = tag.dataset.sessionId;
|
||||
const _sm = window.sessionModule;
|
||||
if (sid && _sm && _sm.selectSession) { closePanel(); _sm.selectSession(sid); }
|
||||
});
|
||||
});
|
||||
body.querySelectorAll('.note-card-label-chip').forEach(chip => {
|
||||
chip.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
@@ -2498,6 +2518,18 @@ function _bindCardEvents(body) {
|
||||
});
|
||||
});
|
||||
|
||||
// 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.
|
||||
body.querySelectorAll('.note-checkbox-agent').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (_selectMode) return;
|
||||
_openTodoAgentMenu(btn);
|
||||
});
|
||||
});
|
||||
|
||||
// Quick-add new checklist item (hover input at bottom of todo cards)
|
||||
body.querySelectorAll('.note-cl-quickadd-input').forEach(input => {
|
||||
input.addEventListener('click', (e) => e.stopPropagation());
|
||||
@@ -3329,7 +3361,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());
|
||||
@@ -3363,14 +3395,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); });
|
||||
@@ -4280,7 +4309,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;
|
||||
@@ -4306,15 +4335,58 @@ 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;`;
|
||||
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) {
|
||||
document.body.appendChild(menu);
|
||||
const r = btn.getBoundingClientRect();
|
||||
let left = Math.min(r.right - width, window.innerWidth - width - 8);
|
||||
left = Math.max(8, left);
|
||||
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:${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();
|
||||
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); });
|
||||
}
|
||||
|
||||
function _openTodoAgentMenu(btn) {
|
||||
document.querySelectorAll('.note-corner-menu-dropdown').forEach(d => d.remove());
|
||||
const noteId = btn.dataset.noteId;
|
||||
const idx = parseInt(btn.dataset.idx);
|
||||
const sid = btn.dataset.sessionId || '';
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'note-corner-menu-dropdown note-agent-item-menu';
|
||||
menu.innerHTML = `
|
||||
${sid ? `<button type="button" class="ncm-item" data-act="open">
|
||||
<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="M15 3h6v6"/><path d="M10 14L21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>
|
||||
<span>Open</span>
|
||||
</button>` : ''}
|
||||
<button type="button" class="ncm-item" data-act="run">
|
||||
<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="M12 8V4H8"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2M20 14h2M15 13v2M9 13v2"/></svg>
|
||||
<span>${sid ? 'Run again' : 'Run Agent'}</span>
|
||||
</button>`;
|
||||
_positionNoteMenu(menu, btn);
|
||||
const openBtn = menu.querySelector('[data-act="open"]');
|
||||
if (openBtn) {
|
||||
openBtn.addEventListener('click', () => {
|
||||
menu.remove();
|
||||
const _sm = window.sessionModule;
|
||||
if (sid && _sm && _sm.selectSession) { closePanel(); _sm.selectSession(sid); }
|
||||
});
|
||||
}
|
||||
menu.querySelector('[data-act="run"]').addEventListener('click', () => {
|
||||
menu.remove();
|
||||
_agentSolveTodoItem(noteId, idx);
|
||||
});
|
||||
}
|
||||
|
||||
// Build the prompt the agent gets from a note: title + body, plus any
|
||||
@@ -4328,7 +4400,7 @@ function _noteToAgentPrompt(note) {
|
||||
.forEach(it => parts.push('- ' + it.text.trim()));
|
||||
}
|
||||
const body = parts.join('\n');
|
||||
return body ? `Help me get this done:\n\n${body}` : '';
|
||||
return body ? `Help me get this done:\n\n${body}\n\nThe source note is read-only. Do not edit, replace, or update it.` : '';
|
||||
}
|
||||
|
||||
// Agent-solve: create a chat session server-side, kick off an agent run
|
||||
@@ -4370,6 +4442,7 @@ async function _agentSolveNote(id) {
|
||||
fd.append('message', prompt);
|
||||
fd.append('session', sid);
|
||||
fd.append('mode', 'agent');
|
||||
fd.append('disabled_tools', JSON.stringify(['manage_notes']));
|
||||
fetch(`${API_BASE}/api/chat_stream`, { method: 'POST', credentials: 'same-origin', body: fd })
|
||||
.then(async (res) => {
|
||||
if (!res.ok || !res.body) return;
|
||||
@@ -4388,6 +4461,86 @@ async function _agentSolveNote(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-item version of _agentSolveNote. Scoped to a single checklist item;
|
||||
// the note title (if any) is included as context, but only this one item's
|
||||
// text is the work the agent is asked to do. agent_session_id is set on the
|
||||
// PARENT note (latest-wins) so the Agent tag still surfaces the most recent
|
||||
// run from this note — same UX as a per-note solve.
|
||||
async function _agentSolveTodoItem(noteId, idx) {
|
||||
const note = _notes.find(n => n.id === noteId);
|
||||
if (!note || !Array.isArray(note.items)) return;
|
||||
const item = note.items[idx];
|
||||
const itemText = (item && (item.text || '').trim()) || '';
|
||||
if (!itemText) {
|
||||
uiModule.showToast('Nothing to solve — item is empty');
|
||||
return;
|
||||
}
|
||||
const titleCtx = (note.title || '').trim();
|
||||
const prompt = titleCtx
|
||||
? `Context (from note "${titleCtx}").\n\nHelp me with this todo: ${itemText}\n\nThe source note is read-only. Do not edit, replace, or update it.`
|
||||
: `Help me with this todo: ${itemText}\n\nThe source note is read-only. Do not edit, replace, or update it.`;
|
||||
try {
|
||||
const dc = await (await fetch(`${API_BASE}/api/default-chat`, { credentials: 'same-origin' })).json();
|
||||
if (!dc.endpoint_url || !dc.model) { uiModule.showError('No default chat model configured'); return; }
|
||||
|
||||
const label = itemText.slice(0, 40);
|
||||
const csFd = new FormData();
|
||||
csFd.append('name', 'Agent: ' + label);
|
||||
csFd.append('endpoint_url', dc.endpoint_url);
|
||||
csFd.append('model', dc.model);
|
||||
if (dc.endpoint_id) csFd.append('endpoint_id', dc.endpoint_id);
|
||||
csFd.append('skip_validation', 'true');
|
||||
const csRes = await fetch(`${API_BASE}/api/session`, { method: 'POST', credentials: 'same-origin', body: csFd });
|
||||
if (!csRes.ok) { uiModule.showError('Could not create agent session'); return; }
|
||||
const sess = await csRes.json();
|
||||
const sid = sess.id;
|
||||
const sessionTitle = 'Agent: ' + label;
|
||||
|
||||
const n = _notes.find(x => x.id === noteId);
|
||||
if (n) {
|
||||
n.agent_session_id = sid;
|
||||
if (Array.isArray(n.items) && n.items[idx]) {
|
||||
n.items[idx].agent_session_id = sid;
|
||||
n.items[idx].agent_session_title = sessionTitle;
|
||||
n.items[idx].agent_status = 'running';
|
||||
n.items[idx].agent_stream_completed_at = '';
|
||||
}
|
||||
}
|
||||
_renderNotes();
|
||||
_patchNote(noteId, { items: n && Array.isArray(n.items) ? n.items : note.items, agent_session_id: sid }).catch(() => {});
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('message', prompt);
|
||||
fd.append('session', sid);
|
||||
fd.append('mode', 'agent');
|
||||
fd.append('disabled_tools', JSON.stringify(['manage_notes']));
|
||||
fetch(`${API_BASE}/api/chat_stream`, { method: 'POST', credentials: 'same-origin', body: fd })
|
||||
.then(async (res) => {
|
||||
if (!res.ok || !res.body) return;
|
||||
const reader = res.body.getReader();
|
||||
while (true) { const { done } = await reader.read(); if (done) break; }
|
||||
if (window.sessionModule && window.sessionModule.markStreamComplete) {
|
||||
try { window.sessionModule.markStreamComplete(sid); } catch {}
|
||||
}
|
||||
const doneNote = _notes.find(x => x.id === noteId);
|
||||
if (doneNote && Array.isArray(doneNote.items) && doneNote.items[idx]) {
|
||||
doneNote.agent_session_id = sid;
|
||||
doneNote.items[idx].agent_session_id = sid;
|
||||
doneNote.items[idx].agent_session_title = sessionTitle;
|
||||
doneNote.items[idx].agent_status = 'stream_complete';
|
||||
doneNote.items[idx].agent_stream_completed_at = new Date().toISOString();
|
||||
_renderNotes();
|
||||
_patchNote(noteId, { items: doneNote.items, agent_session_id: sid }).catch(() => {});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
uiModule.showToast('Agent working on this item — tap the Agent tag when ready');
|
||||
} catch (e) {
|
||||
uiModule.showError('Agent failed: ' + (e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async function _copyNote(noteId, btnEl) {
|
||||
const note = _notes.find(n => n.id === noteId);
|
||||
if (!note) return false;
|
||||
|
||||
+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) {
|
||||
|
||||
+19
-32
@@ -366,20 +366,13 @@ function _buildPanelHTML() {
|
||||
<div class="modal-body research-pane-body" data-no-swipe-dismiss>
|
||||
<div class="research-new-job">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:2px;">
|
||||
<h2 style="margin:0;padding:0;line-height:1;display:inline-flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent, var(--red))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M6 18h8"/><path d="M3 22h18"/><path d="M14 22a7 7 0 1 0 0-14h-1"/><path d="M9 14h2"/><path d="M9 12a2 2 0 0 1-2-2V6h4v4a2 2 0 0 1-2 2Z"/><path d="M12 6V3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3"/></svg>Research <span id="research-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal;position:relative;top:4px;"></span></h2>
|
||||
<h2 style="margin:0;padding:0;line-height:1;display:inline-flex;align-items:center;gap:6px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--accent, var(--red))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M6 18h8"/><path d="M3 22h18"/><path d="M14 22a7 7 0 1 0 0-14h-1"/><path d="M9 14h2"/><path d="M9 12a2 2 0 0 1-2-2V6h4v4a2 2 0 0 1-2 2Z"/><path d="M12 6V3a1 1 0 0 0-1-1H9a1 1 0 0 0-1 1v3"/></svg>Research <span id="research-stats" class="memory-count" style="font-size:0.6em;opacity:0.6;font-weight:normal"></span></h2>
|
||||
</div>
|
||||
<p class="memory-desc doclib-desc" style="margin-top:2px;display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
|
||||
<span>Multi-step web research with an LLM-in-the-loop agent</span>
|
||||
<span id="research-no-past-hint" style="display:none;font-size:11px;opacity:0.7;position:relative;top:-4px;">— past runs in <button type="button" class="research-library-link" style="background:none;border:none;padding:0;font:inherit;color:var(--accent, var(--red));cursor:pointer;text-decoration:underline;">Library, Research</button></span>
|
||||
<span id="research-no-past-hint" style="display:none;font:inherit;opacity:1;position:static;">— past runs in <button type="button" class="research-library-link" style="background:none;border:none;padding:0;font:inherit;color:var(--accent, var(--red));cursor:pointer;text-decoration:underline;">Library, Research</button></span>
|
||||
</p>
|
||||
<textarea id="research-query" class="research-query" placeholder="${_pickResearchHint()}" rows="4"></textarea>
|
||||
<div class="research-category-row" id="research-category-row">
|
||||
<button class="research-cat active" data-cat="" title="LLM auto-detects the best format">Auto</button>
|
||||
<button class="research-cat" data-cat="product">Product</button>
|
||||
<button class="research-cat" data-cat="comparison">Compare</button>
|
||||
<button class="research-cat" data-cat="howto">How-to</button>
|
||||
<button class="research-cat" data-cat="factcheck">Fact-check</button>
|
||||
</div>
|
||||
<button id="research-settings-toggle" class="research-settings-toggle${chevronCls}">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;opacity:0.85;flex-shrink:0;"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>Settings<span class="research-settings-chevron">${_chevronIcon}</span>
|
||||
</button>
|
||||
@@ -787,6 +780,21 @@ function _renderJobs() {
|
||||
+ '<span class="research-section-dot' + (dotPulse ? ' pulsing' : '') + '" style="background:' + dotColor + ';"></span>'
|
||||
+ '<svg class="research-section-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg>'
|
||||
+ '</span>';
|
||||
if (key === 'past') {
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'research-library-hint';
|
||||
hint.innerHTML = '<span>Multi-step web research with an LLM-in-the-loop agent</span> <button type="button" class="research-library-link">Library, Research</button>';
|
||||
hint.querySelector('.research-library-link').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// Close the research panel first so the Library opens ABOVE it on mobile
|
||||
// (otherwise it stacks under the full-screen panel).
|
||||
closePanel();
|
||||
if (window.documentModule && window.documentModule.openLibrary) {
|
||||
window.documentModule.openLibrary({ tab: 'research' });
|
||||
}
|
||||
});
|
||||
header.appendChild(hint);
|
||||
}
|
||||
header.addEventListener('click', () => {
|
||||
const nowCollapsed = sec.classList.toggle('collapsed');
|
||||
if (nowCollapsed) _collapsedSections.add(key); else _collapsedSections.delete(key);
|
||||
@@ -803,27 +811,6 @@ function _renderJobs() {
|
||||
});
|
||||
const body = document.createElement('div');
|
||||
body.className = 'research-section-body';
|
||||
// Past Research header: link goes INLINE next to the title instead
|
||||
// of on a second row. Append it to the title span as a small chip.
|
||||
if (key === 'past') {
|
||||
const titleEl = header.querySelector('.research-section-title');
|
||||
if (titleEl) {
|
||||
const hint = document.createElement('span');
|
||||
hint.className = 'research-library-hint research-library-hint-inline';
|
||||
hint.style.cssText = 'margin-left:8px;font-size:10.5px;opacity:0.65;font-weight:normal;';
|
||||
hint.innerHTML = '— all in <button type="button" class="research-library-link" style="background:none;border:none;padding:0;font:inherit;color:var(--accent, var(--red));cursor:pointer;text-decoration:underline;">Library, Research</button>';
|
||||
hint.querySelector('.research-library-link').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// Close the research panel first so the Library opens ABOVE it on mobile
|
||||
// (otherwise it stacks under the full-screen panel).
|
||||
closePanel();
|
||||
if (window.documentModule && window.documentModule.openLibrary) {
|
||||
window.documentModule.openLibrary({ tab: 'research' });
|
||||
}
|
||||
});
|
||||
titleEl.appendChild(hint);
|
||||
}
|
||||
}
|
||||
arr.forEach(j => body.appendChild(_buildJobCard(j)));
|
||||
sec.appendChild(header);
|
||||
sec.appendChild(body);
|
||||
@@ -1014,9 +1001,9 @@ function _buildJobCard(job) {
|
||||
</div>
|
||||
${failNote}
|
||||
<div class="research-job-actions">
|
||||
<button class="research-job-action" data-action="copy" title="Copy report to clipboard">${_copyIcon}</button>
|
||||
<button class="research-job-action" data-action="chat" title="Open follow-up chat with this research as context">${_chatIcon} Discuss</button>
|
||||
<button class="research-job-action research-job-action-report" data-action="report" title="Visual report">${_externalIcon} Visual Report</button>
|
||||
<button class="research-job-action" data-action="chat" title="Open follow-up chat with this research as context">${_chatIcon} Discuss</button>
|
||||
<button class="research-job-action research-job-action-dim" data-action="copy" title="Copy report to clipboard">${_copyIcon}</button>
|
||||
<button class="research-job-action research-job-action-dim" data-action="dismiss" title="Clear from list">${_cancelIcon}</button>
|
||||
<button class="research-job-action research-job-action-dim" data-action="delete" title="Delete from disk">${_trashIcon} Delete</button>
|
||||
</div>
|
||||
|
||||
@@ -1938,6 +1938,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);
|
||||
|
||||
+28
-10
@@ -8,9 +8,11 @@ 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;
|
||||
let _authPolicy = { password_min_length: 8 };
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
function esc(s) { return uiModule.esc(s); }
|
||||
@@ -2160,6 +2162,16 @@ function initAccount() {
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
// Update password placeholder and policy from server
|
||||
fetch('/api/auth/policy', { credentials: 'same-origin' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(policy => {
|
||||
if (!policy) return;
|
||||
_authPolicy = policy;
|
||||
const pwNew = el('settings-pw-new');
|
||||
if (pwNew) pwNew.placeholder = `New password (min ${policy.password_min_length})`;
|
||||
}).catch(() => {});
|
||||
|
||||
// Change password
|
||||
const saveBtn = el('settings-pw-save');
|
||||
const msgEl = el('settings-pw-msg');
|
||||
@@ -2170,7 +2182,7 @@ function initAccount() {
|
||||
const conf = el('settings-pw-confirm').value;
|
||||
msgEl.style.color = '';
|
||||
if (!cur || !nw) { msgEl.textContent = 'Fill in all fields'; msgEl.style.color = 'var(--red)'; return; }
|
||||
if (nw.length < 8) { msgEl.textContent = 'Min 8 characters'; msgEl.style.color = 'var(--red)'; return; }
|
||||
if (nw.length < _authPolicy.password_min_length) { msgEl.textContent = `Min ${_authPolicy.password_min_length} characters`; msgEl.style.color = 'var(--red)'; return; }
|
||||
if (nw !== conf) { msgEl.textContent = 'Passwords don\'t match'; msgEl.style.color = 'var(--red)'; return; }
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
@@ -3827,7 +3839,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();
|
||||
@@ -3836,8 +3851,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 => {
|
||||
@@ -4573,7 +4587,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.
|
||||
@@ -4586,8 +4603,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 => {
|
||||
@@ -5639,8 +5655,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; }
|
||||
@@ -5672,8 +5691,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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,6 @@ export function initSidebarLayout(Storage, opts) {
|
||||
});
|
||||
|
||||
// Hamburger cycles: full sidebar → mini → off → full
|
||||
// Shift-click swaps sidebar side
|
||||
let _userToggledSidebar = false;
|
||||
let _wasAutoCollapsed = false;
|
||||
|
||||
@@ -122,8 +121,7 @@ export function initSidebarLayout(Storage, opts) {
|
||||
if (window.innerWidth < 768 && cc && cc.classList.contains('compare-active')) return;
|
||||
_userToggledSidebar = true;
|
||||
// Optionally place the sidebar on a specific edge (the swipe gesture passes
|
||||
// the direction). Persist it + re-anchor the doc panel, same as a
|
||||
// shift-click on the hamburger.
|
||||
// the direction). Persist it + re-anchor the doc panel.
|
||||
if (side === 'left' || side === 'right') {
|
||||
const wantRight = side === 'right';
|
||||
if (sidebar.classList.contains('right-side') !== wantRight) {
|
||||
@@ -143,13 +141,6 @@ export function initSidebarLayout(Storage, opts) {
|
||||
hamburgerBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (e.shiftKey) {
|
||||
sidebar.classList.toggle('right-side');
|
||||
Storage.set(Storage.KEYS.SIDEBAR_SIDE, sidebar.classList.contains('right-side') ? 'right' : 'left');
|
||||
syncRailSide();
|
||||
if (documentModule && documentModule.swapSide) documentModule.swapSide();
|
||||
return;
|
||||
}
|
||||
|
||||
_userToggledSidebar = true;
|
||||
const isSidebarVisible = !sidebar.classList.contains('hidden');
|
||||
|
||||
+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'
|
||||
};
|
||||
|
||||
|
||||
+36
-14
@@ -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;
|
||||
@@ -17,9 +19,16 @@ let _tasksFetched = false; // first-fetch sentinel — `false` → show loadin
|
||||
let _escHandler = null;
|
||||
let _viewingRuns = null; // task id when viewing run history
|
||||
let _clockInterval = null;
|
||||
let _taskFailurePending = false;
|
||||
|
||||
const DAYS_OF_WEEK = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
|
||||
function _setTaskFailurePending(active) {
|
||||
_taskFailurePending = !!active;
|
||||
document.getElementById('tool-tasks-btn')?.classList.toggle('task-failure-pending', _taskFailurePending);
|
||||
document.getElementById('rail-tasks')?.classList.toggle('task-failure-pending', _taskFailurePending);
|
||||
}
|
||||
|
||||
// ---- API ----
|
||||
|
||||
async function _fetchTasks() {
|
||||
@@ -892,10 +901,10 @@ function _attachTaskLongPress(card, menuBtn) {
|
||||
|
||||
function _showTaskDropdown(anchor, items) {
|
||||
// Remove any existing dropdown
|
||||
document.querySelectorAll('.task-dropdown').forEach(d => d.remove());
|
||||
document.querySelectorAll('.task-dropdown').forEach(dismissOrRemove);
|
||||
const dd = document.createElement('div');
|
||||
dd.className = 'task-dropdown';
|
||||
dd.style.cssText = 'position:fixed;z-index:100000;background:var(--panel);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px;min-width:120px;';
|
||||
dd.style.cssText = 'position:fixed;background:var(--panel);border:1px solid var(--border);border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.3);padding:4px;min-width:120px;';
|
||||
items.forEach(item => {
|
||||
const btn = document.createElement('button');
|
||||
btn.style.cssText = 'display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:6px 10px;border:none;background:none;color:var(--fg);font-size:11px;font-family:inherit;cursor:pointer;border-radius:4px;transition:background 0.1s;';
|
||||
@@ -907,10 +916,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;
|
||||
@@ -919,16 +932,13 @@ 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); }
|
||||
};
|
||||
// requestAnimationFrame so the listener is registered AFTER the current
|
||||
// pointer/click event cycle has finished bubbling.
|
||||
requestAnimationFrame(() => document.addEventListener('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);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Presets ----
|
||||
@@ -2238,6 +2248,9 @@ function _renderActivityEntry(entry) {
|
||||
status = _classifyResult(entry.result);
|
||||
}
|
||||
const statusDot = `<span class="task-log-status task-log-status-${status}" title="${status}"></span>`;
|
||||
const failedTag = status === 'error'
|
||||
? '<span class="task-log-failed-tag">(failed)</span>'
|
||||
: '';
|
||||
// Render the result through markdown so code blocks, lists, links look right.
|
||||
let resultHtml;
|
||||
const _isRunning = entry.status === 'running' || entry.status === 'queued';
|
||||
@@ -2361,7 +2374,7 @@ function _renderActivityEntry(entry) {
|
||||
<div class="task-log-row-head">
|
||||
${statusDot}
|
||||
<span class="task-log-task-icon">${_taskIcon({ action: entry.action, task_type: entry.kind })}</span>
|
||||
<span class="task-log-name">${_escHtml(entry.taskName)}</span>${_taskAiMark(entry)}
|
||||
<span class="task-log-name">${_escHtml(entry.taskName)}</span>${failedTag}${_taskAiMark(entry)}
|
||||
${repeatBadge}
|
||||
<span style="flex:1"></span>
|
||||
${rightHtml}
|
||||
@@ -2502,8 +2515,11 @@ function _renderMainView() {
|
||||
|
||||
export function openTasks(focusId, opts) {
|
||||
const o = opts || {};
|
||||
const openActivityForFailure = _taskFailurePending && !focusId && o.filter === undefined;
|
||||
_setTaskFailurePending(false);
|
||||
if (_open) {
|
||||
// Already open — just focus the requested task / apply filter.
|
||||
if (openActivityForFailure) _switchTab('activity');
|
||||
if (o.filter !== undefined) { _taskFilter = o.filter; _renderList(); }
|
||||
if (focusId) _focusTask(focusId);
|
||||
return;
|
||||
@@ -2610,7 +2626,7 @@ export function openTasks(focusId, opts) {
|
||||
// of an empty modal-body that fills in after the fetch resolves — that delay
|
||||
// was visible as a "flicker" right after opening.
|
||||
_activeTab = 'tasks';
|
||||
_switchTab('tasks');
|
||||
_switchTab(openActivityForFailure ? 'activity' : 'tasks');
|
||||
_fetchTasks().then(() => {
|
||||
// Re-render so the list swaps the Loading row for real cards.
|
||||
_renderList();
|
||||
@@ -2704,7 +2720,13 @@ async function _pollTaskNotifications() {
|
||||
const msg = `Task ${ok ? 'finished' : 'failed'}: ${n.task_name}`;
|
||||
if (!uiModule) continue;
|
||||
if (ok) uiModule.showToast(msg, { duration: 5000 });
|
||||
else uiModule.showError(msg);
|
||||
else {
|
||||
_setTaskFailurePending(true);
|
||||
uiModule.showError(msg);
|
||||
if (_open && document.querySelector('.tasks-tab.active[data-tab="activity"]')) {
|
||||
_renderActivityView();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silently ignore — server may be unreachable
|
||||
|
||||
+30
-1
@@ -26,7 +26,7 @@ export const THEMES = {
|
||||
gpt: { bg:'#212121', fg:'#ececec', panel:'#171717', border:'#424242', red:'#949494',
|
||||
advanced: { sendBtnBg: '#949494', sendBtnHover: '#7f7f7f',
|
||||
userBubbleBg: '#2f2f2f', aiBubbleBg: '#171717',
|
||||
inputBg: '#2f2f2f' } },
|
||||
inputBg: '#2f2f2f', brandColor: '#ffffff', brandMixTo: '#ffffff' } },
|
||||
claude: { bg:'#262624', fg:'#f5f4f0', panel:'#30302e', border:'#4a4a47', red:'#c6613f' },
|
||||
cute: { bg:'#fff0f5', fg:'#d4608a', panel:'#fff8fa', border:'#f0c0d0', red:'#ff6b9d' },
|
||||
};
|
||||
@@ -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';
|
||||
@@ -184,6 +185,7 @@ const ADV_KEYS = [
|
||||
{ key: 'bubbleBorder', css: '--bubble-border', label: 'Border Chat Bubble', group: 'Chat Bubbles' },
|
||||
{ key: 'sidebarBg', css: '--sidebar-bg', label: 'Sidebar Bg', group: 'Sidebar' },
|
||||
{ key: 'brandColor', css: '--brand-color', label: 'Odysseus Logo', group: 'Sidebar' },
|
||||
{ key: 'brandMixTo', css: '--brand-mix-to', label: 'Logo Gradient End', group: 'Sidebar' },
|
||||
{ key: 'hamburgerColor', css: '--hamburger-color', label: 'Hamburger Menu', group: 'Sidebar' },
|
||||
{ key: 'inputBg', css: '--input-bg', label: 'Input Bg', group: 'Chat Input / Prompt Area' },
|
||||
{ key: 'inputBorder', css: '--input-border', label: 'Input Border', group: 'Chat Input / Prompt Area' },
|
||||
@@ -203,6 +205,7 @@ function computeAdvancedDefaults(colors) {
|
||||
bubbleBorder: colors.border,
|
||||
sidebarBg: colors.panel,
|
||||
brandColor: red,
|
||||
brandMixTo: colors.fg,
|
||||
hamburgerColor: colors.fg,
|
||||
inputBg: colors.panel,
|
||||
inputBorder: colors.border,
|
||||
@@ -385,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',
|
||||
@@ -1131,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;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export const TOOL_WINDOW_SELECTOR = 'body > .modal, body > .research-overlay, body > .notes-pane-backdrop';
|
||||
|
||||
export function topToolWindowZ(options = {}) {
|
||||
const {
|
||||
exclude = null,
|
||||
root = globalThis.document,
|
||||
getStyle = globalThis.getComputedStyle,
|
||||
floor = 250,
|
||||
} = options;
|
||||
let top = floor;
|
||||
if (!root || typeof root.querySelectorAll !== 'function' || typeof getStyle !== 'function') return top;
|
||||
root.querySelectorAll(TOOL_WINDOW_SELECTOR).forEach(el => {
|
||||
if (!el || el === exclude) return;
|
||||
if (el.classList?.contains('hidden') || el.classList?.contains('modal-minimized')) return;
|
||||
const cs = getStyle(el);
|
||||
if (cs.display === 'none' || cs.visibility === 'hidden') return;
|
||||
const z = parseInt(cs.zIndex, 10);
|
||||
if (Number.isFinite(z)) top = Math.max(top, z);
|
||||
});
|
||||
return top;
|
||||
}
|
||||
|
||||
export function nextToolWindowZ(options = {}) {
|
||||
const { current = null } = options;
|
||||
const top = topToolWindowZ(options);
|
||||
const currentZ = parseInt(current, 10);
|
||||
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;
|
||||
}
|
||||
+21
-6
@@ -8,6 +8,7 @@ import themeModule from './theme.js';
|
||||
import * as Modals from './modalManager.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { registerMenuDismiss, dismissTopMenu, dismissOrRemove } from './escMenuStack.js';
|
||||
import { nextToolWindowZ, topToolWindowZ } from './toolWindowZOrder.js';
|
||||
|
||||
let toastEl = null;
|
||||
let autoScrollEnabled = true;
|
||||
@@ -1088,14 +1089,22 @@ if ('ontouchstart' in window) {
|
||||
|
||||
// ---- Bring modal to front on click ----
|
||||
{
|
||||
let topModalZ = 250;
|
||||
const raiseModalToFront = (modal, floor = 250) => {
|
||||
const z = nextToolWindowZ({
|
||||
exclude: modal,
|
||||
current: getComputedStyle(modal).zIndex,
|
||||
floor,
|
||||
});
|
||||
modal.style.setProperty('z-index', String(z), 'important');
|
||||
return z;
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
const modalContent = e.target.closest('.modal-content');
|
||||
if (!modalContent) return;
|
||||
const modal = modalContent.closest('.modal');
|
||||
if (!modal) return;
|
||||
topModalZ += 1;
|
||||
modal.style.zIndex = topModalZ;
|
||||
raiseModalToFront(modal);
|
||||
});
|
||||
|
||||
// Backdrop tap to close — delegated for all modals
|
||||
@@ -1190,9 +1199,15 @@ if (!window._odyEscExpandGuard) {
|
||||
// Re-entry guard: setting style.zIndex itself fires the observer that
|
||||
// calls us back. Skip if this element is already pinned to the top
|
||||
// (matches the current counter) so we don't spin into an infinite loop.
|
||||
const cur = parseInt(m.style.zIndex, 10) || 0;
|
||||
if (cur === _zCounter) return;
|
||||
m.style.zIndex = String(++_zCounter);
|
||||
const cur = parseInt(getComputedStyle(m).zIndex, 10) || 0;
|
||||
if (cur === _zCounter && cur > topToolWindowZ({ exclude: m })) return;
|
||||
const z = nextToolWindowZ({
|
||||
exclude: m,
|
||||
current: cur,
|
||||
floor: _zCounter,
|
||||
});
|
||||
_zCounter = Math.max(_zCounter, z);
|
||||
if (z !== cur) m.style.setProperty('z-index', String(z), 'important');
|
||||
};
|
||||
new MutationObserver((muts) => {
|
||||
for (const m of muts) {
|
||||
|
||||
+18
-5
@@ -328,6 +328,7 @@
|
||||
|
||||
let mode = 'login'; // 'login' | 'signup' | 'setup'
|
||||
let signupAllowed = false;
|
||||
let policy = { password_min_length: 8, reserved_usernames: [] };
|
||||
|
||||
const rememberToggle = document.getElementById('rememberToggle');
|
||||
|
||||
@@ -360,10 +361,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Check auth status
|
||||
// Check auth status and fetch policy in parallel, but don't block the
|
||||
// authenticated redirect on the policy response.
|
||||
const policyPromise = fetch('/api/auth/policy', { credentials: 'same-origin' }).catch(() => null);
|
||||
try {
|
||||
const res = await fetch('/api/auth/status', { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
const statusRes = await fetch('/api/auth/status', { credentials: 'same-origin' });
|
||||
const data = await statusRes.json();
|
||||
if (data.authenticated) {
|
||||
window.location.replace('/');
|
||||
return;
|
||||
@@ -374,6 +377,10 @@
|
||||
} else {
|
||||
setMode('login');
|
||||
}
|
||||
const policyRes = await policyPromise;
|
||||
if (policyRes && policyRes.ok) {
|
||||
policy = await policyRes.json();
|
||||
}
|
||||
} catch (e) {
|
||||
setMode('login');
|
||||
}
|
||||
@@ -426,8 +433,14 @@
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
errEl.textContent = 'Password must be at least 8 characters';
|
||||
if (password.length < policy.password_min_length) {
|
||||
errEl.textContent = `Password must be at least ${policy.password_min_length} characters`;
|
||||
errEl.style.display = 'block';
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (policy.reserved_usernames.includes(username.toLowerCase())) {
|
||||
errEl.textContent = 'This username is reserved';
|
||||
errEl.style.display = 'block';
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
|
||||
+709
-63
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user