mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-09 06:58:41 -04:00
Merge verified Odysseus fixes
This commit is contained in:
+50
-29
@@ -466,21 +466,22 @@ async function _selectAddedModelInChat(endpoint) {
|
||||
async function loadEndpoints() {
|
||||
const listLocal = el('adm-epList-local');
|
||||
const listApi = el('adm-epList-api');
|
||||
// Fallback to the legacy single list if the split containers don't exist
|
||||
// (older HTML or third-party embedding).
|
||||
const listLegacy = el('adm-epList');
|
||||
// Refresh model picker so new endpoints show up in chat
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
window.modelsModule.refreshModels(true);
|
||||
// Render endpoint rows first. Do not make Added Models wait on /api/models or
|
||||
// endpoint probes; explicit Refresh/Probe actions do that work.
|
||||
const refreshDependentModelUi = (force = false) => {
|
||||
setTimeout(() => {
|
||||
if (window.sessionModule && window.sessionModule.updateModelPicker) {
|
||||
window.sessionModule.updateModelPicker();
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
window.modelsModule.refreshModels(!!force, force ? {} : { cacheOnly: true }).then(() => {
|
||||
if (window.sessionModule && window.sessionModule.updateModelPicker) {
|
||||
window.sessionModule.updateModelPicker();
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') {
|
||||
settingsModule.refreshAiModelEndpoints();
|
||||
}
|
||||
if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') {
|
||||
settingsModule.refreshAiModelEndpoints();
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
try {
|
||||
const res = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
|
||||
// Treat a non-OK response (e.g. 401/403 for non-admins, or backend
|
||||
@@ -495,13 +496,16 @@ async function loadEndpoints() {
|
||||
const empty = '<div class="admin-empty">None</div>';
|
||||
if (listLocal) listLocal.innerHTML = empty;
|
||||
if (listApi) listApi.innerHTML = '<div class="admin-empty">None</div>';
|
||||
if (listLegacy) listLegacy.innerHTML = empty;
|
||||
refreshDependentModelUi();
|
||||
return;
|
||||
}
|
||||
const rowHtml = data.map(ep => {
|
||||
const epModels = Array.isArray(ep.models) ? ep.models : [];
|
||||
const visibleCount = epModels.length;
|
||||
const totalCount = visibleCount + (ep.hidden_count || 0);
|
||||
const pinnedModels = Array.isArray(ep.pinned_models) ? ep.pinned_models : [];
|
||||
const visibleCount = ep.picker_requires_pinning ? pinnedModels.length : epModels.length;
|
||||
const totalCount = Number.isFinite(Number(ep.model_count))
|
||||
? Number(ep.model_count)
|
||||
: visibleCount + (ep.hidden_count || 0);
|
||||
// `ep.models` is the *visible* set — when every model is hidden it's
|
||||
// empty, but we still need to render the expand panel so the user can
|
||||
// un-hide them. Gate on the total instead.
|
||||
@@ -562,17 +566,21 @@ async function loadEndpoints() {
|
||||
apiIdx.sort(_sortByEnabled);
|
||||
_renderInto(listLocal, localIdx);
|
||||
_renderInto(listApi, apiIdx);
|
||||
if (listLegacy) listLegacy.innerHTML = rowHtml.join('');
|
||||
// Iterate matching nodes across both containers.
|
||||
const queryAll = (sel) => {
|
||||
const out = [];
|
||||
[listLocal, listApi, listLegacy].forEach(c => {
|
||||
[listLocal, listApi].forEach(c => {
|
||||
if (c) c.querySelectorAll(sel).forEach(n => out.push(n));
|
||||
});
|
||||
return out;
|
||||
};
|
||||
queryAll('[data-adm-toggle-ep]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => { e.stopPropagation(); await fetch(`/api/model-endpoints/${btn.dataset.admToggleEp}`, { method: 'PATCH' }); loadEndpoints(); });
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await fetch(`/api/model-endpoints/${btn.dataset.admToggleEp}`, { method: 'PATCH' });
|
||||
await _refreshAfterEndpointChange();
|
||||
loadEndpoints();
|
||||
});
|
||||
});
|
||||
queryAll('[data-adm-copy-url]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
@@ -663,6 +671,8 @@ async function loadEndpoints() {
|
||||
const _loadingHtml = (label) => `<span style="opacity:0.55;font-size:11px;display:inline-flex;align-items:center;gap:8px;">${esc(label)}</span>`;
|
||||
const renderModels = (models, warning = '') => {
|
||||
const sortedModels = sortModelObjects(models);
|
||||
const usesPinnedPicker = sortedModels.some(m => !!m.picker_requires_pinning);
|
||||
panel.dataset.pickerMode = usesPinnedPicker ? 'pinned' : 'hidden';
|
||||
const warningHtml = warning ? `<div class="admin-error" style="font-size:11px;margin:6px 0;">${esc(warning)}</div>` : '';
|
||||
const attachRefresh = () => {
|
||||
panel.querySelector(`[data-ep-refresh-models="${epId}"]`)?.addEventListener('click', async (e) => {
|
||||
@@ -674,6 +684,7 @@ async function loadEndpoints() {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const refreshedModels = await res.json();
|
||||
renderModels(refreshedModels, refreshWarning);
|
||||
_refreshAfterEndpointChange();
|
||||
if (refreshWarning && uiModule?.showToast) uiModule.showToast(refreshWarning, 6000);
|
||||
} catch (_) {
|
||||
renderModels(sortedModels, 'Model refresh failed; kept cached models.');
|
||||
@@ -684,26 +695,26 @@ async function loadEndpoints() {
|
||||
panel.innerHTML = `<div class="mcp-tools-header">
|
||||
<span>Models</span>
|
||||
<span style="display:flex;gap:8px;align-items:center;">
|
||||
<span class="mcp-tools-count">0/0 enabled</span>
|
||||
<a href="#" data-ep-refresh-models="${epId}">Refresh</a>
|
||||
</span>
|
||||
</div>${warningHtml}<span style="opacity:0.5;font-size:11px;">No models</span>`;
|
||||
attachRefresh();
|
||||
return;
|
||||
}
|
||||
const hiddenSet = new Set(sortedModels.filter(m => m.is_hidden).map(m => m.id));
|
||||
const enabledCount = usesPinnedPicker
|
||||
? sortedModels.filter(m => m.is_pinned).length
|
||||
: sortedModels.filter(m => !m.is_hidden).length;
|
||||
const showSearch = sortedModels.length >= 8;
|
||||
panel.innerHTML = `<div class="mcp-tools-header">
|
||||
<span>Models</span>
|
||||
<span style="display:flex;gap:8px;align-items:center;">
|
||||
<span class="mcp-tools-count">${sortedModels.length - hiddenSet.size}/${sortedModels.length} enabled</span>
|
||||
<a href="#" data-ep-refresh-models="${epId}">Refresh</a>
|
||||
<a href="#" data-ep-select-all="${epId}">All</a>
|
||||
<a href="#" data-ep-select-none="${epId}">None</a>
|
||||
</span>
|
||||
</div>${warningHtml}${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + sortedModels.map(m =>
|
||||
`<label title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row">
|
||||
<input type="checkbox" class="adm-cb-hidden" data-ep-model-id="${esc(m.id)}" ${!m.is_hidden ? 'checked' : ''}>
|
||||
<input type="checkbox" class="adm-cb-hidden" data-ep-model-id="${esc(m.id)}" ${(usesPinnedPicker ? m.is_pinned : !m.is_hidden) ? 'checked' : ''}>
|
||||
<span class="adm-check-dot" aria-hidden="true"></span>
|
||||
<span>${esc(m.display)}</span>
|
||||
</label>`
|
||||
@@ -744,35 +755,43 @@ async function loadEndpoints() {
|
||||
}
|
||||
});
|
||||
});
|
||||
refreshDependentModelUi();
|
||||
} catch (e) {
|
||||
const err = '<div class="admin-error">Failed to load</div>';
|
||||
[listLocal, listApi, listLegacy].forEach(c => { if (c) c.innerHTML = err; });
|
||||
[listLocal, listApi].forEach(c => { if (c) c.innerHTML = err; });
|
||||
}
|
||||
}
|
||||
|
||||
async function _saveEpModelState(epId, panel) {
|
||||
const hidden = [];
|
||||
const pinned = [];
|
||||
const usesPinnedPicker = panel && panel.dataset && panel.dataset.pickerMode === 'pinned';
|
||||
panel.querySelectorAll('input[type=checkbox]').forEach(cb => {
|
||||
if (!cb.checked) hidden.push(cb.dataset.epModelId);
|
||||
if (cb.checked) pinned.push(cb.dataset.epModelId);
|
||||
else hidden.push(cb.dataset.epModelId);
|
||||
});
|
||||
const total = panel.querySelectorAll('input[type=checkbox]').length;
|
||||
const enabled = usesPinnedPicker ? pinned.length : total - hidden.length;
|
||||
try {
|
||||
await fetch(`/api/model-endpoints/${epId}/models`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ hidden }),
|
||||
body: JSON.stringify(usesPinnedPicker ? { pinned_models: pinned } : { hidden }),
|
||||
});
|
||||
const countLabel = panel.querySelector('.mcp-tools-count');
|
||||
if (countLabel) countLabel.textContent = `${total - hidden.length}/${total} enabled`;
|
||||
const row = panel.closest('[data-adm-ep-id]');
|
||||
if (row) {
|
||||
const badge = row.querySelector('.admin-badge');
|
||||
if (badge && !badge.classList.contains('admin-badge-off')) badge.textContent = `${total - hidden.length}/${total} models enabled`;
|
||||
if (badge && !badge.classList.contains('admin-badge-off')) {
|
||||
const match = String(badge.textContent || '').match(/\/(\d+)/);
|
||||
const canonicalTotal = match ? Number(match[1]) : total;
|
||||
badge.textContent = `${enabled}/${canonicalTotal} models enabled`;
|
||||
}
|
||||
}
|
||||
if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') {
|
||||
settingsModule.refreshAiModelEndpoints();
|
||||
}
|
||||
_refreshAfterEndpointChange();
|
||||
} catch (e) { /* silent */ }
|
||||
}
|
||||
|
||||
@@ -1480,6 +1499,7 @@ function initEndpointForm() {
|
||||
})());
|
||||
await Promise.all(workers);
|
||||
await loadEndpoints();
|
||||
await _refreshAfterEndpointChange();
|
||||
_refreshOfflineCount();
|
||||
if (uiModule && uiModule.showToast) {
|
||||
const ok = Math.max(0, ids.length - failed);
|
||||
@@ -1522,6 +1542,7 @@ function initEndpointForm() {
|
||||
await Promise.all(ids.map(id =>
|
||||
fetch('/api/model-endpoints/' + id, { method: 'DELETE', credentials: 'same-origin' }).catch(() => {})
|
||||
));
|
||||
await _refreshAfterEndpointChange();
|
||||
try { await loadEndpoints(); } catch (_) {}
|
||||
_refreshOfflineCount();
|
||||
if (uiModule && uiModule.showToast) uiModule.showToast(`Removed ${ids.length} offline endpoint${ids.length === 1 ? '' : 's'}`, 1800);
|
||||
|
||||
+636
-92
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ const REPORT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
const CHAT_ABOUT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
|
||||
const COPY_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
||||
const CHECK_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
const PAPERCLIP_ICON = '<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="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>';
|
||||
|
||||
/** Sanitize a URL for use in href — only allow http(s) and protocol-relative. */
|
||||
function _safeHref(url) {
|
||||
@@ -1197,7 +1198,7 @@ document.addEventListener('click', function(e) {
|
||||
} catch {}
|
||||
});
|
||||
} else if (kind === 'document') {
|
||||
import('./document.js').then(mod => {
|
||||
import('./document.js?v=20260722emailfastindex1').then(mod => {
|
||||
const open = mod.loadDocument
|
||||
|| mod.openDocument
|
||||
|| (mod.default && (mod.default.loadDocument || mod.default.openDocument));
|
||||
@@ -1219,7 +1220,7 @@ document.addEventListener('click', function(e) {
|
||||
if (open) open(id);
|
||||
}).catch(() => {});
|
||||
} else if (kind === 'email') {
|
||||
import('./emailLibrary.js').then(mod => {
|
||||
import('./emailLibrary.js?v=20260722emailfastindex1').then(mod => {
|
||||
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
||||
if (open) open({ uid: id });
|
||||
}).catch(() => {});
|
||||
@@ -1254,6 +1255,9 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
|
||||
var esc = uiModule.esc;
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'msg msg-ai generated-image-wrap';
|
||||
wrap.dataset.imageUrl = imageUrl || '';
|
||||
wrap.dataset.imageKey = String(imageId || imageUrl || '');
|
||||
if (imageId) wrap.dataset.imageId = imageId;
|
||||
|
||||
const role = document.createElement('div');
|
||||
role.className = 'role';
|
||||
@@ -1329,6 +1333,42 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
|
||||
});
|
||||
actions.appendChild(dlBtn);
|
||||
|
||||
const reuseBtn = document.createElement('button');
|
||||
reuseBtn.className = 'footer-copy-btn';
|
||||
reuseBtn.type = 'button';
|
||||
reuseBtn.title = 'Attach image to new prompt';
|
||||
reuseBtn.innerHTML = PAPERCLIP_ICON;
|
||||
reuseBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
reuseBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(safeImageUrl, { credentials: 'same-origin' });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const blob = await resp.blob();
|
||||
const ext = (blob.type || '').includes('jpeg') ? 'jpg'
|
||||
: (blob.type || '').includes('webp') ? 'webp'
|
||||
: (blob.type || '').includes('gif') ? 'gif'
|
||||
: 'png';
|
||||
const base = (prompt || 'generated-image').slice(0, 36).replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'generated-image';
|
||||
const file = new File([blob], `${base}.${ext}`, { type: blob.type || 'image/png', lastModified: Date.now() });
|
||||
const mod = await import('./fileHandler.js');
|
||||
const addFiles = mod.addFiles || (mod.default && mod.default.addFiles);
|
||||
if (!addFiles) throw new Error('attachment handler unavailable');
|
||||
await addFiles([file], { skipCrop: true });
|
||||
const input = document.getElementById('message');
|
||||
if (input) input.focus();
|
||||
reuseBtn.innerHTML = CHECK_ICON;
|
||||
if (window.showToast) window.showToast('Image attached');
|
||||
setTimeout(() => { reuseBtn.innerHTML = PAPERCLIP_ICON; reuseBtn.disabled = false; }, 1400);
|
||||
} catch (err) {
|
||||
console.warn('Attach generated image failed', err);
|
||||
reuseBtn.textContent = '\u2717';
|
||||
if (window.showToast) window.showToast('Could not attach image');
|
||||
setTimeout(() => { reuseBtn.innerHTML = PAPERCLIP_ICON; reuseBtn.disabled = false; }, 1600);
|
||||
}
|
||||
});
|
||||
actions.appendChild(reuseBtn);
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'footer-copy-btn';
|
||||
editBtn.type = 'button';
|
||||
@@ -1447,8 +1487,12 @@ export function hideWelcomeScreen() {
|
||||
export function showWelcomeScreen() {
|
||||
const ws = document.getElementById('welcome-screen');
|
||||
const cc = document.getElementById('chat-container');
|
||||
const alreadyVisible = !!(ws && !ws.classList.contains('hidden'));
|
||||
if (ws) ws.classList.remove('hidden');
|
||||
if (cc) cc.classList.add('welcome-active');
|
||||
if (alreadyVisible) {
|
||||
return;
|
||||
}
|
||||
// Entering the New Chat / welcome state: discard any stale draft left in the
|
||||
// composer from the previous session so the input starts empty (issue #1343).
|
||||
// Switching between existing sessions loads them directly and does NOT call
|
||||
|
||||
@@ -7,7 +7,7 @@ import Storage from './storage.js';
|
||||
import themeModule from './theme.js';
|
||||
import markdownModule from './markdown.js';
|
||||
import sessionModule from './sessions.js';
|
||||
import documentModule from './document.js';
|
||||
import documentModule from './document.js?v=20260722emailfastindex1';
|
||||
|
||||
/**
|
||||
* Handle a ui_control SSE event — AI-driven UI manipulation.
|
||||
@@ -156,7 +156,7 @@ export function handleUIControl(uiData) {
|
||||
if (fn) fn();
|
||||
}).catch(function(){});
|
||||
} else if (panel === 'email') {
|
||||
import('./emailLibrary.js').then(function(mod) {
|
||||
import('./emailLibrary.js?v=20260722emailfastindex1').then(function(mod) {
|
||||
var fn = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
||||
if (fn) fn();
|
||||
}).catch(function(){});
|
||||
@@ -205,7 +205,7 @@ export function handleUIControl(uiData) {
|
||||
} catch (e) {
|
||||
console.warn('open_email_reply existing draft update failed:', e);
|
||||
}
|
||||
import('./emailInbox.js').then(function(mod) {
|
||||
import('./emailInbox.js?v=20260722emailfastindex1').then(function(mod) {
|
||||
var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft);
|
||||
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || '');
|
||||
}).catch(function(e) {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { EVAL_PROMPTS, WAVE_FRAMES,
|
||||
SEND_SVG, VOTES_STORAGE_KEY,
|
||||
} from './icons.js';
|
||||
import { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js';
|
||||
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js';
|
||||
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js?v=20260723compareicon2';
|
||||
import { _checkUnprobed, _clearProbeWaves } from './probe.js';
|
||||
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js';
|
||||
import {
|
||||
@@ -359,7 +359,7 @@ async function _buildCompareUI() {
|
||||
headerLeft.style.cssText = 'display:flex;align-items:center;min-width:0;';
|
||||
const headerIcon = document.createElement('span');
|
||||
headerIcon.style.cssText = 'display:inline-flex;flex-shrink:0;margin-right:6px;opacity:0.85;';
|
||||
headerIcon.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>';
|
||||
headerIcon.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M10 8h4"/><path d="M10 16h4"/></svg>';
|
||||
headerLeft.appendChild(headerIcon);
|
||||
headerLeft.appendChild(headerLabel);
|
||||
headerBar.appendChild(headerLeft);
|
||||
|
||||
@@ -75,7 +75,7 @@ async function showModelSelector() {
|
||||
header.className = 'modal-header';
|
||||
|
||||
const title = document.createElement('h4');
|
||||
title.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><path d="M11 18H8a2 2 0 0 1-2-2V9"/></svg>Model Comparison';
|
||||
title.innerHTML = '<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"><rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M10 8h4"/><path d="M10 16h4"/></svg>Model Comparison';
|
||||
// Absorb the free space so the injected minimize (_) and close (✕) cluster
|
||||
// together on the right instead of being spread apart by space-between.
|
||||
title.style.marginRight = 'auto';
|
||||
|
||||
@@ -1,61 +1,171 @@
|
||||
/**
|
||||
* ArrowUp on an empty composer recalls the last user message (chat-app convention).
|
||||
* ArrowUp on the composer recalls previous user messages from this chat.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Last user bubble in the active chat surface (#chat-history), using dataset.raw
|
||||
* (same source as resend/regenerate in chat.js).
|
||||
* User bubbles in the active chat surface (#chat-history), newest first, using
|
||||
* dataset.raw (same source as resend/regenerate in chat.js).
|
||||
*
|
||||
* @param {Document | Element} [root=document]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function getUserMessagesFromChatHistory(root = document) {
|
||||
const chatBox =
|
||||
root && root.id === 'chat-history' && typeof root.querySelectorAll === 'function'
|
||||
? root
|
||||
: (root.getElementById ? root.getElementById('chat-history') : null);
|
||||
if (!chatBox) return [];
|
||||
|
||||
const users = chatBox.querySelectorAll('.msg-user');
|
||||
const prompts = [];
|
||||
for (let i = users.length - 1; i >= 0; i--) {
|
||||
const msg = users[i];
|
||||
const bodyEl = msg.querySelector('.body');
|
||||
const text = msg.dataset?.raw || (bodyEl ? bodyEl.textContent : '') || '';
|
||||
if (text) prompts.push(text);
|
||||
}
|
||||
return prompts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Last user bubble in the active chat surface (#chat-history).
|
||||
*
|
||||
* @param {Document | Element} [root=document]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getLastUserMessageFromChatHistory(root = document) {
|
||||
const chatBox =
|
||||
root && root.id === 'chat-history' && typeof root.querySelectorAll === 'function'
|
||||
? root
|
||||
: (root.getElementById ? root.getElementById('chat-history') : null);
|
||||
if (!chatBox) return '';
|
||||
|
||||
const users = chatBox.querySelectorAll('.msg-user');
|
||||
const last = users[users.length - 1];
|
||||
if (!last) return '';
|
||||
|
||||
const bodyEl = last.querySelector('.body');
|
||||
return last.dataset?.raw || (bodyEl ? bodyEl.textContent : '') || '';
|
||||
return getUserMessagesFromChatHistory(root)[0] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLTextAreaElement} composer
|
||||
* @param {() => string} getLastUserMessage
|
||||
* @param {() => string|string[]} getUserMessages
|
||||
* @param {{ autoResize?: (el: HTMLTextAreaElement) => void }} [options]
|
||||
* @returns {boolean} true when wired (or already wired)
|
||||
*/
|
||||
export function wireArrowUpRecall(composer, getLastUserMessage, options = {}) {
|
||||
export function wireArrowUpRecall(composer, getUserMessages, options = {}) {
|
||||
if (!composer) return false;
|
||||
if (composer._arrowUpRecallWired) return true;
|
||||
composer._arrowUpRecallWired = true;
|
||||
|
||||
const { autoResize } = options;
|
||||
let recallIndex = -1;
|
||||
let applyingRecall = false;
|
||||
let lastRecalledValue = '';
|
||||
let recallHistory = [];
|
||||
|
||||
const readHistory = () => {
|
||||
const value = getUserMessages?.();
|
||||
if (Array.isArray(value)) return value.filter(Boolean);
|
||||
return value ? [value] : [];
|
||||
};
|
||||
const norm = (value) => String(value || '').replace(/\r\n/g, '\n').trimEnd();
|
||||
const debug = (...args) => {
|
||||
try {
|
||||
if (localStorage.getItem('odysseusArrowRecallDebug') === '1') {
|
||||
console.debug('[arrow-recall]', ...args);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
composer.addEventListener('input', () => {
|
||||
if (applyingRecall) return;
|
||||
if (norm(composer.value) === norm(lastRecalledValue)) return;
|
||||
recallIndex = -1;
|
||||
lastRecalledValue = '';
|
||||
recallHistory = [];
|
||||
try { delete composer.dataset.odysseusRecallIndex; } catch (_) {}
|
||||
});
|
||||
|
||||
composer.addEventListener('keydown', (e) => {
|
||||
// Only ArrowUp, no modifier keys, no IME composition
|
||||
if (e.key !== 'ArrowUp') return;
|
||||
// Prompt history: ArrowUp walks older, ArrowDown walks newer/back to blank.
|
||||
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||
if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return;
|
||||
if (e.isComposing) return;
|
||||
if (typeof window !== 'undefined' && window._ghostAutocomplete?.isActive?.()) return;
|
||||
|
||||
// Literal emptiness — intentional whitespace is not empty
|
||||
if (composer.value !== '') return;
|
||||
|
||||
const recalled = getLastUserMessage();
|
||||
if (!recalled) return;
|
||||
const freshHistory = readHistory();
|
||||
const history = freshHistory.length ? freshHistory : recallHistory;
|
||||
if (!history.length) {
|
||||
debug('skip:no-history', { value: composer.value });
|
||||
return;
|
||||
}
|
||||
|
||||
const rawCurrentValue = String(composer.value || '');
|
||||
const currentValue = norm(rawCurrentValue);
|
||||
const recalledValue = norm(lastRecalledValue);
|
||||
let currentIndex = rawCurrentValue === ''
|
||||
? -1
|
||||
: history.findIndex((item) => norm(item) === currentValue);
|
||||
if (currentIndex < 0 && currentValue && currentValue === recalledValue) {
|
||||
currentIndex = recallIndex;
|
||||
}
|
||||
if (currentIndex < 0 && currentValue) {
|
||||
const markedIndex = Number(composer.dataset?.odysseusRecallIndex);
|
||||
if (Number.isInteger(markedIndex) && markedIndex >= 0 && markedIndex < history.length) {
|
||||
currentIndex = markedIndex;
|
||||
}
|
||||
}
|
||||
if (rawCurrentValue !== '' && currentIndex < 0) {
|
||||
debug('skip:draft-in-progress', { value: composer.value });
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation?.();
|
||||
e.stopImmediatePropagation?.();
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (currentIndex < 0) return;
|
||||
const nextIndex = currentIndex - 1;
|
||||
if (nextIndex < 0) {
|
||||
recallIndex = -1;
|
||||
recallHistory = history;
|
||||
applyingRecall = true;
|
||||
lastRecalledValue = '';
|
||||
try { delete composer.dataset.odysseusRecallIndex; } catch (_) {}
|
||||
composer.value = '';
|
||||
try { composer.selectionStart = composer.selectionEnd = 0; } catch (_) {}
|
||||
if (autoResize) autoResize(composer);
|
||||
debug('handled-down-clear', { historyLength: history.length });
|
||||
setTimeout(() => { applyingRecall = false; }, 0);
|
||||
return;
|
||||
}
|
||||
const recalled = history[nextIndex];
|
||||
recallIndex = nextIndex;
|
||||
recallHistory = history;
|
||||
applyingRecall = true;
|
||||
lastRecalledValue = recalled;
|
||||
try { composer.dataset.odysseusRecallIndex = String(nextIndex); } catch (_) {}
|
||||
composer.value = recalled;
|
||||
try { composer.selectionStart = composer.selectionEnd = recalled.length; } catch (_) {}
|
||||
if (autoResize) autoResize(composer);
|
||||
debug('handled-down', { nextIndex, recalled, historyLength: history.length });
|
||||
setTimeout(() => { applyingRecall = false; }, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// ArrowUp owns prompt history in the chat composer. If the current text
|
||||
// is not already a recalled prompt, start from newest instead of letting
|
||||
// the browser move the caret inside the textarea.
|
||||
const nextIndex = currentIndex >= 0 ? Math.min(currentIndex + 1, history.length - 1) : 0;
|
||||
const recalled = history[nextIndex];
|
||||
if (!recalled) {
|
||||
debug('skip:no-recalled', { nextIndex, history });
|
||||
return;
|
||||
}
|
||||
|
||||
recallIndex = nextIndex;
|
||||
recallHistory = history;
|
||||
applyingRecall = true;
|
||||
lastRecalledValue = recalled;
|
||||
try { composer.dataset.odysseusRecallIndex = String(nextIndex); } catch (_) {}
|
||||
composer.value = recalled;
|
||||
try {
|
||||
composer.selectionStart = composer.selectionEnd = recalled.length;
|
||||
} catch (_) {}
|
||||
if (autoResize) autoResize(composer);
|
||||
});
|
||||
debug('handled', { nextIndex, recalled, historyLength: history.length });
|
||||
setTimeout(() => { applyingRecall = false; }, 0);
|
||||
}, true);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// generic fallback for that backend.
|
||||
|
||||
// Recipes carry two variants per entry:
|
||||
// variants.pip → install into the configured venv via uv/pip
|
||||
// variants.pip → install into the configured venv via pip/uv
|
||||
// variants.docker → pull the official container image
|
||||
//
|
||||
// The renderer prepends a `source <venv>/bin/activate` for the pip variant
|
||||
@@ -55,7 +55,89 @@ const _RECIPES = [
|
||||
label: 'Any MLX model',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['uv pip install -U mlx-lm'] },
|
||||
pip: { commands: ['python -m pip install -U mlx-lm'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
backend: 'mflux',
|
||||
label: 'mflux-compatible MLX image models',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['python -m pip install -U mflux fastapi uvicorn python-multipart'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
backend: 'boogu_image_mlx',
|
||||
label: 'MLX image models (Boogu)',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['python -m pip install -U git+https://github.com/xocialize/boogu-image-mlx.git fastapi uvicorn python-multipart pillow'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
backend: 'mlx_vlm',
|
||||
label: 'MLX image models (HiDream)',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['python -m pip install -U fastapi uvicorn python-multipart mlx mlx-vlm "transformers>=4.57.0,<6.0" huggingface_hub safetensors numpy pillow tqdm sentencepiece hf_transfer'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
backend: 'mlx_lama_swift',
|
||||
label: 'MLX image editing (LaMa / MI-GAN)',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: {
|
||||
commands: [
|
||||
'python -m pip install -U fastapi uvicorn python-multipart pillow huggingface_hub',
|
||||
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; test -d "$BRIDGE_DIR" || { echo "Run this from an Odysseus checkout that includes swift/odysseus-mlx-image-bridge, or set ODYSSEUS_ROOT=/path/to/odysseus."; exit 1; }',
|
||||
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; cd "$BRIDGE_DIR" && swift build -c release --product odysseus-mlx-inpaint',
|
||||
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; mkdir -p "$HOME/.local/bin" && cp "$BRIDGE_DIR/.build/release/odysseus-mlx-inpaint" "$HOME/.local/bin/odysseus-mlx-inpaint"',
|
||||
'MLX_METALLIB="$(python - <<\'PY\'\nimport pathlib, sys\ntry:\n import mlx\nexcept Exception as exc:\n raise SystemExit(f"mlx Python package is required for mlx.metallib: {exc}")\nroot = pathlib.Path(mlx.__file__).resolve().parent\nfor name in ("lib/mlx.metallib", "mlx.metallib", "lib/default.metallib", "default.metallib"):\n path = root / name\n if path.exists():\n print(path)\n break\nelse:\n raise SystemExit(f"No MLX metallib found under {root}")\nPY\n)"; mkdir -p "$HOME/.local/bin" && cp "$MLX_METALLIB" "$HOME/.local/bin/mlx.metallib" && cp "$MLX_METALLIB" "$HOME/.local/bin/default.metallib"',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
backend: 'mlx_ddcolor_swift',
|
||||
label: 'MLX image editing (DDColor)',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: {
|
||||
commands: [
|
||||
'python -m pip install -U fastapi uvicorn python-multipart pillow huggingface_hub',
|
||||
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; test -d "$BRIDGE_DIR" || { echo "Run this from an Odysseus checkout that includes swift/odysseus-mlx-image-bridge, or set ODYSSEUS_ROOT=/path/to/odysseus."; exit 1; }',
|
||||
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; cd "$BRIDGE_DIR" && swift build -c release --product odysseus-mlx-colorize',
|
||||
'BRIDGE_DIR="${ODYSSEUS_ROOT:-$PWD}/swift/odysseus-mlx-image-bridge"; mkdir -p "$HOME/.local/bin" && cp "$BRIDGE_DIR/.build/release/odysseus-mlx-colorize" "$HOME/.local/bin/odysseus-mlx-colorize"',
|
||||
'MLX_METALLIB="$(python - <<\'PY\'\nimport pathlib, sys\ntry:\n import mlx\nexcept Exception as exc:\n raise SystemExit(f"mlx Python package is required for mlx.metallib: {exc}")\nroot = pathlib.Path(mlx.__file__).resolve().parent\nfor name in ("lib/mlx.metallib", "mlx.metallib", "lib/default.metallib", "default.metallib"):\n path = root / name\n if path.exists():\n print(path)\n break\nelse:\n raise SystemExit(f"No MLX metallib found under {root}")\nPY\n)"; mkdir -p "$HOME/.local/bin" && cp "$MLX_METALLIB" "$HOME/.local/bin/mlx.metallib" && cp "$MLX_METALLIB" "$HOME/.local/bin/default.metallib"',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── Diffusers ────────────────────────────────────────────────────────
|
||||
{
|
||||
backend: 'diffusers',
|
||||
label: 'Any Diffusers image model',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['python -m pip install -U "diffusers[torch]" torchvision accelerate scipy python-multipart'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
backend: 'krea_diffusers',
|
||||
label: 'Latest Diffusers from Git',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['python -m pip install -U git+https://github.com/huggingface/diffusers.git torchvision accelerate scipy python-multipart'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
backend: 'sam_mask',
|
||||
label: 'SAM object mask tools',
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['python -m pip install -U torch torchvision transformers accelerate pillow'] },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -85,7 +167,7 @@ export function recipeCommands(recipe, variant) {
|
||||
// Backends we surface a recipe panel for. Other rows in the Dependencies
|
||||
// list keep the existing flat Install/Reinstall button without an expand
|
||||
// affordance.
|
||||
export const RECIPE_BACKENDS = new Set(['vllm', 'sglang', 'mlx_lm', 'llama_cpp']);
|
||||
export const RECIPE_BACKENDS = new Set(['vllm', 'sglang', 'mlx_lm', 'mflux', 'boogu_image_mlx', 'mlx_vlm', 'mlx_lama_swift', 'mlx_ddcolor_swift', 'diffusers', 'krea_diffusers', 'sam_mask', 'llama_cpp']);
|
||||
|
||||
// All recipe entries for a given backend, in catalog order. The first one
|
||||
// is the model-specific match (when present); the last is always the
|
||||
|
||||
@@ -261,17 +261,6 @@ async function _clearGpuProcesses(panel) {
|
||||
await _runQuickCmd(panel, _gpuCleanupCommand());
|
||||
}
|
||||
|
||||
// Infer the gated base repo that single-file checkpoints need configs from
|
||||
function _inferBaseRepo(text) {
|
||||
if (!text) return null;
|
||||
const t = text.toLowerCase();
|
||||
if (t.includes('sd3.5') || t.includes('stable-diffusion-3.5')) return 'stabilityai/stable-diffusion-3.5-large';
|
||||
if (t.includes('sd3') || t.includes('stable-diffusion-3')) return 'stabilityai/stable-diffusion-3-medium-diffusers';
|
||||
if (t.includes('flux')) return 'black-forest-labs/FLUX.1-schnell';
|
||||
if (t.includes('sdxl') || t.includes('stable-diffusion-xl')) return 'stabilityai/stable-diffusion-xl-base-1.0';
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ERROR_PATTERNS = [
|
||||
{
|
||||
pattern: /tmux is required|tmux.*not found|tmux:\s*command not found|command not found:\s*tmux|No such file or directory:\s*['"]?tmux/i,
|
||||
@@ -450,11 +439,10 @@ export const ERROR_PATTERNS = [
|
||||
message: 'Single-file checkpoint needs a base model for missing components (text encoder, VAE). The base model may be gated — accept the license and set your HF token.',
|
||||
fixes: [
|
||||
{ label: 'Request access to base model', action: (panel, _text) => {
|
||||
// Extract gated repo from error, or infer from model name
|
||||
const gated = _text && _text.match(/Access to model\s+(\S+)\s+is restricted/i);
|
||||
const base = _text && _text.match(/config=([^\s,)]+)/i);
|
||||
const model = _text && _text.match(/load model from\s+(\S+)/i);
|
||||
const repo = (gated && gated[1]) || (base && base[1]) || _inferBaseRepo(_text);
|
||||
const repo = (gated && gated[1]) || (base && base[1]);
|
||||
if (repo) window.open('https://huggingface.co/' + repo, '_blank');
|
||||
else if (model && model[1]) window.open('https://huggingface.co/' + model[1].replace(/[.]$/, ''), '_blank');
|
||||
}},
|
||||
@@ -464,13 +452,21 @@ export const ERROR_PATTERNS = [
|
||||
}},
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed/i,
|
||||
message: 'This image model uses a custom Diffusers pipeline that your launch environment does not know yet.',
|
||||
fixes: [
|
||||
{ label: 'Update image dependencies', action: () => _openCookbookDependencies('diffusers') },
|
||||
{ label: 'Copy diagnosis', action: (_panel, _text) => navigator.clipboard?.writeText(_text || '') },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /Entry Not Found.*model_index\.json|Could not load model.*Check diffusers/i,
|
||||
message: 'Single-file model — needs base config from a gated repo. Accept the license and set your HF token.',
|
||||
message: 'Single-file model may need an explicit base config. Add --single-file-config <repo_or_path> if the checkpoint is missing components.',
|
||||
fixes: [
|
||||
{ label: 'Request access to base model', action: (panel, _text) => {
|
||||
const gated = _text && _text.match(/Access to model\s+(\S+)\s+is restricted/i);
|
||||
const repo = (gated && gated[1]) || _inferBaseRepo(_text);
|
||||
const repo = gated && gated[1];
|
||||
if (repo) window.open('https://huggingface.co/' + repo, '_blank');
|
||||
else window.open('https://huggingface.co/settings/gated-repos', '_blank');
|
||||
}},
|
||||
@@ -560,6 +556,15 @@ export const ERROR_PATTERNS = [
|
||||
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install -U mlx-lm') },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /mflux-generate-qwen.*not found|mflux-generate.*not found|MLX image serving requires mflux|No module named ['"]?mflux/i,
|
||||
message: 'MLX image serving requires mflux on this Apple Silicon server.',
|
||||
suggestion: 'Suggested action: install mflux in the selected Python environment. This is for MLX image generation, not text MLX-LM.',
|
||||
fixes: [
|
||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('mflux') },
|
||||
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install -U mflux fastapi uvicorn') },
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /Unable to quantize model of type <class ['"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['"]>|QuantizedSwitchLinear/i,
|
||||
message: 'MLX-LM tried to quantize an already-quantized DeepSeek switch layer.',
|
||||
@@ -725,11 +730,11 @@ export const ERROR_PATTERNS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
pattern: /No module named ['"]?torch|No module named ['"]?diffusers|diffusers.*command not found/i,
|
||||
message: 'Diffusion serving needs PyTorch and diffusers. Install diffusers from Cookbook → Dependencies.',
|
||||
pattern: /No module named ['"]?torch|No module named ['"]?torchvision|No module named ['"]?diffusers|No module named ['"]?scipy|install scipy if you want to use beta sigmas|requires the Torchvision library|diffusers.*command not found/i,
|
||||
message: 'Diffusion serving needs PyTorch, Torchvision, Diffusers, Accelerate, and SciPy. Install Diffusers image deps from Cookbook → Dependencies.',
|
||||
fixes: [
|
||||
{ label: 'Open Dependencies', action: () => _openCookbookDependencies('diffusers') },
|
||||
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install "diffusers[torch]"') },
|
||||
{ label: 'Copy install command', action: () => _copyText('python3 -m pip install "diffusers[torch]" torchvision accelerate scipy python-multipart') },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+190
-32
@@ -40,7 +40,13 @@ import { openCookbookDependencies } from './cookbook-diagnosis.js';
|
||||
// Map a serve-backend code (vllm / sglang / llamacpp / mlx) → the package name
|
||||
// the Dependencies API reports. Used to look up "is this backend installed
|
||||
// on the target server" before firing a launch.
|
||||
const _BACKEND_PKG = { vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', mlx: 'mlx_lm' };
|
||||
const _BACKEND_PKG = { vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', mlx: 'mlx_lm', mlx_image: 'mflux', diffusers: 'diffusers' };
|
||||
function _dependencyPkgForModel(runBackend, modelName = '') {
|
||||
const nm = String(modelName || '').toLowerCase();
|
||||
if (runBackend === 'mlx_image' && nm.includes('boogu')) return 'boogu_image_mlx';
|
||||
if (runBackend === 'diffusers' && nm.includes('krea')) return 'krea_diffusers';
|
||||
return _BACKEND_PKG[runBackend];
|
||||
}
|
||||
|
||||
function _normalizeCookbookModelDir(dir) {
|
||||
const d = String(dir || '').replaceAll('\u2715', '').replaceAll('\u2716', '').trim();
|
||||
@@ -95,7 +101,7 @@ function _wireServerColorPicker(entry) {
|
||||
// the target server. Returns true if it's good to go, false if we should
|
||||
// block and route the user into Dependencies.
|
||||
async function _ensureBackendInstalled(runBackend, host, port, envPath, modelName) {
|
||||
const pkgName = _BACKEND_PKG[runBackend];
|
||||
const pkgName = _dependencyPkgForModel(runBackend, modelName);
|
||||
if (!pkgName) return true; // unknown backend — don't block
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
@@ -542,9 +548,31 @@ function _hwfitShowError(list, host, detail) {
|
||||
// needed. Ollama rows are merged into the main list (see _ensureOllamaLib +
|
||||
// _ollamaToHwfitRows below) so the filter handles all engines uniformly.
|
||||
function _applyEngineFilter(models) {
|
||||
let out = Array.isArray(models) ? models : [];
|
||||
const useCase = document.getElementById('hwfit-usecase')?.value || '';
|
||||
const srv = _serverByVal(_envState.remoteServerKey || _envState.remoteHost);
|
||||
const platform = String(srv?.platform || _envState.platform || _hwfitCache?.system?.platform || '').toLowerCase();
|
||||
const backend = String(_hwfitCache?.system?.backend || '').toLowerCase();
|
||||
const gpuName = String(_hwfitCache?.system?.gpu_name || '').toLowerCase();
|
||||
const isAppleTarget = !!(useCase === 'image_gen' && (
|
||||
platform === 'darwin'
|
||||
|| platform === 'macos'
|
||||
|| platform.includes('mac')
|
||||
|| backend === 'metal'
|
||||
|| backend === 'mps'
|
||||
|| backend === 'apple'
|
||||
|| gpuName.includes('apple')
|
||||
|| _hwfitCache?.system?.unified_memory
|
||||
));
|
||||
if (isAppleTarget) {
|
||||
out = out.filter(m => {
|
||||
const text = `${m?.name || ''} ${m?.id || ''} ${m?.provider || ''}`.toLowerCase();
|
||||
return text.includes('mlx-community/') || text.includes('mlx-community') || m?.mlx_only || m?.apple_ok;
|
||||
});
|
||||
}
|
||||
const want = document.getElementById('hwfit-engine')?.value || '';
|
||||
if (!want || !Array.isArray(models)) return models || [];
|
||||
return models.filter(m => {
|
||||
if (!want) return out;
|
||||
return out.filter(m => {
|
||||
try { return _detectBackend(m).backend === want; } catch { return true; }
|
||||
});
|
||||
}
|
||||
@@ -794,7 +822,7 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
||||
if (v !== '') params.set(k, v);
|
||||
});
|
||||
if (hasManualOrDismissed) params.set('_hw_override_ts', String(Date.now()));
|
||||
// Image models use a separate registry/endpoint
|
||||
// Image models use a separate registry/endpoint.
|
||||
const isImageMode = useCase === 'image_gen';
|
||||
if ((fresh || (_paintedFromCache && !search)) && !isImageMode) {
|
||||
params.set('refresh_catalog', '1'); // update HF-backed dynamic catalogs in the background
|
||||
@@ -840,7 +868,7 @@ export async function _hwfitFetch(fresh = false, opts = {}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Normalize image model fields to match LLM renderer expectations
|
||||
// Normalize image model fields to match LLM renderer expectations.
|
||||
if (isImageMode && data.models) {
|
||||
data.models = data.models.map(m => ({
|
||||
...m,
|
||||
@@ -1263,9 +1291,46 @@ export const _hwfitColumns = [
|
||||
{ key: null, label: 'Mode', cls: 'hwfit-c-mode' },
|
||||
];
|
||||
|
||||
function _sortHwfitRows(models) {
|
||||
const rows = Array.isArray(models) ? [...models] : [];
|
||||
const sortSel = document.getElementById('hwfit-sort');
|
||||
const sortKey = sortSel?.value || 'newest';
|
||||
const asc = sortSel?.dataset.reverse === '1';
|
||||
if (sortKey === 'fit') {
|
||||
const fitRank = { perfect: 4, good: 3, marginal: 2, too_tight: 1, no_fit: 0 };
|
||||
rows.sort((a, b) => {
|
||||
const ar = fitRank[a.fit_level] ?? -1;
|
||||
const br = fitRank[b.fit_level] ?? -1;
|
||||
if (ar !== br) return asc ? ar - br : br - ar;
|
||||
const as = Number(a.score) || 0;
|
||||
const bs = Number(b.score) || 0;
|
||||
return asc ? as - bs : bs - as;
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
if (sortKey === 'newest') {
|
||||
rows.sort((a, b) => {
|
||||
const ad = String(a.release_date || '');
|
||||
const bd = String(b.release_date || '');
|
||||
if (ad === bd) return 0;
|
||||
if (!ad) return 1;
|
||||
if (!bd) return -1;
|
||||
return asc ? (ad < bd ? -1 : 1) : (ad < bd ? 1 : -1);
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
const field = { score: 'score', vram: 'required_gb', speed: 'speed_tps', params: 'params_b', context: 'context' }[sortKey] || 'score';
|
||||
rows.sort((a, b) => {
|
||||
const av = Number(a[field]) || 0;
|
||||
const bv = Number(b[field]) || 0;
|
||||
return asc ? av - bv : bv - av;
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function _hwfitRenderList(el, models) {
|
||||
if (!el) return;
|
||||
models = models || [];
|
||||
models = _sortHwfitRows(models);
|
||||
if (!models.length) {
|
||||
// Disambiguate WHY the list is empty so capable servers don't read as "too weak":
|
||||
// active filters vs. a likely under-reported probe vs. genuinely low hardware.
|
||||
@@ -1570,7 +1635,9 @@ export function _expandModelRow(row, modelData) {
|
||||
html += `</div>`;
|
||||
html += `<div class="hwfit-panel-actions">`;
|
||||
html += `<button class="cookbook-btn hwfit-dl-btn">Download</button>`;
|
||||
if (!modelData.is_image_gen) {
|
||||
if (modelData.is_image_gen) {
|
||||
html += `<button class="cookbook-btn cookbook-run-btn hwfit-quickrun-btn" title="Download + run as an image endpoint">Run Image</button>`;
|
||||
} else {
|
||||
html += `<button class="cookbook-btn cookbook-run-btn hwfit-quickrun-btn" title="Download + launch with smart defaults">Run</button>`;
|
||||
html += `<button class="cookbook-btn hwfit-serve-expand-btn" title="Configure & serve">Configure</button>`;
|
||||
}
|
||||
@@ -1653,30 +1720,34 @@ export function _expandModelRow(row, modelData) {
|
||||
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?.(
|
||||
`${_clashing.length} model${_clashing.length === 1 ? '' : 's'} on port ${_qrPort} (${_names.join(', ')}). Stop it and launch this one?`,
|
||||
{ confirmText: 'Stop & launch', cancelText: 'Cancel' }
|
||||
const _choice = await window.styledConfirm?.(
|
||||
`${_clashing.length} model${_clashing.length === 1 ? '' : 's'} on port ${_qrPort} (${_names.join(', ')}). Stop it first, or launch anyway?`,
|
||||
{ title: `Port ${_qrPort} in use`, confirmText: 'Stop & launch', alternateText: 'Launch anyway', cancelText: 'Cancel' }
|
||||
);
|
||||
if (!_ok) return;
|
||||
quickRunBtn.disabled = true;
|
||||
quickRunBtn.textContent = 'Stopping…';
|
||||
for (const t of _clashing) {
|
||||
try {
|
||||
const _taskEl = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
|
||||
const _stopBtn = _taskEl?.querySelector('.cookbook-task-action-stop');
|
||||
if (_stopBtn) {
|
||||
_stopBtn.click();
|
||||
} else {
|
||||
await fetch('/api/shell/exec', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxGracefulKill(t) }),
|
||||
});
|
||||
if (!_choice) return;
|
||||
if (_choice === 'alternate') {
|
||||
uiModule.showToast('Launching anyway. If the port is already occupied, the new serve may fail.', 6000);
|
||||
} else {
|
||||
quickRunBtn.disabled = true;
|
||||
quickRunBtn.textContent = 'Stopping…';
|
||||
for (const t of _clashing) {
|
||||
try {
|
||||
const _taskEl = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
|
||||
const _stopBtn = _taskEl?.querySelector('.cookbook-task-action-stop');
|
||||
if (_stopBtn) {
|
||||
_stopBtn.click();
|
||||
} else {
|
||||
await fetch('/api/shell/exec', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _tmuxGracefulKill(t) }),
|
||||
});
|
||||
}
|
||||
} catch (_killErr) { /* best-effort */ }
|
||||
}
|
||||
} catch (_killErr) { /* best-effort */ }
|
||||
await new Promise(r => setTimeout(r, 2500));
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 2500));
|
||||
}
|
||||
} catch (_e) { /* best-effort */ }
|
||||
|
||||
@@ -1808,9 +1879,12 @@ export function _expandModelRow(row, modelData) {
|
||||
cmd += ` --context-length ${maxCtx}`;
|
||||
cmd += ` --mem-fraction-static ${gpuUtil}`;
|
||||
cmd += ' --trust-remote-code';
|
||||
} else if (runBackend === 'mlx_image') {
|
||||
const bindHost = host ? '0.0.0.0' : '127.0.0.1';
|
||||
cmd = `python3 scripts/mlx_image_server.py --model ${_shellQuote(modelData.name)} --host ${bindHost} --port ${port} --steps 20`;
|
||||
} else if (runBackend === 'mlx') {
|
||||
const bindHost = host ? '0.0.0.0' : '127.0.0.1';
|
||||
cmd = `python3 -m mlx_lm.server --model ${_shellQuote(modelData.name)} --host ${bindHost} --port ${port}`;
|
||||
cmd = `python3 -m mlx_lm.server --model ${_shellQuote(modelData.name)} --host ${bindHost} --port ${port} --max-tokens ${maxCtx}`;
|
||||
} 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)`;
|
||||
@@ -1852,7 +1926,7 @@ export function _expandModelRow(row, modelData) {
|
||||
);
|
||||
if (!_ok) {
|
||||
quickRunBtn.disabled = false;
|
||||
quickRunBtn.textContent = 'Run';
|
||||
quickRunBtn.textContent = modelData.is_image_gen ? 'Run Image' : 'Run';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1889,7 +1963,7 @@ export function _expandModelRow(row, modelData) {
|
||||
uiModule.showError('Launch failed: ' + e.message);
|
||||
}
|
||||
quickRunBtn.disabled = false;
|
||||
quickRunBtn.textContent = 'Run';
|
||||
quickRunBtn.textContent = modelData.is_image_gen ? 'Run Image' : 'Run';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1938,6 +2012,89 @@ function _hwfitEngineGlyph(value) {
|
||||
return _HWFIT_ENGINE_GLYPHS[value] || _HWFIT_ENGINE_GLYPHS[''];
|
||||
}
|
||||
|
||||
const _HWFIT_USECASE_GLYPHS = {
|
||||
general: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>',
|
||||
multimodal: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>',
|
||||
image_gen: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><path d="M21 15l-5-5L5 21"></path></svg>',
|
||||
};
|
||||
|
||||
function _hwfitUsecaseGlyph(value) {
|
||||
return _HWFIT_USECASE_GLYPHS[value] || _HWFIT_USECASE_GLYPHS.general;
|
||||
}
|
||||
|
||||
function _bindHwfitUsecasePicker(usecase) {
|
||||
const wrap = usecase?.closest('.hwfit-usecase-wrap');
|
||||
const btn = wrap?.querySelector('[data-hwfit-usecase-btn]');
|
||||
const menu = wrap?.querySelector('[data-hwfit-usecase-menu]');
|
||||
const icon = wrap?.querySelector('[data-hwfit-usecase-icon]');
|
||||
const label = wrap?.querySelector('[data-hwfit-usecase-label]');
|
||||
if (!usecase || !wrap || !btn || !menu) return;
|
||||
usecase.querySelectorAll('option[value="video_gen"]').forEach((opt) => opt.remove());
|
||||
menu.querySelectorAll('[data-hwfit-usecase-value="video_gen"], .hwfit-usecase-item').forEach((item) => {
|
||||
if (item.dataset.hwfitUsecaseValue === 'video_gen' || item.textContent?.trim() === 'Video') item.remove();
|
||||
});
|
||||
if (usecase.value === 'video_gen') {
|
||||
usecase.value = 'general';
|
||||
usecase.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
if (wrap.dataset.usecasePickerBound) return;
|
||||
wrap.dataset.usecasePickerBound = '1';
|
||||
|
||||
const setOpen = (open) => {
|
||||
menu.hidden = !open;
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
};
|
||||
const currentLabel = () => {
|
||||
const opt = Array.from(usecase.options).find((o) => o.value === usecase.value);
|
||||
return opt?.textContent || 'Standard';
|
||||
};
|
||||
const syncButton = () => {
|
||||
if (label) label.textContent = currentLabel();
|
||||
if (icon) icon.innerHTML = _hwfitUsecaseGlyph(usecase.value);
|
||||
menu.querySelectorAll('[data-hwfit-usecase-value]').forEach((item) => {
|
||||
const active = item.dataset.hwfitUsecaseValue === usecase.value;
|
||||
item.classList.toggle('active', active);
|
||||
item.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
};
|
||||
const renderMenu = () => {
|
||||
menu.innerHTML = Array.from(usecase.options).filter((opt) => opt.value !== 'video_gen').map((opt) => (
|
||||
`<button type="button" role="option" class="hwfit-usecase-item" data-hwfit-usecase-value="${opt.value}">`
|
||||
+ `<span class="hwfit-usecase-item-icon" aria-hidden="true">${_hwfitUsecaseGlyph(opt.value)}</span>`
|
||||
+ `<span class="hwfit-usecase-item-label">${opt.textContent}</span>`
|
||||
+ '</button>'
|
||||
)).join('');
|
||||
menu.querySelectorAll('[data-hwfit-usecase-value]').forEach((item) => {
|
||||
item.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const next = item.dataset.hwfitUsecaseValue || 'general';
|
||||
if (usecase.value !== next) {
|
||||
usecase.value = next;
|
||||
usecase.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
syncButton();
|
||||
setOpen(false);
|
||||
});
|
||||
});
|
||||
syncButton();
|
||||
};
|
||||
|
||||
btn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setOpen(menu.hidden);
|
||||
});
|
||||
usecase.addEventListener('change', syncButton);
|
||||
document.addEventListener('click', (ev) => {
|
||||
if (!wrap.contains(ev.target)) setOpen(false);
|
||||
});
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Escape') setOpen(false);
|
||||
});
|
||||
renderMenu();
|
||||
}
|
||||
|
||||
function _bindHwfitEnginePicker(engine) {
|
||||
const wrap = engine?.closest('.hwfit-engine-wrap');
|
||||
const btn = wrap?.querySelector('[data-hwfit-engine-btn]');
|
||||
@@ -2011,6 +2168,7 @@ export function _hwfitInit() {
|
||||
const search = document.getElementById('hwfit-search');
|
||||
const remote = document.getElementById('hwfit-host');
|
||||
_syncCtxControl();
|
||||
if (uc) _bindHwfitUsecasePicker(uc);
|
||||
if (uc) uc.addEventListener('change', () => _hwfitFetch());
|
||||
if (sort) sort.addEventListener('change', () => _hwfitFetch());
|
||||
if (qpref) qpref.addEventListener('change', () => _hwfitFetch());
|
||||
|
||||
+262
-91
@@ -504,6 +504,14 @@ export function _detectBackend(model) {
|
||||
const isRocm = sysBackend === 'rocm';
|
||||
const isAppleSilicon = ['metal', 'mps', 'apple'].includes(sysBackend);
|
||||
const _nm = `${model.repo_id || ''} ${model.path || ''} ${model.name || ''}`.toLowerCase();
|
||||
const isImageModel = !!(model.is_image_gen || model.is_diffusion || model._tag === 'image');
|
||||
// Image gen models → diffusers
|
||||
if (isImageModel) {
|
||||
if (/\bmlx\b|mlx-|_mlx|mlx-community\//i.test(_nm) || q.startsWith('MLX') || model.mlx_only) {
|
||||
return { backend: 'mlx_image', label: 'MLX Image' };
|
||||
}
|
||||
return { backend: 'diffusers', label: 'Diffusers' };
|
||||
}
|
||||
if (/\bmlx\b|mlx-|_mlx/i.test(_nm) || q.startsWith('MLX')) {
|
||||
return { backend: 'mlx', label: 'MLX' };
|
||||
}
|
||||
@@ -512,11 +520,6 @@ export function _detectBackend(model) {
|
||||
&& model.gguf_files.some(f => f && typeof f.rel_path === 'string' && /\.gguf$/i.test(f.rel_path));
|
||||
const isGgufLike = model.is_gguf || hasGgufFile || /^Q[2-8]/.test(q) || /^IQ/.test(q) || q === 'GGUF' || _nm.includes('gguf');
|
||||
|
||||
// Image gen models → diffusers
|
||||
if (model.is_image_gen || model.is_diffusion || model._tag === 'image') {
|
||||
return { backend: 'diffusers', label: 'Diffusers' };
|
||||
}
|
||||
|
||||
// AWQ / GPTQ / FP8 are safetensors GPU-serving formats. Never route them
|
||||
// through llama.cpp/Ollama just because the host is Mac/Windows; those engines
|
||||
// need GGUF. The UI will warn/block on Metal where vLLM/SGLang aren't viable.
|
||||
@@ -558,6 +561,18 @@ export function _shellQuote(value) {
|
||||
return "'" + String(value ?? '').replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
function _listField(value) {
|
||||
return String(value || '')
|
||||
.split(/[\n,]+/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function _numField(value) {
|
||||
const s = String(value || '').trim();
|
||||
return /^-?\d+(?:\.\d+)?$/.test(s) ? s : '';
|
||||
}
|
||||
|
||||
export function _psQuote(value) {
|
||||
return "'" + String(value ?? '').replace(/'/g, "''") + "'";
|
||||
}
|
||||
@@ -709,6 +724,10 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
const _kv = (f.vllm_kv_cache_dtype ?? '').toString().trim();
|
||||
if (_kv === 'fp8') cmd += ' --kv-cache-dtype fp8';
|
||||
if (f.max_seqs && f.max_seqs.toString().trim()) cmd += ` --max-num-seqs ${f.max_seqs.toString().trim()}`;
|
||||
const _vllmLoraModules = _listField(f.vllm_lora_modules);
|
||||
if (_vllmLoraModules.length) {
|
||||
cmd += ` --enable-lora --lora-modules ${_vllmLoraModules.map(_shellQuote).join(' ')}`;
|
||||
}
|
||||
if (f.enforce_eager) cmd += ' --enforce-eager';
|
||||
if (f.trust_remote) cmd += ' --trust-remote-code';
|
||||
if (f.prefix_cache) cmd += ' --enable-prefix-caching';
|
||||
@@ -917,7 +936,7 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
// Trailing GGUF_FILE is optional; helper picks the first match if empty.
|
||||
cmd = `docker exec ollama-test ollama-import ${modelName} ${_name} ${_ctx}${_file ? ' ' + _file : ''}`;
|
||||
} else if (!modelName.includes('/') && modelName) {
|
||||
// Already-pulled Ollama tag (e.g. `qwen2.5:7b`). On kierkegaard the
|
||||
// Already-pulled Ollama tag (e.g. `qwen2.5:7b`). On remote hosts the
|
||||
// runtime is the ROCm Ollama sidecar; this quick command verifies the
|
||||
// tag exists, then the backend auto-registers http://host.docker.internal:11434/v1.
|
||||
cmd = `docker exec ollama-rocm ollama show ${modelName}`;
|
||||
@@ -930,22 +949,54 @@ export function _buildServeCmd(f, modelName, backend) {
|
||||
const gpuStr = f.gpus?.trim();
|
||||
cmd += _gpuEnvPrefix(gpuStr);
|
||||
const diffusersPy = _isWindows() ? 'python' : _py3Bin;
|
||||
cmd += `${diffusersPy} scripts/diffusion_server.py --model ${modelName} --port ${f.port || '8100'}`;
|
||||
const diffHost = f.host ? '0.0.0.0' : '127.0.0.1';
|
||||
cmd += `${diffusersPy} scripts/diffusion_server.py --model ${modelName} --host ${diffHost} --port ${f.port || '8100'}`;
|
||||
if (f.host) {
|
||||
const allowedHost = String(f.host || '').split('@').pop().split(':')[0].trim();
|
||||
if (allowedHost) cmd += ` --allowed-host ${allowedHost}`;
|
||||
}
|
||||
if (f.diff_dtype && f.diff_dtype !== 'bfloat16') cmd += ` --dtype ${f.diff_dtype}`;
|
||||
if (f.diff_device_map && f.diff_device_map !== 'balanced') cmd += ` --device-map ${f.diff_device_map}`;
|
||||
if (f.diff_steps) cmd += ` --steps ${f.diff_steps}`;
|
||||
if (f.diff_guidance_scale) cmd += ` --guidance-scale ${_numField(f.diff_guidance_scale) || f.diff_guidance_scale}`;
|
||||
if (String(f.diff_negative_prompt || '').trim()) cmd += ` --negative-prompt ${_shellQuote(String(f.diff_negative_prompt || '').trim())}`;
|
||||
if (f.diff_width) cmd += ` --width ${f.diff_width}`;
|
||||
if (f.diff_height) cmd += ` --height ${f.diff_height}`;
|
||||
const _diffLoras = _listField(f.diff_lora);
|
||||
if (_diffLoras.length) cmd += ` --lora ${_shellQuote(_diffLoras.join(','))}`;
|
||||
const _diffLoraScale = _numField(f.diff_lora_scale);
|
||||
if (_diffLoraScale) cmd += ` --lora-scale ${_diffLoraScale}`;
|
||||
if (f.diff_offload) cmd += ' --cpu-offload';
|
||||
if (f.diff_attention_slicing) cmd += ' --attention-slicing';
|
||||
if (f.diff_vae_slicing) cmd += ' --vae-slicing';
|
||||
if (f.diff_harmonize_gpu) cmd += ` --harmonize-gpu ${f.diff_harmonize_gpu}`;
|
||||
} else if (backend === 'mlx_image') {
|
||||
const mlxPy = _isWindows() ? 'python' : _py3Bin;
|
||||
const mlxHost = f.host ? '0.0.0.0' : '127.0.0.1';
|
||||
cmd += `${mlxPy} scripts/mlx_image_server.py --model ${_shellQuote(modelName)} --host ${mlxHost} --port ${f.port || '8100'}`;
|
||||
if (f.diff_steps) cmd += ` --steps ${f.diff_steps}`;
|
||||
if (f.diff_width) cmd += ` --width ${f.diff_width}`;
|
||||
if (f.diff_height) cmd += ` --height ${f.diff_height}`;
|
||||
const _mlxBaseModel = String(f.mlx_base_model || '').trim();
|
||||
if (_mlxBaseModel) cmd += ` --base-model ${_shellQuote(_mlxBaseModel)}`;
|
||||
const _mlxLoraStyle = String(f.mlx_lora_style || '').trim();
|
||||
if (_mlxLoraStyle) cmd += ` --lora-style ${_shellQuote(_mlxLoraStyle)}`;
|
||||
const _mlxLoraPaths = _listField(f.mlx_lora_paths);
|
||||
if (_mlxLoraPaths.length) cmd += ` --lora-paths ${_mlxLoraPaths.map(_shellQuote).join(' ')}`;
|
||||
const _mlxLoraScales = _listField(f.mlx_lora_scales).filter(s => /^-?\d+(?:\.\d+)?$/.test(s));
|
||||
if (_mlxLoraScales.length) cmd += ` --lora-scales ${_mlxLoraScales.map(_shellQuote).join(' ')}`;
|
||||
} else if (backend === 'mlx') {
|
||||
const mlxPy = _isWindows() ? 'python' : _py3Bin;
|
||||
const mlxHost = f.host ? '0.0.0.0' : '127.0.0.1';
|
||||
cmd += `${mlxPy} -m mlx_lm.server --model ${_shellQuote(modelName)} --host ${mlxHost} --port ${f.port || '8080'}`;
|
||||
const mlxMaxTokens = String(f.ctx || '').trim();
|
||||
if (/minimax|mini-max/i.test(modelName)) {
|
||||
cmd += ' --temp 0.7 --top-p 0.9 --max-tokens 2048';
|
||||
cmd += ` --temp 0.7 --top-p 0.9 --max-tokens ${mlxMaxTokens || '2048'}`;
|
||||
} else if (/^\d+$/.test(mlxMaxTokens)) {
|
||||
// MLX-LM server has no vLLM-style --context-length flag. The closest
|
||||
// server-side request budget it exposes is --max-tokens, so wire the
|
||||
// Cookbook Context/Auto control there for MLX launches.
|
||||
cmd += ` --max-tokens ${mlxMaxTokens}`;
|
||||
}
|
||||
}
|
||||
return cmd;
|
||||
@@ -1041,19 +1092,20 @@ async function _fetchDependencies() {
|
||||
try {
|
||||
// Resolve the target server from the deps dropdown so remote-target
|
||||
// packages are checked on THAT server's venv (not just the local host).
|
||||
let _depHost = '', _depPort = '', _depVenv = '';
|
||||
let _depHost = '', _depPort = '', _depVenv = '', _depPlatform = '';
|
||||
const _dsel = document.getElementById('hwfit-deps-server');
|
||||
const _depSrv = _dsel && _dsel.value !== 'local' ? _serverByVal(_dsel.value) : null;
|
||||
if (_depSrv) {
|
||||
_depHost = _depSrv.host || ''; _depPort = _depSrv.port || ''; _depVenv = _depSrv.envPath || '';
|
||||
_depHost = _depSrv.host || ''; _depPort = _depSrv.port || ''; _depVenv = _depSrv.envPath || ''; _depPlatform = _depSrv.platform || '';
|
||||
} else if (_envState.remoteHost) {
|
||||
_depHost = _envState.remoteHost; _depPort = _getPort(_envState.remoteHost) || ''; _depVenv = _envState.envPath || '';
|
||||
_depHost = _envState.remoteHost; _depPort = _getPort(_envState.remoteHost) || ''; _depVenv = _envState.envPath || ''; _depPlatform = _envState.platform || '';
|
||||
}
|
||||
const _pkgParams = new URLSearchParams();
|
||||
if (_depHost) {
|
||||
_pkgParams.set('host', _depHost);
|
||||
if (_depPort) _pkgParams.set('ssh_port', _depPort);
|
||||
if (_depVenv) _pkgParams.set('venv', _depVenv);
|
||||
if (_depPlatform) _pkgParams.set('platform', _depPlatform);
|
||||
}
|
||||
// Pass the detected backend so the server can build a single
|
||||
// OS+backend-aware install command per row (e.g. add nvidia-cuda-toolkit
|
||||
@@ -1063,6 +1115,13 @@ async function _fetchDependencies() {
|
||||
if (_depBackend && _hwfitCache?._scannedHost === _depHost) {
|
||||
_pkgParams.set('backend', _depBackend);
|
||||
}
|
||||
if (_cachedModelIds && _cachedModelIds.size) {
|
||||
const _hint = Array.from(_cachedModelIds)
|
||||
.filter(id => /krea/i.test(String(id || '')))
|
||||
.slice(0, 20)
|
||||
.join(',');
|
||||
if (_hint) _pkgParams.set('model_hint', _hint);
|
||||
}
|
||||
const resp = await fetch('/api/cookbook/packages' + (_pkgParams.toString() ? '?' + _pkgParams.toString() : ''));
|
||||
const data = await resp.json();
|
||||
const pkgs = data.packages || [];
|
||||
@@ -1098,9 +1157,13 @@ async function _fetchDependencies() {
|
||||
vllm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
|
||||
sglang: '<span aria-hidden="true" style="display:block;width:13px;height:13px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
|
||||
mlx_lm: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"/><path d="M16 6v12"/><path d="M20 6v12"/></svg>',
|
||||
mflux: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>',
|
||||
boogu_image_mlx: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 17c2.5-4 4.5-4 7 0"/><circle cx="9" cy="9" r="1"/><circle cx="15" cy="9" r="1"/></svg>',
|
||||
llama_cpp: '<svg width="13" height="13" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"/><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"/></svg>',
|
||||
ollama: '<img src="/static/icons/ollama-mark-crop.png" alt="" aria-hidden="true" width="13" height="13" style="display:block;width:13px;height:13px;object-fit:contain;" />',
|
||||
diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
|
||||
krea_diffusers: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 19V5"/><path d="M4 12h4"/><path d="M12 5l-7 7 7 7"/><path d="M14 19l3-14 3 14"/><path d="M15.3 13h3.4"/></svg>',
|
||||
sam_mask: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>',
|
||||
};
|
||||
const _depGlyphHtml = (name) => {
|
||||
const g = _DEP_GLYPHS[name];
|
||||
@@ -1143,23 +1206,6 @@ async function _fetchDependencies() {
|
||||
const _buildDepsBtn = _bdm.length
|
||||
? `<button type="button" class="cookbook-dep-tag cookbook-dep-install cookbook-dep-install-sysdeps" data-dep-sysdeps="${esc(_bdm.join(','))}" data-dep-target="${isLocal ? 'local' : 'remote'}" title="Install ${esc(_bdm.join(', '))} via the OS package manager on this target (requires passwordless sudo or root).">Install build deps</button>`
|
||||
: '';
|
||||
// Render the target-specific install command as a compact mono box
|
||||
// when the server resolved it (target's /etc/os-release was readable
|
||||
// AND the backend is known). The box doubles as the source of truth
|
||||
// for the "Install build deps" button's failure toast — both surfaces
|
||||
// show the same string for the same target.
|
||||
const _instCmd = (_bdm.length && pkg.install_cmd_for_target) ? String(pkg.install_cmd_for_target) : '';
|
||||
const _instCmdOs = pkg.install_cmd_os ? String(pkg.install_cmd_os) : '';
|
||||
const _instCmdBe = pkg.install_cmd_backend ? String(pkg.install_cmd_backend) : '';
|
||||
const _instLabel = (_instCmdOs && _instCmdBe) ? `${_instCmdOs} + ${_instCmdBe}` : (_instCmdOs || _instCmdBe || 'this target');
|
||||
const _instCmdBox = _instCmd
|
||||
? `<div class="cookbook-dep-install-cmd" data-dep-cmd="${esc(_instCmd)}" style="margin-top:6px;font-size:10.5px;opacity:0.85;">`
|
||||
+ `<div style="opacity:0.65;margin-bottom:2px;">Install on ${esc(_instLabel)}:</div>`
|
||||
+ `<div style="display:flex;gap:4px;align-items:stretch;">`
|
||||
+ `<code style="flex:1;padding:4px 6px;background:color-mix(in srgb, var(--fg) 6%, transparent);border:1px solid var(--border);border-radius:4px;font-family:var(--mono, ui-monospace, monospace);font-size:10.5px;white-space:pre-wrap;word-break:break-all;">${esc(_instCmd)}</code>`
|
||||
+ `<button type="button" class="cookbook-dep-cmd-copy" data-dep-cmd-copy="${esc(_instCmd)}" title="Copy install command" style="padding:2px 8px;font-size:10px;border:1px solid var(--border);border-radius:4px;background:none;cursor:pointer;color:var(--fg-muted);">Copy</button>`
|
||||
+ `</div></div>`
|
||||
: '';
|
||||
// Partial-state row (replaces the cryptic yellow "Partial ▾" tag).
|
||||
// Renders inline as a yellow banner with two clear actions: one-tap
|
||||
// Install (runs the reinstall in cookbook) or Copy command (paste
|
||||
@@ -1179,7 +1225,6 @@ async function _fetchDependencies() {
|
||||
+ `<div class="memory-item-meta" style="font-size:10px;opacity:0.5;margin-top:2px;">${esc(pkg.desc)}</div>`
|
||||
+ note
|
||||
+ updateNote
|
||||
+ _instCmdBox
|
||||
+ `</div>`
|
||||
+ _rebuildBtn
|
||||
+ _buildDepsBtn
|
||||
@@ -1194,13 +1239,21 @@ async function _fetchDependencies() {
|
||||
// the user sees a paste-ready sequence; Run keeps using env_prefix to
|
||||
// activate the same venv before the pip command. Docker variant skips
|
||||
// the activate line — `docker pull` doesn't need a venv.
|
||||
function _recipeRuntimeCommands(commands, variant) {
|
||||
if (variant === 'docker') return commands;
|
||||
const envPath = (_envState.envPath || '').replace(/\/+$/, '');
|
||||
if (_envState.env !== 'venv' || !envPath) return commands;
|
||||
const py = _shellQuote(`${envPath}/bin/python3`);
|
||||
return commands.map(cmd => String(cmd || '').replace(/^python(\s+-m\s+pip\b)/, `${py}$1`));
|
||||
}
|
||||
function _recipeDisplayText(commands, variant) {
|
||||
const runtimeCommands = _recipeRuntimeCommands(commands, variant);
|
||||
if (variant === 'docker') return commands.join('\n');
|
||||
const envPath = (_envState.envPath || '').replace(/\/+$/, '');
|
||||
const activate = envPath
|
||||
? `source ${envPath}${envPath.endsWith('/bin/activate') ? '' : '/bin/activate'}`
|
||||
: '# (activate your venv first)';
|
||||
return [activate, ...commands].join('\n');
|
||||
return [activate, ...runtimeCommands].join('\n');
|
||||
}
|
||||
|
||||
// Per-backend recipe panel (model picker + commands + Copy/Run).
|
||||
@@ -1224,18 +1277,19 @@ async function _fetchDependencies() {
|
||||
const initial = pickRecipe(backend, '') || candidates[0];
|
||||
const initialVariant = RECIPE_DEFAULT_VARIANT;
|
||||
const initialCmds = recipeCommands(initial, initialVariant);
|
||||
const initialRuntimeCmds = _recipeRuntimeCommands(initialCmds, initialVariant);
|
||||
const rightActive = initialVariant === 'docker' ? ' mode-right' : '';
|
||||
return `<div class="cookbook-dep-recipe-panel" data-dep-recipe-panel="${esc(backend)}" data-dep-recipe-active-variant="${esc(initialVariant)}" style="display:none;margin:-4px 0 8px;padding:8px 12px 10px;background:rgba(0,0,0,0.04);border:1px solid var(--border);border-top:none;border-radius:0 0 6px 6px;">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
|
||||
<span style="font-size:11px;opacity:0.75;flex-shrink:0;">Serving which model?</span>
|
||||
<select class="settings-select cookbook-dep-recipe-pick" data-dep-recipe-pick="${esc(backend)}" style="flex:1;font-size:11px;padding:3px 6px;">${opts}</select>
|
||||
<div class="mode-toggle${rightActive}" data-dep-recipe-variants="${esc(backend)}" style="flex-shrink:0;">
|
||||
<button type="button" class="mode-toggle-btn${initialVariant === 'pip' ? ' active' : ''}" data-dep-recipe-variant="${esc(backend)}" data-variant="pip" aria-pressed="${initialVariant === 'pip'}">Pip/uv</button>
|
||||
<button type="button" class="mode-toggle-btn${initialVariant === 'pip' ? ' active' : ''}" data-dep-recipe-variant="${esc(backend)}" data-variant="pip" aria-pressed="${initialVariant === 'pip'}">Pip</button>
|
||||
<button type="button" class="mode-toggle-btn${initialVariant === 'docker' ? ' active' : ''}" data-dep-recipe-variant="${esc(backend)}" data-variant="docker" aria-pressed="${initialVariant === 'docker'}">Docker</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="position:relative;">
|
||||
<pre class="cookbook-dep-recipe-cmds" data-dep-recipe-cmds="${esc(backend)}" data-dep-recipe-install="${esc(initialCmds.join('\n'))}" style="margin:0;padding:8px 36px 8px 10px;background:rgba(0,0,0,0.08);border-radius:4px;font-size:11px;line-height:1.5;overflow-x:auto;white-space:pre;">${esc(_recipeDisplayText(initialCmds, initialVariant))}</pre>
|
||||
<pre class="cookbook-dep-recipe-cmds" data-dep-recipe-cmds="${esc(backend)}" data-dep-recipe-install="${esc(initialRuntimeCmds.join('\n'))}" style="margin:0;padding:8px 36px 8px 10px;background:rgba(0,0,0,0.08);border-radius:4px;font-size:11px;line-height:1.5;overflow-x:auto;white-space:pre;">${esc(_recipeDisplayText(initialCmds, initialVariant))}</pre>
|
||||
<button type="button" id="recipe-copy-${esc(backend)}" class="cookbook-dep-recipe-copy" data-dep-recipe-copy="${esc(backend)}" title="Copy" aria-label="Copy" style="position:absolute;top:6px;right:6px;padding:3px 5px;background:none;border:none;color:inherit;opacity:0.7;cursor:pointer;display:inline-flex;align-items:center;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end;margin-top:6px;">
|
||||
@@ -1244,18 +1298,99 @@ async function _fetchDependencies() {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const _rowsHtml = (items) => items.map(_depRow).join('');
|
||||
const _sectionHeader = (title, note) =>
|
||||
`<div class="cookbook-dep-section"><span class="cookbook-dep-section-title">${title}</span><span class="cookbook-dep-section-note">${note}</span></div>`;
|
||||
const _section = (title, note, items) =>
|
||||
items.length
|
||||
? `<div class="cookbook-dep-section"><span class="cookbook-dep-section-title">${title}</span><span class="cookbook-dep-section-note">${note}</span></div>` + items.map(_depRow).join('')
|
||||
: '';
|
||||
items.length ? _sectionHeader(title, note) + _rowsHtml(items) : '';
|
||||
const _pkgOrder = {
|
||||
System: ['tmux', 'docker'],
|
||||
Tools: ['hf_transfer'],
|
||||
LLM: ['llama_cpp', 'sglang', 'vllm', 'mlx_lm'],
|
||||
Image: ['diffusers', 'krea_diffusers', 'transformers', 'sam_mask', 'mflux', 'boogu_image_mlx', 'mlx_vlm'],
|
||||
};
|
||||
const _sortDeps = (items, category) => {
|
||||
const order = _pkgOrder[category] || [];
|
||||
return [...items].sort((a, b) => {
|
||||
const ai = order.indexOf(a.name);
|
||||
const bi = order.indexOf(b.name);
|
||||
const ar = ai === -1 ? 999 : ai;
|
||||
const br = bi === -1 ? 999 : bi;
|
||||
return ar - br || String(a.name || '').localeCompare(String(b.name || ''));
|
||||
});
|
||||
};
|
||||
const _serverDepsHtml = (items) => {
|
||||
const byCat = new Map();
|
||||
for (const item of items) {
|
||||
const cat = item.category || 'Other';
|
||||
if (!byCat.has(cat)) byCat.set(cat, []);
|
||||
byCat.get(cat).push(item);
|
||||
}
|
||||
const parts = [];
|
||||
const order = ['System', 'Tools', 'Image', 'LLM', 'Audio', 'Other'];
|
||||
for (const cat of order) {
|
||||
const catItems = _sortDeps(byCat.get(cat) || [], cat);
|
||||
if (!catItems.length) continue;
|
||||
if (cat === 'Image') {
|
||||
const mlxNames = new Set(['mflux', 'boogu_image_mlx', 'mlx_vlm']);
|
||||
const general = catItems.filter(p => !mlxNames.has(p.name));
|
||||
const mlx = catItems.filter(p => mlxNames.has(p.name));
|
||||
parts.push(_sectionHeader('Image', 'Diffusers and shared image tooling.'));
|
||||
if (general.length) parts.push(_rowsHtml(general));
|
||||
if (mlx.length) {
|
||||
parts.push(
|
||||
`<div class="cookbook-dep-subgroup">`
|
||||
+ `<div class="cookbook-dep-subgroup-title"><span>MLX image runtimes</span><em>Apple Silicon only</em></div>`
|
||||
+ _rowsHtml(mlx)
|
||||
+ `</div>`
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const note = cat === 'System'
|
||||
? 'OS tools needed for background tasks.'
|
||||
: cat === 'LLM'
|
||||
? 'Text model serving engines and download helpers.'
|
||||
: cat === 'Tools'
|
||||
? 'Browser and assistant utilities.'
|
||||
: '';
|
||||
parts.push(_section(cat, note, catItems));
|
||||
}
|
||||
return parts.join('');
|
||||
};
|
||||
const _appDepsHtml = (items) => {
|
||||
if (!items.length) return '';
|
||||
const byCat = new Map();
|
||||
for (const item of items) {
|
||||
const cat = item.category || 'Other';
|
||||
if (!byCat.has(cat)) byCat.set(cat, []);
|
||||
byCat.get(cat).push(item);
|
||||
}
|
||||
const parts = [_sectionHeader('Odysseus app', 'Run inside the Odysseus app itself.')];
|
||||
const order = ['System', 'Tools', 'Image', 'LLM', 'Audio', 'Other'];
|
||||
for (const cat of order) {
|
||||
const catItems = _sortDeps(byCat.get(cat) || [], cat);
|
||||
if (!catItems.length) continue;
|
||||
const note = cat === 'LLM'
|
||||
? 'Local app model helpers.'
|
||||
: cat === 'Image'
|
||||
? 'Editor image tools.'
|
||||
: cat === 'Tools'
|
||||
? 'Browser and assistant utilities.'
|
||||
: '';
|
||||
parts.push(_section(cat, note, catItems));
|
||||
}
|
||||
return parts.join('');
|
||||
};
|
||||
|
||||
const _viewingRemote = !!(_dsel && _dsel.value && _dsel.value !== 'local');
|
||||
const _appDeps = pkgs.filter(p => p.target === 'local');
|
||||
const _serverDeps = pkgs.filter(p => p.target !== 'local');
|
||||
const _visibleDep = (p) => p.applicable !== false || p.installed || (p.kind === 'system' && p.name !== 'APFEL');
|
||||
const _appDeps = pkgs.filter(p => p.target === 'local' && _visibleDep(p));
|
||||
const _serverDeps = pkgs.filter(p => p.target !== 'local' && _visibleDep(p));
|
||||
|
||||
list.innerHTML = [
|
||||
_viewingRemote ? '' : _section('Odysseus app', 'Run inside the Odysseus app itself.', _appDeps),
|
||||
_section('Server', 'Run on the server chosen above (Local, or a remote box over SSH).', _serverDeps),
|
||||
_viewingRemote ? '' : _appDepsHtml(_appDeps),
|
||||
_serverDepsHtml(_serverDeps),
|
||||
].join('');
|
||||
|
||||
// Shared install/update routine — used by the Install button and the
|
||||
@@ -1275,8 +1410,11 @@ async function _fetchDependencies() {
|
||||
}
|
||||
}
|
||||
const targetHost = isLocalOnly ? 'this server' : ((targetServer?.host || _envState.remoteHost) || 'local');
|
||||
const targetEnv = isLocalOnly ? 'none' : (targetServer?.env || _envState.env || 'none');
|
||||
let targetEnv = isLocalOnly ? 'none' : (targetServer?.env || _envState.env || 'none');
|
||||
const targetEnvPath = isLocalOnly ? '' : (targetServer?.envPath || _envState.envPath || '');
|
||||
if (!isLocalOnly && targetEnvPath && (!targetEnv || targetEnv === 'none')) {
|
||||
targetEnv = /(?:^|\/)(?:\.?venv|env)(?:\/|$)|\/bin\/activate$/i.test(targetEnvPath) ? 'venv' : targetEnv;
|
||||
}
|
||||
const targetPlatform = isLocalOnly ? (_envState.hostPlatform || _envState.platform || '') : (targetServer?.platform || _envState.platform || '');
|
||||
const targetRemoteHost = isLocalOnly ? '' : (targetServer?.host || _envState.remoteHost || '');
|
||||
// Always go through `python -m pip` so the leading token is `python`
|
||||
@@ -1300,7 +1438,14 @@ async function _fetchDependencies() {
|
||||
} else {
|
||||
_py = 'python3';
|
||||
}
|
||||
const cmd = `${_py} -m pip install${upgrade ? ' -U' : ''}${_pipFlags} "${pipName}"`;
|
||||
const pipArgs = String(pipName || '')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map(_shellQuote)
|
||||
.join(' ');
|
||||
const depTaskId = String(pkgName || pipName || 'dependency').trim().replace(/\s+/g, '_');
|
||||
const cmd = `${_py} -m pip install${upgrade ? ' -U' : ''}${_pipFlags} ${pipArgs}`;
|
||||
let envPrefix = '';
|
||||
if (_isWindows()) {
|
||||
if (targetEnv === 'venv' && targetEnvPath) {
|
||||
@@ -1318,7 +1463,7 @@ async function _fetchDependencies() {
|
||||
}
|
||||
try {
|
||||
const reqBody = {
|
||||
repo_id: pipName,
|
||||
repo_id: depTaskId,
|
||||
cmd: cmd,
|
||||
remote_host: targetRemoteHost || undefined,
|
||||
ssh_port: _getPort(targetRemoteHost) || undefined,
|
||||
@@ -1347,7 +1492,7 @@ async function _fetchDependencies() {
|
||||
}
|
||||
// _dep flags this as a pip dependency/driver install (not a servable
|
||||
// model) so the running-task card doesn't offer a "Serve →" button.
|
||||
const payload = { repo_id: pipName, _cmd: cmd, remote_host: targetRemoteHost || '', _dep: true, env_path: targetEnvPath || '', platform: targetPlatform || '' };
|
||||
const payload = { repo_id: depTaskId, _cmd: cmd, remote_host: targetRemoteHost || '', _dep: true, env_path: targetEnvPath || '', platform: targetPlatform || '' };
|
||||
_addTask(data.session_id, 'pip ' + pkgName, 'download', payload);
|
||||
if (statusEl) { statusEl.textContent = upgrade ? 'Updating...' : 'Installing...'; statusEl.disabled = true; }
|
||||
uiModule.showToast(`${upgrade ? 'Updating' : 'Installing'} ${pkgName} on ${targetHost}...`);
|
||||
@@ -1422,9 +1567,8 @@ async function _fetchDependencies() {
|
||||
});
|
||||
});
|
||||
|
||||
// Inline command-box "Copy" buttons — one per row that has a
|
||||
// resolved per-target install command. Same string surfaces here
|
||||
// and in the toast/diagnosis so the user always sees one answer.
|
||||
// Inline command "Copy" buttons, currently used by targeted recipe
|
||||
// repair panels such as the llama.cpp CUDA wheel reinstall.
|
||||
list.querySelectorAll('.cookbook-dep-cmd-copy').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -1443,12 +1587,6 @@ async function _fetchDependencies() {
|
||||
const names = (btn.dataset.depSysdeps || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (!names.length) return;
|
||||
const isLocal = btn.dataset.depTarget === 'local';
|
||||
// Pull the per-target install command from the sibling box on
|
||||
// the same row, so failure toasts surface the SAME line the
|
||||
// user already sees inline. No duplicated formatting logic.
|
||||
const _row = btn.closest('.cookbook-dep-row');
|
||||
const _cmdBox = _row?.querySelector('.cookbook-dep-install-cmd');
|
||||
const _resolvedCmd = _cmdBox?.dataset.depCmd || '';
|
||||
// Mirror _installDep: the Dependencies tab has its own server
|
||||
// picker that can override _envState. Apply it before reading
|
||||
// remoteHost, otherwise the install silently runs on the wrong
|
||||
@@ -1481,18 +1619,10 @@ async function _fetchDependencies() {
|
||||
try { await _fetchDependencies(); } catch {}
|
||||
} else {
|
||||
const reason = data.error || data.detail || `HTTP ${res.status}`;
|
||||
// Append the per-target install command (if we already know it
|
||||
// from the row) so the user can copy-paste it without leaving
|
||||
// the toast. Otherwise just surface the error.
|
||||
const _suffix = _resolvedCmd ? `\n\nRun on ${targetLabel}: ${_resolvedCmd}` : '';
|
||||
uiModule.showToast('System dependency install failed: ' + String(reason).slice(0, 300) + _suffix, {
|
||||
uiModule.showToast('System dependency install failed: ' + String(reason).slice(0, 300), {
|
||||
duration: 25000,
|
||||
action: _resolvedCmd ? 'Copy command' : 'OK',
|
||||
onAction: async () => {
|
||||
if (_resolvedCmd) {
|
||||
try { await navigator.clipboard.writeText(_resolvedCmd); } catch {}
|
||||
}
|
||||
},
|
||||
action: 'OK',
|
||||
onAction: () => {},
|
||||
});
|
||||
btn.textContent = origText;
|
||||
btn.disabled = false;
|
||||
@@ -1532,17 +1662,18 @@ async function _fetchDependencies() {
|
||||
const sel = panel.querySelector('[data-dep-recipe-pick]');
|
||||
const recipe = pickRecipe(backend, (sel && sel.value) || '');
|
||||
const cmds = recipeCommands(recipe, variant);
|
||||
const runtimeCmds = _recipeRuntimeCommands(cmds, variant);
|
||||
const pre = panel.querySelector('[data-dep-recipe-cmds]');
|
||||
if (pre) {
|
||||
pre.textContent = _recipeDisplayText(cmds, variant);
|
||||
pre.dataset.depRecipeInstall = cmds.join('\n');
|
||||
pre.dataset.depRecipeInstall = runtimeCmds.join('\n');
|
||||
}
|
||||
}
|
||||
// Model select: pickRecipe matches the model id against the catalog.
|
||||
list.querySelectorAll('[data-dep-recipe-pick]').forEach(sel => {
|
||||
sel.addEventListener('change', () => _refreshRecipePre(sel.dataset.depRecipePick));
|
||||
});
|
||||
// Variant toggle (Pip/uv vs Docker): mirrors the agent/chat mode-toggle
|
||||
// Variant toggle (Pip vs Docker): mirrors the agent/chat mode-toggle
|
||||
// pattern — buttons get .active, container gets .mode-right when the
|
||||
// right slot is selected so the sliding pill animates over.
|
||||
list.querySelectorAll('[data-dep-recipe-variant]').forEach(btn => {
|
||||
@@ -1595,16 +1726,26 @@ async function _fetchDependencies() {
|
||||
// displayed source line is for the user's reading; env_prefix
|
||||
// handles it for the actual run.
|
||||
const installRaw = pre.dataset.depRecipeInstall || pre.textContent;
|
||||
const cmd = installRaw.split('\n').map(s => s.trim()).filter(Boolean).join(' && ');
|
||||
const depsSel = document.getElementById('hwfit-deps-server');
|
||||
if (depsSel) _applyServerSelection(depsSel.value);
|
||||
const targetHost = _envState.remoteHost || 'local';
|
||||
const inferredVenv = _envState.envPath && (!_envState.env || _envState.env === 'none')
|
||||
&& /(?:^|\/)(?:\.?venv|env)(?:\/|$)|\/bin\/activate$/i.test(_envState.envPath);
|
||||
const recipeEnv = inferredVenv ? 'venv' : _envState.env;
|
||||
const recipePy = (recipeEnv === 'venv' && _envState.envPath)
|
||||
? `${_envState.envPath.replace(/\/+$/, '').replace(/\/bin\/activate$/i, '')}/bin/python3`
|
||||
: '';
|
||||
const cmd = installRaw.split('\n').map(s => {
|
||||
let line = s.trim();
|
||||
if (recipePy) line = line.replace(/^python(?:3)?\s+-m\s+pip\b/, `${recipePy} -m pip`);
|
||||
return line;
|
||||
}).filter(Boolean).join(' && ');
|
||||
// Build env_prefix from the configured envPath (matches _installDep).
|
||||
let envPrefix = '';
|
||||
if (_envState.env === 'venv' && _envState.envPath) {
|
||||
if (recipeEnv === 'venv' && _envState.envPath) {
|
||||
const p = _envState.envPath;
|
||||
envPrefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate');
|
||||
} else if (_envState.env === 'conda' && _envState.envPath) {
|
||||
} else if (recipeEnv === 'conda' && _envState.envPath) {
|
||||
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
|
||||
}
|
||||
const reqBody = {
|
||||
@@ -2013,6 +2154,27 @@ function _wireTabEvents(body) {
|
||||
hwRefreshBtn.addEventListener('click', _refreshScanDownloadTarget);
|
||||
}
|
||||
|
||||
const hwAdvancedBtn = document.getElementById('hwfit-advanced-btn');
|
||||
const hwAdvancedPanel = document.getElementById('hwfit-advanced-panel');
|
||||
if (hwAdvancedBtn && hwAdvancedPanel && !hwAdvancedBtn.dataset.bound) {
|
||||
hwAdvancedBtn.dataset.bound = '1';
|
||||
const setAdvancedOpen = (open) => {
|
||||
hwAdvancedPanel.classList.toggle('hidden', !open);
|
||||
hwAdvancedBtn.classList.toggle('active', open);
|
||||
hwAdvancedBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
};
|
||||
hwAdvancedBtn.addEventListener('click', (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setAdvancedOpen(hwAdvancedPanel.classList.contains('hidden'));
|
||||
});
|
||||
hwAdvancedPanel.addEventListener('click', (ev) => ev.stopPropagation());
|
||||
document.addEventListener('click', () => setAdvancedOpen(false));
|
||||
document.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Escape') setAdvancedOpen(false);
|
||||
});
|
||||
}
|
||||
|
||||
const editDirsLink = document.querySelector('.cookbook-serve-dir-edit');
|
||||
if (editDirsLink) {
|
||||
editDirsLink.addEventListener('click', () => {
|
||||
@@ -2926,15 +3088,39 @@ function _renderRecipes() {
|
||||
html += '</div>';
|
||||
html += '<p class="memory-desc doclib-desc" style="margin-top:6px;">Scans your hardware for what models you can run. Hardware is cached; hit the scan button to re-probe after changing GPUs.</p>';
|
||||
html += '<div class="hwfit-toolbar" style="margin-top:9px;">';
|
||||
html += '<select class="cookbook-field-input hwfit-usecase" id="hwfit-usecase" style="height:28px;">';
|
||||
html += '<option value="general" selected>Standard</option>';
|
||||
// Image tab removed — text→image gen is gone from this build (only inpaint
|
||||
// remains, which uses its own settings panel). Vision (multimodal) stays.
|
||||
html += '<option value="multimodal">Vision</option></select>';
|
||||
// Search moved next to the Type filter so the two primary picks
|
||||
// (what category + free text) sit together; the more advanced
|
||||
// levers (Engine / Quant / Context) live to the right.
|
||||
html += '<select class="cookbook-field-input hwfit-server-select" id="hwfit-server-select" style="height:28px;min-width:88px;position:relative;top:0px;">';
|
||||
html += _buildServerOpts(false);
|
||||
html += '</select>';
|
||||
// Keep the main scan toolbar light: server + free-text search. Advanced
|
||||
// levers (Engine / Quant / Context) live behind the cog beside Refresh.
|
||||
html += '<input type="text" class="cookbook-field-input hwfit-search" id="hwfit-search" placeholder="Search models..." style="flex:1;" />';
|
||||
html += '</div>';
|
||||
html += '<div class="hwfit-toolbar" style="margin-top:7px;">';
|
||||
html += '<span class="hwfit-usecase-wrap">';
|
||||
html += '<select class="cookbook-field-input hwfit-usecase" id="hwfit-usecase" style="display:none;height:28px;">';
|
||||
html += '<option value="general" selected>Standard</option>';
|
||||
html += '<option value="multimodal">Vision</option>';
|
||||
html += '<option value="image_gen">Image</option></select>';
|
||||
html += '<button type="button" class="cookbook-field-input hwfit-usecase-btn" data-hwfit-usecase-btn aria-haspopup="listbox" aria-expanded="false" title="Model type">';
|
||||
html += '<span class="hwfit-usecase-btn-icon" data-hwfit-usecase-icon aria-hidden="true"></span>';
|
||||
html += '<span class="hwfit-usecase-btn-label" data-hwfit-usecase-label>Standard</span>';
|
||||
html += '<svg class="hwfit-usecase-caret" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>';
|
||||
html += '</button>';
|
||||
html += '<div class="hwfit-usecase-menu" data-hwfit-usecase-menu role="listbox" hidden></div>';
|
||||
html += '</span>';
|
||||
html += '<div class="hwfit-gpu-toggles" id="hwfit-gpu-toggles"></div>';
|
||||
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-manual-btn" id="hwfit-hw-manual-btn" title="Set hardware manually" style="flex-shrink:0;position:relative;top:-3px;left:-1px;display:inline-flex;align-items:center;gap:3px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>EDIT</button>';
|
||||
html += '<button type="button" class="hwfit-gpu-btn hwfit-advanced-btn" id="hwfit-advanced-btn" title="Scan settings" aria-label="Scan settings" aria-expanded="false" style="flex-shrink:0;position:relative;top:-3px;left:-3px;width:26px;height:26px;padding:0;display:inline-flex;align-items:center;justify-content:center;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5Z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06A2 2 0 1 1 7.04 4.3l.06.06A1.65 1.65 0 0 0 8.92 4a1.65 1.65 0 0 0 1-1.51V2a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82 1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg></button>';
|
||||
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-refresh-btn" id="hwfit-hw-refresh-btn" title="Refresh selected server hardware and cached models" aria-label="Refresh selected server hardware and cached models" style="flex-shrink:0;position:relative;top:-3px;left:-5px;width:26px;height:26px;padding:0;display:inline-flex;align-items:center;justify-content:center;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10"/><path d="M3.51 15a9 9 0 0 0 14.85 3.36L23 14"/></svg></button>';
|
||||
// Sort state — the clickable column headers read/write this (pewds' original
|
||||
// sort paradigm). Newest is reachable by clicking the Model column header.
|
||||
html += '<select class="cookbook-field-input hwfit-sort" id="hwfit-sort" style="display:none">';
|
||||
html += '<option value="newest" selected>Latest</option>';
|
||||
html += '<option value="fit">Fit</option><option value="score">Score</option><option value="vram">VRAM</option>';
|
||||
html += '<option value="speed">Speed</option><option value="params">Params</option>';
|
||||
html += '<option value="context">Context</option></select>';
|
||||
html += '</div>';
|
||||
html += '<div class="hwfit-advanced-panel hidden" id="hwfit-advanced-panel" aria-label="Scan settings">';
|
||||
html += '<span class="hwfit-engine-wrap">';
|
||||
html += '<select class="cookbook-field-input hwfit-engine" id="hwfit-engine" style="display:none;" title="Filter by serving engine">';
|
||||
html += '<option value="">Engine</option>';
|
||||
@@ -2971,21 +3157,6 @@ function _renderRecipes() {
|
||||
html += '<span>Context</span><span class="hwfit-help-chip hwfit-help-chip-inline" title="Context length. Lower it to find more models that could fit your hardware; raise it when you need longer chats or documents.">?</span><input type="range" id="hwfit-context" min="0" max="5" step="1" value="3" />';
|
||||
html += '<output id="hwfit-context-label">50k</output></label>';
|
||||
html += '</div>';
|
||||
html += '<div class="hwfit-toolbar" style="margin-top:7px;">';
|
||||
html += '<select class="cookbook-field-input hwfit-server-select" id="hwfit-server-select" style="height:28px;min-width:88px;position:relative;top:0px;">';
|
||||
html += _buildServerOpts(false);
|
||||
html += '</select>';
|
||||
html += '<div class="hwfit-gpu-toggles" id="hwfit-gpu-toggles"></div>';
|
||||
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-manual-btn" id="hwfit-hw-manual-btn" title="Set hardware manually" style="flex-shrink:0;position:relative;top:-3px;left:-1px;display:inline-flex;align-items:center;gap:3px;"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg>EDIT</button>';
|
||||
html += '<button type="button" class="hwfit-gpu-btn hwfit-hw-refresh-btn" id="hwfit-hw-refresh-btn" title="Refresh selected server hardware and cached models" aria-label="Refresh selected server hardware and cached models" style="flex-shrink:0;position:relative;top:-3px;left:-3px;width:26px;height:26px;padding:0;display:inline-flex;align-items:center;justify-content:center;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10"/><path d="M3.51 15a9 9 0 0 0 14.85 3.36L23 14"/></svg></button>';
|
||||
// Sort state — the clickable column headers read/write this (pewds' original
|
||||
// sort paradigm). Newest is reachable by clicking the Model column header.
|
||||
html += '<select class="cookbook-field-input hwfit-sort" id="hwfit-sort" style="display:none">';
|
||||
html += '<option value="newest" selected>Latest</option>';
|
||||
html += '<option value="fit">Fit</option><option value="score">Score</option><option value="vram">VRAM</option>';
|
||||
html += '<option value="speed">Speed</option><option value="params">Params</option>';
|
||||
html += '<option value="context">Context</option></select>';
|
||||
html += '</div>';
|
||||
html += '<div class="hwfit-manual-panel hidden" id="hwfit-manual-panel">';
|
||||
html += '<span class="hwfit-manual-note" style="font-size:10px;opacity:0.6;width:100%;margin-bottom:2px;">Simulator — these values REPLACE detected hardware.</span>';
|
||||
html += '<select class="hwfit-manual-mode"><option value="gpu">GPU</option><option value="ram">RAM</option></select>';
|
||||
|
||||
@@ -9,6 +9,7 @@ import { _diagnose, _showDiagnosis, _clearDiagnosis } from './cookbook-diagnosis
|
||||
import { registerMenuDismiss } from './escMenuStack.js';
|
||||
import { computeProgressSignal } from './cookbookProgressSignal.js';
|
||||
import { portOf, nextFreePort } from './cookbookPorts.js';
|
||||
import { topPortalZ } from './toolWindowZOrder.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
|
||||
@@ -20,6 +21,18 @@ function _statusLabel(status, type) {
|
||||
return status || '';
|
||||
}
|
||||
|
||||
function _downloadBadgeText(progress) {
|
||||
const raw = String(progress || '').trim();
|
||||
if (!raw) return 'downloading';
|
||||
const pct = raw.match(/(\d+)%/);
|
||||
if (pct) return pct[0];
|
||||
if (/^(?:Downloading|Fetching|Resuming)\s+'[^']+'\s+to\s+'[^']+/i.test(raw)
|
||||
|| /^Downloading\s*\(incomplete\b/i.test(raw)) {
|
||||
return 'downloading';
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
// Single source of truth for what a task's status badge shows + its style class.
|
||||
// Crucially, a serve task that's still coming up shows its live phase
|
||||
// ("loading 45%", "warming up", …) rather than the generic "running" — they're
|
||||
@@ -29,8 +42,7 @@ function _statusLabel(status, type) {
|
||||
function _taskBadge(task) {
|
||||
if (task._unreachable && task.status === 'running') return { text: 'unreachable', cls: 'cookbook-task-error' };
|
||||
if (task.type === 'download' && task.status === 'running') {
|
||||
const progress = String(task.progress || '').trim();
|
||||
return { text: progress || _statusLabel(task.status, task.type), cls: 'cookbook-task-downloading' };
|
||||
return { text: _downloadBadgeText(task.progress), 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
|
||||
@@ -61,9 +73,12 @@ function _downloadNameFromPayload(name, payload) {
|
||||
const rawName = String(name || '').trim();
|
||||
// Defensive: failed/restarted downloads can inherit the wrapper executable
|
||||
// name if older state was saved from a command preview. The row title should
|
||||
// always be the model/repo, never "bash" or "python".
|
||||
// always be the model/repo, never "bash", "python", or a live HF progress
|
||||
// line like "Downloading 'vae/...' to '/mnt/...".
|
||||
const looksLikeLauncher = /^(?:bash|sh|zsh|python|python3|pwsh|powershell|cmd|tmux)$/i.test(rawName);
|
||||
const base = (!rawName || looksLikeLauncher)
|
||||
const looksLikeProgressLine = /^(?:Downloading|Fetching|Resuming)\s+'[^']+'\s+to\s+'[^']+/i.test(rawName)
|
||||
|| /^Downloading\s*\(incomplete\b/i.test(rawName);
|
||||
const base = (!rawName || looksLikeLauncher || looksLikeProgressLine)
|
||||
? String(payload?.repo_id || payload?.repo || '').split('/').pop()
|
||||
: rawName;
|
||||
const include = payload?.include || '';
|
||||
@@ -636,6 +651,11 @@ function _appendPinnedServeModel(fd, task) {
|
||||
if (expected) fd.append('pinned_models', expected);
|
||||
}
|
||||
|
||||
function _isImageServeTask(task) {
|
||||
const cmd = String(task?.payload?._cmd || '');
|
||||
return cmd.includes('diffusion_server') || cmd.includes('mlx_image_server');
|
||||
}
|
||||
|
||||
// ── Download queue — runs one at a time per server ──
|
||||
|
||||
function _processQueue() {
|
||||
@@ -1274,7 +1294,7 @@ function _autoSaveWorkingConfig(task) {
|
||||
if (task._autoSaved) return;
|
||||
const cmd = task.payload._cmd;
|
||||
// Diffusion/image servers aren't vLLM presets — skip them.
|
||||
if (cmd.includes('diffusion_server')) { task._autoSaved = true; return; }
|
||||
if (cmd.includes('diffusion_server') || cmd.includes('mlx_image_server')) { task._autoSaved = true; return; }
|
||||
const model = task.payload.repo_id || task.name;
|
||||
const presets = _loadPresets();
|
||||
const existing = presets.find(p => p.cmd === cmd);
|
||||
@@ -1752,15 +1772,16 @@ function _promptEditServeCmd(currentCmd) {
|
||||
function _parseServeCmdToFields(cmd) {
|
||||
if (!cmd) return null;
|
||||
const ex = (re) => { const m = cmd.match(re); return m ? m[1] : ''; };
|
||||
const fields = {
|
||||
backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp'
|
||||
const fields = {
|
||||
backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp'
|
||||
: cmd.includes('mlx_image_server') ? 'mlx_image'
|
||||
: cmd.includes('mlx_lm.server') ? 'mlx'
|
||||
: cmd.includes('diffusion_server') ? 'diffusers'
|
||||
: cmd.includes('sglang') ? 'sglang'
|
||||
: cmd.includes('ollama') ? 'ollama' : 'vllm',
|
||||
port: ex(/--port\s+(\d+)/) || '8000',
|
||||
tp: ex(/--tensor-parallel-size\s+(\d+)/) || '1',
|
||||
ctx: ex(/--max-model-len\s+(\d+)/) || ex(/--n_ctx\s+(\d+)/) || ex(/-c\s+(\d+)/) || '8192',
|
||||
ctx: ex(/--max-model-len\s+(\d+)/) || ex(/--context-length\s+(\d+)/) || ex(/--max-tokens\s+(\d+)/) || ex(/--n_ctx\s+(\d+)/) || ex(/-c\s+(\d+)/) || '8192',
|
||||
gpu_mem: ex(/--gpu-memory-utilization\s+([\d.]+)/) || '0.90',
|
||||
swap: ex(/--swap-space\s+(\d+)/) || '',
|
||||
dtype: ex(/--dtype\s+(\w+)/) || 'auto',
|
||||
@@ -1796,7 +1817,7 @@ function _serveCmdNeedsGpuPreflight(cmd, repo) {
|
||||
const c = String(cmd || '').toLowerCase();
|
||||
const r = String(repo || '').toLowerCase();
|
||||
if (!c || /gpu-cleanup|sglang-kernel|mlx-lm|pip\s+install|python\d*\s+-m\s+pip/.test(`${r} ${c}`)) return false;
|
||||
return /\b(vllm\s+serve|sglang(?:\.launch_server|\s+serve)|mlx_lm\.server|llama-server|llama_cpp\.server|text-generation-launcher|aphrodite|ollama\s+(?:serve|run))\b/.test(c);
|
||||
return /\b(vllm\s+serve|sglang(?:\.launch_server|\s+serve)|mlx_lm\.server|mlx_image_server\.py|diffusion_server\.py|llama-server|llama_cpp\.server|text-generation-launcher|aphrodite|ollama\s+(?:serve|run))\b/.test(c);
|
||||
}
|
||||
|
||||
function _selectedGpuIndexes(gpus) {
|
||||
@@ -1900,6 +1921,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
|
||||
const _serverMetaName = targetMeta?.serverName || _hsrv.name || (_host ? _host : 'Local');
|
||||
const _hplatform = _host ? (_hsrv.platform || '') : (_envState.hostPlatform || '');
|
||||
const _replaceTaskId = fields?._replaceTaskId || '';
|
||||
const _launchAnyway = !!targetMeta?.launchAnyway;
|
||||
if (_replaceTaskId) {
|
||||
try {
|
||||
const _old = _loadTasks().find(t => t.sessionId === _replaceTaskId);
|
||||
@@ -1917,7 +1939,7 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
|
||||
// servers on one port, so re-serving (or retrying) should stop & remove the
|
||||
// old one instead of leaving a dead duplicate behind. (The retry buttons
|
||||
// already removed their own task, so this is a no-op for them.)
|
||||
try {
|
||||
if (!_launchAnyway) try {
|
||||
const _pm = cmd.match(/--port[=\s]+(\d+)/) || cmd.match(/(?:^|\s)-p[=\s]+(\d+)/);
|
||||
const _newPort = _pm ? _pm[1] : '';
|
||||
if (_newPort) {
|
||||
@@ -2374,14 +2396,15 @@ 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);
|
||||
const logoName = task.type === 'download' ? (task.payload?.repo_id || task.name) : task.name;
|
||||
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(displayName)}</span>
|
||||
<span class="cookbook-task-name">${modelLogo(logoName)}${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>
|
||||
<button type="button" class="cookbook-task-menu-btn" title="Actions">⋮</button>
|
||||
</div>
|
||||
<div class="cookbook-task-sub"><span class="cookbook-task-session">${esc(task.sessionId)}</span><span class="cookbook-task-uptime" style="display:${((task.type === 'serve' || task.type === 'download') && task.status === 'running') ? '' : 'none'}"></span>${(task.type === 'download') ? `<span class="cookbook-task-dldir" title="Download destination" style="font-size:9px;color:var(--fg-muted);font-family:'Fira Code',monospace;opacity:0.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:40ch;">Dir: ${esc(task.payload?.local_dir || '~/.cache/huggingface/hub')}</span>` : ''}</div>
|
||||
<div class="cookbook-output-wrap cookbook-task-collapsible${(_mobileCollapseDefault && !_shouldAutoExpandTaskOutput(task)) ? ' cookbook-task-collapsed' : ''}"><pre class="cookbook-output-pre">${esc(task.output || '')}</pre><button type="button" class="copy-code cookbook-output-copy"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></div>
|
||||
@@ -2572,8 +2595,10 @@ export function _renderRunningTab() {
|
||||
el.addEventListener('touchmove', _lpMove, { passive: true });
|
||||
el.addEventListener('touchend', _lpCancel, { passive: true });
|
||||
el.addEventListener('touchcancel', _lpCancel, { passive: true });
|
||||
menuBtn.addEventListener('click', (e) => {
|
||||
let _lastTouchMenuOpenAt = 0;
|
||||
const _openTaskMenu = (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.type === 'click' && Date.now() - _lastTouchMenuOpenAt < 550) return;
|
||||
const existing = document.querySelector('.cookbook-task-dropdown');
|
||||
if (existing && existing._anchor === menuBtn) {
|
||||
if (typeof existing._dismiss === 'function') existing._dismiss();
|
||||
@@ -2646,7 +2671,7 @@ export function _renderRunningTab() {
|
||||
fd.append('name', task.name);
|
||||
fd.append('skip_probe', 'true');
|
||||
_appendCookbookEndpointScope(fd, task.remoteHost || '');
|
||||
if (task.payload?._cmd?.includes('diffusion_server')) fd.append('model_type', 'image');
|
||||
if (_isImageServeTask(task)) fd.append('model_type', 'image');
|
||||
const res = await fetch('/api/model-endpoints', { method: 'POST', credentials: 'same-origin', body: fd });
|
||||
if (res.ok) {
|
||||
task._endpointAdded = true;
|
||||
@@ -2764,6 +2789,7 @@ export function _renderRunningTab() {
|
||||
|
||||
const rect = menuBtn.getBoundingClientRect();
|
||||
dropdown.style.position = 'fixed';
|
||||
dropdown.style.zIndex = String(topPortalZ());
|
||||
dropdown.style.top = rect.bottom + 2 + 'px';
|
||||
dropdown.style.right = (window.innerWidth - rect.right) + 'px';
|
||||
document.body.appendChild(dropdown);
|
||||
@@ -2811,7 +2837,13 @@ export function _renderRunningTab() {
|
||||
window.visualViewport?.addEventListener('scroll', scrollClose);
|
||||
}, 0);
|
||||
_unreg = registerMenuDismiss(_cleanup);
|
||||
});
|
||||
};
|
||||
menuBtn.addEventListener('click', _openTaskMenu);
|
||||
menuBtn.addEventListener('touchend', (e) => {
|
||||
e.preventDefault();
|
||||
_lastTouchMenuOpenAt = Date.now();
|
||||
_openTaskMenu(e);
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
// Hidden action buttons for menu dispatch
|
||||
@@ -3619,7 +3651,7 @@ async function _reconnectTask(el, task) {
|
||||
if (_ex && _ex.id && !(_ex.models || []).length) _probeEndpointUntilOnline(_ex.id, host, port);
|
||||
return null;
|
||||
}
|
||||
const _isDiffusion = task.payload?._cmd?.includes('diffusion_server');
|
||||
const _isDiffusion = _isImageServeTask(task);
|
||||
const fd = new FormData();
|
||||
fd.append('base_url', baseUrl);
|
||||
fd.append('name', task.name);
|
||||
@@ -4236,7 +4268,6 @@ async function _pollBackgroundStatus() {
|
||||
for (const t of readyServes) {
|
||||
const localTasks = _loadTasks();
|
||||
const localTask = localTasks.find(lt => lt.sessionId === t.session_id);
|
||||
if (localTask && localTask._endpointAdded) continue;
|
||||
|
||||
let host = _connectHostFromRemote(localTask?.remoteHost || t.remote);
|
||||
const portMatch = localTask?.payload?._cmd?.match(/--port\s+(\d+)/)
|
||||
@@ -4249,9 +4280,9 @@ async function _pollBackgroundStatus() {
|
||||
const endpoint = _endpointFromAdvertisedUrl(ollamaUrlMatch[1], host, '11434');
|
||||
if (endpoint) ({ host, port, baseUrl } = endpoint);
|
||||
}
|
||||
const _isDiffusion = localTask?.payload?._cmd?.includes('diffusion_server');
|
||||
const _isDiffusion = _isImageServeTask(localTask);
|
||||
|
||||
_updateTask(t.session_id, { _serveReady: true, _endpointAdded: true });
|
||||
_updateTask(t.session_id, { _serveReady: true });
|
||||
if (localTask) _autoSaveWorkingConfig(localTask); // remember working settings (modal may be closed)
|
||||
|
||||
// Auto-detect function-calling support from the serve cmd.
|
||||
@@ -4273,6 +4304,7 @@ async function _pollBackgroundStatus() {
|
||||
_markServeEndpointMismatch(taskForMatch, existing, host, port);
|
||||
return null;
|
||||
}
|
||||
_updateTask(t.session_id, { _endpointAdded: true });
|
||||
// Already registered — but it may be showing offline because
|
||||
// it was added while the server was still warming. Kick a
|
||||
// re-probe so it flips online without manual toggle.
|
||||
@@ -4291,6 +4323,7 @@ async function _pollBackgroundStatus() {
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res && res.ok) {
|
||||
_updateTask(t.session_id, { _endpointAdded: true });
|
||||
uiModule.showToast(`Model endpoint added: ${host}:${port}`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
// A just-started server often can't answer the 1s add-time
|
||||
@@ -4328,12 +4361,8 @@ async function _pollBackgroundStatus() {
|
||||
statusEl.textContent = 'cooking';
|
||||
}
|
||||
} else {
|
||||
var _dlProgress = '';
|
||||
if (t.progress) {
|
||||
var _pctMatch = t.progress.match(/(\d+)%/);
|
||||
_dlProgress = _pctMatch ? ` ${_pctMatch[0]}` : '';
|
||||
}
|
||||
statusEl.textContent = `downloading${_dlProgress}`;
|
||||
const _dlText = _downloadBadgeText(t.progress);
|
||||
statusEl.textContent = _dlText === 'downloading' ? 'downloading' : `downloading ${_dlText}`;
|
||||
}
|
||||
statusEl.style.display = '';
|
||||
} else if (errorTasks.length > 0) {
|
||||
|
||||
+374
-69
@@ -46,7 +46,7 @@ const SERVE_STATE_KEY = 'cookbook-serve-state';
|
||||
const SERVE_FAVORITES_KEY = 'cookbook-serve-favorite-models';
|
||||
|
||||
let _cachedAllModels = [];
|
||||
const _CACHED_MODELS_SCAN_KEY = 'cookbook_cached_models_scan_v1';
|
||||
const _CACHED_MODELS_SCAN_KEY = 'cookbook_cached_models_scan_v3_ltx_video';
|
||||
const _CACHED_MODELS_SCAN_TTL = 6 * 3600 * 1000;
|
||||
|
||||
function _normalizeCookbookModelDir(dir) {
|
||||
@@ -54,6 +54,47 @@ function _normalizeCookbookModelDir(dir) {
|
||||
return /^(home|mnt|media|data|opt|srv|var)\//.test(d) ? `/${d}` : d;
|
||||
}
|
||||
|
||||
function _serveCmdPort(cmd) {
|
||||
const s = String(cmd || '');
|
||||
const m = s.match(/--port[=\s]+(\d+)/)
|
||||
|| s.match(/(?:^|\s)-p[=\s]+(\d+)/)
|
||||
|| s.match(/OLLAMA_HOST=[^:\s]+:(\d+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function _replaceServeCmdPort(cmd, port) {
|
||||
const s = String(cmd || '');
|
||||
const p = String(port || '').trim();
|
||||
if (!s || !p) return s;
|
||||
if (/(^|\s)--port=\d+/.test(s)) return s.replace(/(^|\s)--port=\d+/, `$1--port=${p}`);
|
||||
if (/(^|\s)--port\s+\d+/.test(s)) return s.replace(/(^|\s)--port\s+\d+/, `$1--port ${p}`);
|
||||
if (/(^|\s)-p=\d+/.test(s)) return s.replace(/(^|\s)-p=\d+/, `$1-p=${p}`);
|
||||
if (/(^|\s)-p\s+\d+/.test(s)) return s.replace(/(^|\s)-p\s+\d+/, `$1-p ${p}`);
|
||||
if (/OLLAMA_HOST=([^:\s]+):\d+/.test(s)) return s.replace(/OLLAMA_HOST=([^:\s]+):\d+/, `OLLAMA_HOST=$1:${p}`);
|
||||
return `${s} --port ${p}`;
|
||||
}
|
||||
|
||||
function _nextServeLaunchPort(currentPort, runningMod, host, serverKey) {
|
||||
const used = new Set();
|
||||
try {
|
||||
for (const t of (runningMod?._loadTasks ? runningMod._loadTasks() : [])) {
|
||||
if (!t || t.type !== 'serve') continue;
|
||||
if (!(t.status === 'queued' || t.status === 'running' || t.status === 'ready' || t._serveReady)) continue;
|
||||
const sameTarget = ((t.remoteHost || '') === (host || ''))
|
||||
|| ((t.remoteServerKey || '') === (serverKey || ''));
|
||||
if (!sameTarget) continue;
|
||||
const tp = runningMod?._taskPort ? runningMod._taskPort(t) : _serveCmdPort(t.payload?._cmd || t.cmd || '');
|
||||
const n = parseInt(tp, 10);
|
||||
if (Number.isFinite(n) && n > 0) used.add(n);
|
||||
}
|
||||
} catch {}
|
||||
const start = parseInt(currentPort || '8000', 10) || 8000;
|
||||
used.add(start);
|
||||
let next = Math.max(1, start + 1);
|
||||
while (used.has(next)) next += 1;
|
||||
return String(next);
|
||||
}
|
||||
|
||||
function _readCachedModelScan(sig) {
|
||||
try {
|
||||
const all = JSON.parse(localStorage.getItem(_CACHED_MODELS_SCAN_KEY) || '{}');
|
||||
@@ -611,6 +652,56 @@ function _estimateLlamaContextFit(model, fields, modelCtxMax, modelWeightsGb = 0
|
||||
};
|
||||
}
|
||||
|
||||
function _estimateMlxContextFit(model, fields, modelCtxMax, modelWeightsGb = 0, fitSystem = null) {
|
||||
const sys = fitSystem || _hwfitCache?.system || {};
|
||||
const modelMax = Math.max(1024, _modelContextMaxForServe(model, modelCtxMax));
|
||||
const modelGb = _modelSizeGb(model, modelWeightsGb);
|
||||
const availableRamGb = Number(sys.available_ram_gb) || 0;
|
||||
const totalRamGb = Number(sys.total_ram_gb) || 0;
|
||||
const unifiedPoolGb = Math.max(availableRamGb, totalRamGb > 0 ? totalRamGb * 0.75 : 0);
|
||||
if (!unifiedPoolGb) {
|
||||
return {
|
||||
ctx: Math.min(modelMax, 32768),
|
||||
needsHardwareScan: true,
|
||||
reason: 'scan Apple memory first; using model limit fallback',
|
||||
};
|
||||
}
|
||||
if (!modelGb) {
|
||||
return {
|
||||
ctx: Math.min(modelMax, 32768),
|
||||
needsModelSize: true,
|
||||
reason: 'model weight size unknown; using MLX fallback',
|
||||
};
|
||||
}
|
||||
|
||||
const usableGb = Math.max(1, unifiedPoolGb - Math.max(4.0, unifiedPoolGb * 0.10));
|
||||
const freeForKv = usableGb - modelGb;
|
||||
const name = `${model?.repo_id || ''} ${model?.name || ''} ${model?.quant || ''}`.toLowerCase();
|
||||
const totalParams = _parseParamsB(name) || Math.max(1, modelGb / 0.58);
|
||||
const activeMatch = name.match(/\ba(\d+(?:\.\d+)?)b\b/);
|
||||
const activeParams = activeMatch ? parseFloat(activeMatch[1]) : (/moe|minimax|deepseek|mixtral|kimi-k2/.test(name) ? Math.min(totalParams, 32) : totalParams);
|
||||
// MLX uses unified memory. This is intentionally conservative because the
|
||||
// server exposes max generation tokens, not a hard prefill context length.
|
||||
const kvGbPerToken = Math.max(0.00002, 0.0000065 * activeParams);
|
||||
if (freeForKv <= 0) {
|
||||
return {
|
||||
ctx: Math.min(modelMax, 2048),
|
||||
modelGb,
|
||||
kvGbPerToken,
|
||||
reason: `model ${modelGb.toFixed(1)}G exceeds usable unified memory ${usableGb.toFixed(1)}G before KV`,
|
||||
};
|
||||
}
|
||||
const raw = Math.floor(freeForKv / kvGbPerToken);
|
||||
const rounded = Math.max(1024, Math.floor(raw / 1024) * 1024);
|
||||
const ctx = Math.min(modelMax, rounded);
|
||||
return {
|
||||
ctx,
|
||||
modelGb,
|
||||
kvGbPerToken,
|
||||
reason: `MLX --max-tokens from unified memory (${freeForKv.toFixed(1)}G free)`,
|
||||
};
|
||||
}
|
||||
|
||||
function _selectedServeTarget(panel) {
|
||||
const select = panel?.querySelector?.('#hwfit-server-select')
|
||||
|| document.getElementById('hwfit-server-select')
|
||||
@@ -629,11 +720,11 @@ function _selectedServeTarget(panel) {
|
||||
}
|
||||
}
|
||||
const typedVenv = panel?.querySelector('[data-field="venv"]')?.value?.trim() || '';
|
||||
// For remote targets the server profile is authoritative. Otherwise a stale
|
||||
// venv typed/loaded for another host can leak into this launch, e.g. a Linux
|
||||
// /home/... Python path being used on an Apple Silicon MLX server.
|
||||
// A venv typed in the serve panel is a per-launch/per-model override and must
|
||||
// win over the server default. _buildServeCmd still drops obviously wrong
|
||||
// platform paths, so stale Linux/macOS paths do not leak across hosts.
|
||||
const venv = host
|
||||
? (server?.envPath || typedVenv || '')
|
||||
? (typedVenv || server?.envPath || '')
|
||||
: (typedVenv || server?.envPath || _envState.envPath || '');
|
||||
const label = host
|
||||
? (server?.name ? `${server.name} (${host})` : host)
|
||||
@@ -660,19 +751,79 @@ function _backendChoicesForTarget(target) {
|
||||
return [['llamacpp','llama.cpp'],['diffusers','Diffusers']];
|
||||
}
|
||||
return _isMetal()
|
||||
? [['mlx','MLX'],['llamacpp','llama.cpp'],['ollama','Ollama']]
|
||||
: [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['mlx','MLX'],['diffusers','Diffusers']];
|
||||
? [['mlx','MLX'],['mlx_image','MLX Image'],['llamacpp','llama.cpp'],['ollama','Ollama']]
|
||||
: [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['mlx','MLX'],['mlx_image','MLX Image'],['diffusers','Diffusers']];
|
||||
}
|
||||
|
||||
async function _fetchServeRuntimePackage(panel, backend) {
|
||||
function _dependencyPkgForServeBackend(backend, modelName = '') {
|
||||
const nm = String(modelName || '').toLowerCase();
|
||||
if (backend === 'mlx_image' && nm.includes('boogu')) return 'boogu_image_mlx';
|
||||
if (backend === 'mlx_image' && (nm.includes('mi-gan') || nm.includes('migan') || nm.includes('lama'))) return 'mlx_lama_swift';
|
||||
if (backend === 'mlx_image' && nm.includes('ddcolor')) return 'mlx_ddcolor_swift';
|
||||
if (backend === 'diffusers' && nm.includes('krea')) return 'krea_diffusers';
|
||||
const packageByBackend = {
|
||||
vllm: 'vllm',
|
||||
sglang: 'sglang',
|
||||
llamacpp: 'llama_cpp',
|
||||
mlx: 'mlx_lm',
|
||||
mlx_image: 'mflux',
|
||||
diffusers: 'diffusers',
|
||||
};
|
||||
const packageName = packageByBackend[backend];
|
||||
return packageByBackend[backend];
|
||||
}
|
||||
|
||||
function _looksLikeAdapterModel(m) {
|
||||
const repo = String(m?.repo_id || '');
|
||||
const n = repo.toLowerCase();
|
||||
return !!(
|
||||
m?.is_adapter
|
||||
|| /\b(lora|adapter|peft|qlora)\b/i.test(n)
|
||||
|| /(?:^|[-_/])(lora|adapter|peft|qlora)(?:[-_/]|$)/i.test(repo)
|
||||
|| /control[-_]?lora|diffusion[-_]?lora/i.test(repo)
|
||||
);
|
||||
}
|
||||
|
||||
function _cachedAdapterModels(currentRepo = '') {
|
||||
const current = String(currentRepo || '');
|
||||
return (_cachedAllModels || [])
|
||||
.filter(m => m && m.status === 'ready' && m.repo_id && m.repo_id !== current)
|
||||
.sort((a, b) => String(a.repo_id || '').localeCompare(String(b.repo_id || '')));
|
||||
}
|
||||
|
||||
function _cachedAdapterSelectHtml(kind, currentRepo = '') {
|
||||
const adapters = _cachedAdapterModels(currentRepo);
|
||||
const cls = kind === 'vllm_lora_modules'
|
||||
? 'hwfit-backend-vllm'
|
||||
: kind === 'diff_lora'
|
||||
? 'hwfit-backend-diffusers'
|
||||
: kind === 'mlx_lora_paths'
|
||||
? 'hwfit-backend-mlx_image'
|
||||
: '';
|
||||
if (!adapters.length) {
|
||||
return `<label class="hwfit-cached-adapter-label ${cls}" style="grid-column:1 / -1;">Cached adapter <select class="hwfit-cached-adapter-select" data-adapter-kind="${esc(kind)}" disabled style="height:30px;width:100%;background:var(--bg);color:var(--fg-muted);border:1px solid var(--border);border-radius:4px;font:inherit;font-size:11px;opacity:0.75;"><option value="">No cached adapters found</option></select></label>`;
|
||||
}
|
||||
const opts = adapters.map(m => {
|
||||
const repo = String(m.repo_id || '');
|
||||
const value = m.is_local_dir && m.path
|
||||
? `${String(m.path || '').replace(/\/+$/, '')}/${repo}`
|
||||
: repo;
|
||||
const short = repo.split('/').pop() || repo;
|
||||
return `<option value="${esc(value)}">${esc(short)}</option>`;
|
||||
}).join('');
|
||||
return `<label class="hwfit-cached-adapter-label ${cls}" style="grid-column:1 / -1;">Cached adapter <select class="hwfit-cached-adapter-select" data-adapter-kind="${esc(kind)}" style="height:30px;width:100%;background:var(--bg);color:var(--fg);border:1px solid var(--border);border-radius:4px;font:inherit;font-size:11px;"><option value="">Choose cached adapter…</option>${opts}</select></label>`;
|
||||
}
|
||||
|
||||
async function _fetchServeRuntimePackage(panel, backend) {
|
||||
const repo = (panel.closest('.doclib-card, .memory-item')?.dataset?.repo) || '';
|
||||
const packageByBackend = {
|
||||
vllm: 'vllm',
|
||||
sglang: 'sglang',
|
||||
llamacpp: 'llama_cpp',
|
||||
mlx: 'mlx_lm',
|
||||
mlx_image: 'mflux',
|
||||
diffusers: 'diffusers',
|
||||
};
|
||||
const packageName = _dependencyPkgForServeBackend(backend, repo) || packageByBackend[backend];
|
||||
if (!packageName) return null;
|
||||
const target = _selectedServeTarget(panel);
|
||||
const params = new URLSearchParams();
|
||||
@@ -681,6 +832,7 @@ async function _fetchServeRuntimePackage(panel, backend) {
|
||||
if (target.port) params.set('ssh_port', target.port);
|
||||
if (target.venv) params.set('venv', target.venv);
|
||||
}
|
||||
if (repo) params.set('model_hint', repo);
|
||||
const res = await fetch('/api/cookbook/packages' + (params.toString() ? '?' + params.toString() : ''), { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
@@ -689,7 +841,7 @@ async function _fetchServeRuntimePackage(panel, backend) {
|
||||
}
|
||||
|
||||
function _runtimeNoteText(backend, pkg, target) {
|
||||
const labels = { vllm: 'vLLM', sglang: 'SGLang', llamacpp: 'llama.cpp', mlx: 'MLX', diffusers: 'Diffusers' };
|
||||
const labels = { vllm: 'vLLM', sglang: 'SGLang', llamacpp: 'llama.cpp', mlx: 'MLX', mlx_image: 'MLX Image', diffusers: 'Diffusers' };
|
||||
const label = labels[backend] || backend;
|
||||
if (!pkg) return `${label} readiness unavailable for ${target.label}.`;
|
||||
const note = pkg.status_note || pkg.update_note || '';
|
||||
@@ -750,6 +902,14 @@ function _isActivelyServing(repoId) {
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function _isIncompleteCachedModel(model) {
|
||||
return !!model && (
|
||||
model.status === 'stalled'
|
||||
|| model.has_incomplete
|
||||
|| (model.status === 'downloading' && !_isActivelyDownloading(model.repo_id))
|
||||
);
|
||||
}
|
||||
|
||||
function _formatGgufSize(bytes) {
|
||||
const n = Number(bytes || 0);
|
||||
if (!Number.isFinite(n) || n <= 0) return '';
|
||||
@@ -964,6 +1124,7 @@ function _rerenderCachedModels() {
|
||||
let html = '';
|
||||
let visibleCount = 0;
|
||||
for (const m of allModels) {
|
||||
if (m.is_adapter && !m.is_diffusion && !m.is_video) continue;
|
||||
if (activeTag && m._tag !== activeTag) continue;
|
||||
if (searchVal && !(m.repo_id || '').toLowerCase().includes(searchVal)) continue;
|
||||
visibleCount++;
|
||||
@@ -1085,7 +1246,9 @@ function _rerenderCachedModels() {
|
||||
const items = [];
|
||||
items.push({ label: _favNow ? 'Unfavorite' : 'Favorite', icon: _favIco, action: 'favorite' });
|
||||
if (m && m.status === 'ready') items.push({ label: 'Serve', icon: _serveIco, action: 'serve' });
|
||||
if (m && m.status === 'downloading') items.push({ label: 'Retry', icon: _retryIco, action: 'retry' });
|
||||
if (m && (m.status === 'downloading' || m.status === 'stalled' || m.has_incomplete)) {
|
||||
items.push({ label: 'Resume download', icon: _retryIco, action: 'retry' });
|
||||
}
|
||||
if (m && m.status === 'ready') items.push({ label: 'Schedule…', icon: _schedIco, action: 'schedule' });
|
||||
items.push({ label: 'Select', icon: _selectIco, action: 'select' });
|
||||
items.push({ label: 'Delete', icon: _deleteIco, action: 'delete', danger: true });
|
||||
@@ -1102,7 +1265,7 @@ function _rerenderCachedModels() {
|
||||
_rerenderCachedModels();
|
||||
}
|
||||
else if (opt.action === 'delete') _deleteCachedModel(repo, item, false, m);
|
||||
else if (opt.action === 'retry') _retryCachedModel(repo, m);
|
||||
else if (opt.action === 'retry') _promptResumeIncompleteModel(m, item);
|
||||
else if (opt.action === 'schedule') {
|
||||
// Same entry point as the ^ button next to Launch — let
|
||||
// cookbookSchedule.js handle it. Expand the panel first
|
||||
@@ -1171,7 +1334,7 @@ function _rerenderCachedModels() {
|
||||
|
||||
// Wire click on card to expand serve panel
|
||||
list.querySelectorAll('.memory-item[data-repo]').forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
item.addEventListener('click', async (e) => {
|
||||
if (e.target.closest('a, .hwfit-cached-menu-btn, .memory-item-btn, .hwfit-serve-panel')) return;
|
||||
if (document.getElementById('hwfit-cache-select')?.classList.contains('active')) return;
|
||||
const repo = item.dataset.repo;
|
||||
@@ -1179,9 +1342,15 @@ function _rerenderCachedModels() {
|
||||
const m = allModels.find(x => x.repo_id === repo);
|
||||
if (!m) return;
|
||||
if (m.status !== 'ready') {
|
||||
if (m.status === 'downloading' && !_isActivelyDownloading(m.repo_id)) {
|
||||
if (m.status === 'downloading' && _isActivelyDownloading(m.repo_id)) {
|
||||
uiModule.showToast?.(`${(m.name || m.repo_id || 'Model').split('/').pop()} is still downloading.`);
|
||||
} else if (_isIncompleteCachedModel(m)) {
|
||||
await _promptResumeIncompleteModel(m, item);
|
||||
} else if (m.status === 'downloading') {
|
||||
uiModule.showToast?.('Refreshing cached model status…');
|
||||
_fetchCachedModels(true);
|
||||
} else {
|
||||
uiModule.showToast?.(`${(m.name || m.repo_id || 'Model').split('/').pop()} is not ready yet.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1245,11 +1414,12 @@ function _rerenderCachedModels() {
|
||||
const _backendChoices = _backendChoicesForTarget(_serveTarget);
|
||||
const _allowedBackends = new Set(_backendChoices.map(([v]) => v));
|
||||
const detectedBackend = _detectBackend(m).backend;
|
||||
let defaultBackend = (_repoForcedBackend && ss.backend && _allowedBackends.has(ss.backend))
|
||||
const _imageBackend = detectedBackend === 'mlx_image' || detectedBackend === 'diffusers';
|
||||
let defaultBackend = (!_imageBackend && _repoForcedBackend && ss.backend && _allowedBackends.has(ss.backend))
|
||||
? ss.backend
|
||||
: detectedBackend;
|
||||
if (!_allowedBackends.has(defaultBackend)) defaultBackend = _backendChoices[0]?.[0] || detectedBackend;
|
||||
const savedMatchesBackend = _repoForcedBackend || (ss.backend || 'vllm') === detectedBackend;
|
||||
const savedMatchesBackend = !_imageBackend && (_repoForcedBackend || (ss.backend || 'vllm') === detectedBackend);
|
||||
const sv = (k, def) => (ss[k] !== undefined && savedMatchesBackend) ? ss[k] : def;
|
||||
const defaultTp = defaultBackend === 'llamacpp' ? '1' : sv('tp', _isMiniMaxMSeries ? '8' : '1');
|
||||
const detectedGpuIds = _allGpuIds(_getGpuToggleTotal?.());
|
||||
@@ -1452,6 +1622,8 @@ function _rerenderCachedModels() {
|
||||
panelHtml += `<label class="hwfit-backend-vllm">${_l('Attention','vLLM VLLM_ATTENTION_BACKEND. auto = vLLM picks (often FLASHINFER, which JITs and can fail on old nvcc). FLASH_ATTN skips the JIT entirely.')}<select class="hwfit-sf" data-field="vllm_attn_backend" style="height:32px;">${vllmAttnBackendOpts}</select></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-vllm">${_l('Block Size','vLLM --block-size. Controls KV-cache block granularity. Leave blank for runtime default; some sparse-attention or custom runtimes need a specific value.')}<input type="text" class="hwfit-sf" data-field="vllm_block_size" value="${esc(svm('vllm_block_size', _isMiniMaxM3 ? '128' : ''))}" placeholder="auto" /></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-vllm">${_l('Swap','vLLM CPU swap space in GB. Blank/off omits the flag; enter a positive number only for older vLLM runtimes that support --swap-space.')}<input type="text" class="hwfit-sf" data-field="swap" value="${esc(sv('swap', ''))}" placeholder="off" /></label>`;
|
||||
panelHtml += _cachedAdapterSelectHtml('vllm_lora_modules', repo);
|
||||
panelHtml += `<label class="hwfit-backend-vllm" style="grid-column:1 / -1;">${_l('LoRA Modules','vLLM LoRA modules, one per line or comma-separated, using name=path. Adds --enable-lora --lora-modules.')}<input type="text" class="hwfit-sf" data-field="vllm_lora_modules" value="${esc(sv('vllm_lora_modules', ''))}" placeholder="style=/path/to/lora or style=org/repo" style="width:100%;" /></label>`;
|
||||
{
|
||||
const _envPresetDefault = _isMiniMaxM3 ? 'minimax_m3_cuda' : '';
|
||||
const _envPresetVal = svm('vllm_env_preset', _envPresetDefault);
|
||||
@@ -1471,15 +1643,36 @@ function _rerenderCachedModels() {
|
||||
panelHtml += `<label class="hwfit-backend-vllm hwfit-backend-sglang hwfit-extra-env-label">${_l('Env','Extra KEY=VALUE env-var pairs prepended to the launch (space-separated). The Env Preset above covers the usual MiniMax M3 values; use this for additional overrides.')}<input type="text" class="hwfit-sf" data-field="extra_env" value="${esc(svm('extra_env', sv('extra_env','')))}" placeholder="NCCL_P2P_DISABLE=1" style="width:100%;" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Row 2b: Diffusers settings
|
||||
const diffDefaultNegative = 'low quality, blurry, out of focus, deformed, distorted, disfigured, unfinished, smudged, watermark, artifacts';
|
||||
const diffDtypeOpts = ['bfloat16','float16','float32'].map(d => `<option value="${d}"${sv('diff_dtype','bfloat16')===d?' selected':''}>${d}</option>`).join('');
|
||||
const deviceMapOpts = ['balanced','auto','sequential'].map(d => `<option value="${d}"${sv('diff_device_map','balanced')===d?' selected':''}>${d}</option>`).join('');
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-diffusers hwfit-diff-settings-row">`;
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-diffusers hwfit-backend-mlx_image hwfit-diff-settings-row">`;
|
||||
panelHtml += `<label>Dtype${_h('Precision. bfloat16 recommended for Flux, float16 for SD')} <select class="hwfit-sf" data-field="diff_dtype">${diffDtypeOpts}</select></label>`;
|
||||
panelHtml += `<label>Device Map${_h('How to place model on GPUs. balanced = split evenly')} <select class="hwfit-sf" data-field="diff_device_map">${deviceMapOpts}</select></label>`;
|
||||
panelHtml += `<label>Steps${_h('Default inference steps. More = better quality, slower')} <input type="text" class="hwfit-sf" data-field="diff_steps" value="${esc(sv('diff_steps', ''))}" placeholder="auto" /></label>`;
|
||||
panelHtml += `<label>Steps${_h('Default inference steps. More = better quality, slower. Override with the model card recommendation when needed.')} <input type="text" class="hwfit-sf" data-field="diff_steps" value="${esc(sv('diff_steps', '20'))}" placeholder="20" /></label>`;
|
||||
panelHtml += `<label>Guidance${_h('Classifier-free guidance scale. Override with the model card recommended value when available.')} <input type="text" class="hwfit-sf" data-field="diff_guidance_scale" value="${esc(sv('diff_guidance_scale', '3.5'))}" placeholder="3.5" /></label>`;
|
||||
panelHtml += `<label>Width${_h('Default output width')} <input type="text" class="hwfit-sf" data-field="diff_width" value="${esc(sv('diff_width', ''))}" placeholder="1024" /></label>`;
|
||||
panelHtml += `<label>Height${_h('Default output height')} <input type="text" class="hwfit-sf" data-field="diff_height" value="${esc(sv('diff_height', ''))}" placeholder="1024" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
panelHtml += `<div class="hwfit-serve-row hwfit-backend-diffusers hwfit-backend-mlx_image hwfit-diff-adapters-row">`;
|
||||
panelHtml += _cachedAdapterSelectHtml('diff_lora', repo);
|
||||
panelHtml += `<label class="hwfit-backend-diffusers" style="grid-column:1 / -1;">Negative${_h('Default negative prompt. Adds --negative-prompt for pipelines that support it. Edit or clear this per model.')} <input type="text" class="hwfit-sf" data-field="diff_negative_prompt" value="${esc(sv('diff_negative_prompt', diffDefaultNegative))}" placeholder="${esc(diffDefaultNegative)}" style="width:100%;" /></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-diffusers" style="grid-column:1 / -1;">LoRA${_h('Diffusers LoRA file/path(s), comma or newline separated. Adds --lora.')} <input type="text" class="hwfit-sf" data-field="diff_lora" value="${esc(sv('diff_lora', ''))}" placeholder="/path/adapter.safetensors or org/repo" style="width:100%;" /></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-diffusers">Scale${_h('Diffusers LoRA scale. Adds --lora-scale.')} <input type="text" class="hwfit-sf" data-field="diff_lora_scale" value="${esc(sv('diff_lora_scale', ''))}" placeholder="1.0" /></label>`;
|
||||
{
|
||||
const _mlxBase = sv('mlx_base_model', '');
|
||||
panelHtml += `<label class="hwfit-backend-mlx_image">Base model${_h('Optional runtime base model/family override from the model card. Adds --base-model. This is not a LoRA/adaptor.')} <input type="text" class="hwfit-sf" data-field="mlx_base_model" value="${esc(_mlxBase)}" placeholder="auto" /></label>`;
|
||||
}
|
||||
{
|
||||
const _mlxStyle = sv('mlx_lora_style', '');
|
||||
const _styleOpts = ['', 'couple', 'font', 'home', 'identity', 'illustration', 'portrait', 'ppt', 'sandstorm', 'sparklers', 'storyboard']
|
||||
.map(v => `<option value="${v}"${_mlxStyle === v ? ' selected' : ''}>${v || 'none'}</option>`).join('');
|
||||
panelHtml += `<label class="hwfit-backend-mlx_image">Style${_h('mflux built-in LoRA style. Adds --lora-style.')} <select class="hwfit-sf" data-field="mlx_lora_style">${_styleOpts}</select></label>`;
|
||||
}
|
||||
panelHtml += _cachedAdapterSelectHtml('mlx_lora_paths', repo);
|
||||
panelHtml += `<label class="hwfit-backend-mlx_image" style="grid-column:1 / -1;">LoRA Paths${_h('mflux LoRA paths/repos, comma or newline separated. Adds --lora-paths.')} <input type="text" class="hwfit-sf" data-field="mlx_lora_paths" value="${esc(sv('mlx_lora_paths', ''))}" placeholder="org/lora or repo:file.safetensors" style="width:100%;" /></label>`;
|
||||
panelHtml += `<label class="hwfit-backend-mlx_image" style="grid-column:1 / -1;">LoRA Scales${_h('mflux LoRA scales matching paths. Space/comma/newline separated. Adds --lora-scales.')} <input type="text" class="hwfit-sf" data-field="mlx_lora_scales" value="${esc(sv('mlx_lora_scales', ''))}" placeholder="0.8, 1.0" style="width:100%;" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Row 3: Advanced toggles for vLLM/SGLang. Several concepts overlap,
|
||||
// but the actual flags differ; keep labels backend-neutral where a
|
||||
// shared checkbox maps to different runtime flags.
|
||||
@@ -1581,11 +1774,11 @@ function _rerenderCachedModels() {
|
||||
panelHtml += `<label class="hwfit-sf-cb hwfit-spec-group"><input type="checkbox" class="hwfit-sf" data-field="llama_speculative_mtp"${sv('llama_speculative_mtp',false)?' checked':''} /> MTP Spec${_h('llama.cpp native MTP speculative decoding: --spec-type draft-mtp. Requires a GGUF with MTP heads.')} <input type="number" class="hwfit-sf hwfit-spec-tokens hwfit-spec-tokens-bare" data-field="llama_spec_tokens" value="${esc(sv('llama_spec_tokens', '3'))}" min="1" max="10" title="--spec-draft-n-max" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Row 3b: Checkboxes (diffusers)
|
||||
panelHtml += `<div class="hwfit-serve-checks hwfit-backend-diffusers hwfit-diff-checks-row">`;
|
||||
panelHtml += `<div class="hwfit-serve-checks hwfit-backend-diffusers hwfit-backend-mlx_image hwfit-diff-checks-row">`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="diff_offload"${sv('diff_offload',false)?' checked':''} /> CPU Offload${_h('Offload parts of model to CPU RAM to save VRAM. Slower but fits larger models')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="diff_attention_slicing"${sv('diff_attention_slicing',false)?' checked':''} /> Attention Slicing${_h('Slice attention computation to reduce peak VRAM. Slower')}</label>`;
|
||||
panelHtml += `<label class="hwfit-sf-cb"><input type="checkbox" class="hwfit-sf" data-field="diff_vae_slicing"${sv('diff_vae_slicing',false)?' checked':''} /> VAE Slicing${_h('Process VAE in slices. Reduces VRAM for high-res images')}</label>`;
|
||||
panelHtml += `</div><div class="hwfit-serve-row hwfit-backend-diffusers hwfit-diff-harmonize-row">`;
|
||||
panelHtml += `</div><div class="hwfit-serve-row hwfit-backend-diffusers hwfit-backend-mlx_image hwfit-diff-harmonize-row">`;
|
||||
panelHtml += `<label>Harmonize GPU${_h('Separate GPU for img2img/harmonize. Leave empty to use same GPU')}<input type="text" class="hwfit-sf" data-field="diff_harmonize_gpu" value="${esc(sv('diff_harmonize_gpu', ''))}" placeholder="auto" style="width:50px;" /></label>`;
|
||||
panelHtml += `</div>`;
|
||||
// Model-specific optimizations. The checks row always renders for the
|
||||
@@ -1747,6 +1940,8 @@ function _rerenderCachedModels() {
|
||||
let fit = null;
|
||||
if (backend === 'vllm' || backend === 'sglang') {
|
||||
fit = _estimateVllmContextFit(m, f, panel._modelCtxMax, panel._modelWeightsGb, panel._fitSystem);
|
||||
} else if (backend === 'mlx') {
|
||||
fit = _estimateMlxContextFit(m, f, panel._modelCtxMax, panel._modelWeightsGb, panel._fitSystem);
|
||||
} else if (backend === 'llamacpp' || backend === 'ollama') {
|
||||
const ggufGb = _selectedGgufSizeGb(m, f.gguf_file);
|
||||
fit = _estimateLlamaContextFit(m, f, panel._modelCtxMax, ggufGb || panel._modelWeightsGb, panel._fitSystem, panel._contextProfileData);
|
||||
@@ -1772,6 +1967,8 @@ function _rerenderCachedModels() {
|
||||
: 'selected GPU memory';
|
||||
_ctxAutoNote.title = backend === 'llamacpp' || backend === 'ollama'
|
||||
? `Estimated from scanned GGUF/model size, trained context limit, and ${_llamaMemoryLabel} for llama.cpp KV cache.`
|
||||
: backend === 'mlx'
|
||||
? `MLX-LM server does not expose a context-length flag; Cookbook maps this estimate to MLX --max-tokens using scanned unified memory and model size.`
|
||||
: `Estimated from model size, selected GPU VRAM, GPU utilization, TP, and KV dtype.`;
|
||||
}
|
||||
if (apply && _ctxEl0.dataset.autoCtx === '1') {
|
||||
@@ -1951,6 +2148,7 @@ function _rerenderCachedModels() {
|
||||
vllm: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4l7 16 7-16"/><path d="M14 4l4 9 3-9"/></svg>',
|
||||
sglang: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;mask:url(/static/icons/sglang-mark.png) center/contain no-repeat;"></span>',
|
||||
mlx: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 18V6l4 7 4-7v12"/><path d="M16 6v12"/><path d="M20 6v12"/></svg>',
|
||||
mlx_image: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.5"/><path d="M21 15l-5-5L5 20"/><path d="M17.5 4v4M15.5 6h4"/></svg>',
|
||||
llamacpp: '<svg width="14" height="14" viewBox="0 0 600 600" fill="none" aria-hidden="true"><path d="M600 392L504.249 558L504.137 557.929C487.252 584.069 458.193 600 426.864 600H120L240 392H600Z" fill="currentColor"/><path d="M240 392H0L199.602 46.0254C216.032 17.5463 246.411 0 279.29 0H466.154L240 392Z" fill="currentColor"/></svg>',
|
||||
ollama: '<span aria-hidden="true" style="display:block;width:14px;height:14px;background:currentColor;-webkit-mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;mask:url(/static/icons/ollama-mark-crop.png) center/contain no-repeat;"></span>',
|
||||
diffusers: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></svg>',
|
||||
@@ -2064,7 +2262,7 @@ function _rerenderCachedModels() {
|
||||
const backend = panel.querySelector('[data-field="backend"]')?.value || 'vllm';
|
||||
const noteText = note.querySelector('.hwfit-serve-runtime-text');
|
||||
const _writeNote = (s) => { if (noteText) noteText.textContent = s; else note.textContent = s; };
|
||||
if (!['vllm', 'sglang', 'llamacpp', 'mlx', 'diffusers'].includes(backend)) {
|
||||
if (!['vllm', 'sglang', 'llamacpp', 'mlx', 'mlx_image', 'diffusers'].includes(backend)) {
|
||||
note.style.display = 'none';
|
||||
_writeNote('');
|
||||
return;
|
||||
@@ -2104,8 +2302,8 @@ function _rerenderCachedModels() {
|
||||
// recipe panel for this backend so the user has one click
|
||||
// to the fix instead of hunting for the right row.
|
||||
if (noteText) {
|
||||
const pkgName = pkg?.name || ({ vllm: 'vllm', sglang: 'sglang', llamacpp: 'llama_cpp', mlx: 'mlx_lm', diffusers: 'diffusers' }[backend]);
|
||||
const repo = (panel.closest('.doclib-card, .memory-item')?.dataset?.repo) || '';
|
||||
const pkgName = pkg?.name || _dependencyPkgForServeBackend(backend, repo);
|
||||
const link = document.createElement('a');
|
||||
link.href = '#';
|
||||
link.textContent = ' Install in Dependencies →';
|
||||
@@ -2159,15 +2357,16 @@ function _rerenderCachedModels() {
|
||||
});
|
||||
} else {
|
||||
const fields = {
|
||||
backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp' : cmd.includes('mlx_lm.server') ? 'mlx' : cmd.includes('diffusion_server') ? 'diffusers' : cmd.includes('sglang') ? 'sglang' : cmd.includes('ollama') ? 'ollama' : 'vllm',
|
||||
backend: cmd.includes('llama_cpp') || cmd.includes('llama-server') ? 'llamacpp' : cmd.includes('mlx_image_server') ? 'mlx_image' : cmd.includes('mlx_lm.server') ? 'mlx' : cmd.includes('diffusion_server') ? 'diffusers' : cmd.includes('sglang') ? 'sglang' : cmd.includes('ollama') ? 'ollama' : 'vllm',
|
||||
port: _ex(/--port\s+(\d+)/) || '8000',
|
||||
tp: _ex(/--tensor-parallel-size\s+(\d+)/) || '1',
|
||||
ctx: _ex(/--max-model-len\s+(\d+)/) || _ex(/--n_ctx\s+(\d+)/) || _ex(/-c\s+(\d+)/) || '8192',
|
||||
ctx: _ex(/--max-model-len\s+(\d+)/) || _ex(/--context-length\s+(\d+)/) || _ex(/--max-tokens\s+(\d+)/) || _ex(/--n_ctx\s+(\d+)/) || _ex(/-c\s+(\d+)/) || '8192',
|
||||
gpu_mem: _ex(/--gpu-memory-utilization\s+([\d.]+)/) || '0.90',
|
||||
swap: _ex(/--swap-space\s+(\d+)/) || '',
|
||||
dtype: _ex(/--dtype\s+(\w+)/) || 'auto',
|
||||
vllm_kv_cache_dtype: _ex(/--kv-cache-dtype\s+([\w.-]+)/) || 'auto',
|
||||
max_seqs: _ex(/--max-num-seqs\s+(\d+)/) || '',
|
||||
vllm_lora_modules: _ex(/--lora-modules\s+(.+?)(?:\s+--|$)/) || '',
|
||||
cache_type: _ex(/(?:--cache-type-k|-ctk)\s+(\S+)/) || '',
|
||||
llama_fit: _ex(/(?:--fit|-fit)\s+(on|off)/) || '',
|
||||
llama_split_mode: _ex(/(?:--split-mode|-sm)\s+(none|layer|row|tensor)/) || '',
|
||||
@@ -2177,6 +2376,14 @@ function _rerenderCachedModels() {
|
||||
llama_batch_size: _ex(/(?:--batch-size|-b)\s+(\d+)/) || '',
|
||||
llama_ubatch_size: _ex(/(?:--ubatch-size|-ub)\s+(\d+)/) || '',
|
||||
llama_spec_tokens: _ex(/--spec-draft-n-max\s+(\d+)/) || '3',
|
||||
diff_lora: (_ex(/--lora\s+'([^']*)'/) || _ex(/--lora\s+(\S+)/) || '').replace(/,/g, '\n'),
|
||||
diff_lora_scale: _ex(/--lora-scale\s+([\d.]+)/) || '',
|
||||
diff_guidance_scale: _ex(/--guidance-scale\s+([\d.]+)/) || '',
|
||||
diff_negative_prompt: _ex(/--negative-prompt\s+'([^']*)'/) || _ex(/--negative-prompt\s+(.+?)(?:\s+--|$)/) || '',
|
||||
mlx_base_model: _ex(/--base-model\s+'?([^'\s]+)'?/) || '',
|
||||
mlx_lora_style: _ex(/--lora-style\s+'?([^'\s]+)'?/) || '',
|
||||
mlx_lora_paths: (_ex(/--lora-paths\s+(.+?)(?:\s+--|$)/) || '').replace(/'\s+'/g, '\n').replace(/^'|'$/g, ''),
|
||||
mlx_lora_scales: (_ex(/--lora-scales\s+(.+?)(?:\s+--|$)/) || '').replace(/'\s+'/g, '\n').replace(/^'|'$/g, ''),
|
||||
venv: p.envPath || '',
|
||||
};
|
||||
const checks = {
|
||||
@@ -2268,8 +2475,9 @@ function _rerenderCachedModels() {
|
||||
const presets = _loadPresets();
|
||||
const modelSlots = _presetsForModel(presets, repo);
|
||||
// Compute the current launch command first so we can detect a no-op save.
|
||||
updateCmd();
|
||||
const cmd = panel._cmd;
|
||||
if (!_cmdManuallyEdited) updateCmd();
|
||||
const cmdBox = panel.querySelector('.hwfit-serve-cmd');
|
||||
const cmd = _normalizeServeCmdForLaunch((_cmdManuallyEdited && cmdBox) ? cmdBox.value : panel._cmd);
|
||||
// Already saved? If an existing preset for this model has the identical
|
||||
// launch command, don't make a duplicate — tell the user via a popup.
|
||||
const _norm = s => String(s || '').replace(/\s+/g, ' ').trim();
|
||||
@@ -2289,6 +2497,8 @@ function _rerenderCachedModels() {
|
||||
if (el.type === 'checkbox') fields[el.dataset.field] = el.checked;
|
||||
else fields[el.dataset.field] = el.value;
|
||||
});
|
||||
if (_cmdManuallyEdited) fields._manual_cmd = cmd;
|
||||
else delete fields._manual_cmd;
|
||||
presets.push(_redactServeStateForStorage({ name: shortName, model: repo, cmd, remoteHost: host, port: fields.port || '8000', label, fields }));
|
||||
_savePresets(presets);
|
||||
uiModule.showToast(`Saved "${label}"`);
|
||||
@@ -2513,6 +2723,7 @@ function _rerenderCachedModels() {
|
||||
menu.appendChild(mk('Cancel', 'dropdown-cancel-mobile', () => {}));
|
||||
const r = _launchMoreBtn.getBoundingClientRect();
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.zIndex = String(topPortalZ());
|
||||
menu.style.right = (window.innerWidth - r.right) + 'px';
|
||||
document.body.appendChild(menu);
|
||||
{
|
||||
@@ -2559,6 +2770,7 @@ function _rerenderCachedModels() {
|
||||
menu.appendChild(mk('Cancel', 'dropdown-cancel-mobile', () => {}));
|
||||
const r = _splitArrow.getBoundingClientRect();
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.zIndex = String(topPortalZ());
|
||||
menu.style.right = (window.innerWidth - r.right) + 'px';
|
||||
document.body.appendChild(menu);
|
||||
// Default open BELOW, but if there's no room (esp. on mobile where
|
||||
@@ -2916,6 +3128,31 @@ function _rerenderCachedModels() {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.hwfit-cached-adapter-select').forEach(sel => {
|
||||
sel.addEventListener('change', () => {
|
||||
const repoId = String(sel.value || '').trim();
|
||||
if (!repoId) return;
|
||||
const kind = sel.dataset.adapterKind || '';
|
||||
const target = panel.querySelector(`[data-field="${kind}"]`);
|
||||
if (!target) return;
|
||||
const current = String(target.value || '').trim();
|
||||
let next = repoId;
|
||||
if (kind === 'vllm_lora_modules') {
|
||||
const name = repoId.split('/').pop().replace(/[^A-Za-z0-9_.-]+/g, '_') || 'adapter';
|
||||
next = `${name}=${repoId}`;
|
||||
}
|
||||
if (current) {
|
||||
const lines = current.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
|
||||
if (!lines.includes(next)) lines.push(next);
|
||||
target.value = lines.join('\n');
|
||||
} else {
|
||||
target.value = next;
|
||||
}
|
||||
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
updateCmd();
|
||||
});
|
||||
});
|
||||
// llama.cpp CPU/GPU/Unified mode-toggle wiring. Clicking a mode
|
||||
// flips the .active classes + marker class (so the sliding
|
||||
// pill matches Agent/Chat), updates the hidden data-field input,
|
||||
@@ -2994,6 +3231,14 @@ function _rerenderCachedModels() {
|
||||
// Track manual edits
|
||||
let _cmdManuallyEdited = false;
|
||||
const _cmdTextarea = panel.querySelector('.hwfit-serve-cmd');
|
||||
const _savedManualCmd = String(svm('_manual_cmd', '') || '').trim();
|
||||
if (_cmdTextarea && _savedManualCmd) {
|
||||
panel._cmd = _savedManualCmd;
|
||||
_cmdTextarea.value = _formatServeCmdPreview(_savedManualCmd);
|
||||
_cmdTextarea.style.height = 'auto';
|
||||
_cmdTextarea.style.height = _cmdTextarea.scrollHeight + 'px';
|
||||
_cmdManuallyEdited = true;
|
||||
}
|
||||
if (_cmdTextarea) _cmdTextarea.addEventListener('input', () => { _cmdManuallyEdited = true; });
|
||||
|
||||
// Cancel button — collapses the serve config panel (same effect as
|
||||
@@ -3078,8 +3323,9 @@ function _rerenderCachedModels() {
|
||||
// all whitespace to single spaces before launch — same effect as the
|
||||
// user manually re-flowing the textarea, no behavior change.
|
||||
const _rawLaunchCmd = (_cmdManuallyEdited && _cmdTextarea) ? _cmdTextarea.value : panel._cmd;
|
||||
const launchCmd = _normalizeServeCmdForLaunch(_rawLaunchCmd);
|
||||
let launchCmd = _normalizeServeCmdForLaunch(_rawLaunchCmd);
|
||||
const serveState = {};
|
||||
let launchAnyway = false;
|
||||
panel.querySelectorAll('.hwfit-sf').forEach(el => {
|
||||
if (el.type === 'checkbox') serveState[el.dataset.field] = el.checked;
|
||||
else serveState[el.dataset.field] = el.value;
|
||||
@@ -3091,7 +3337,7 @@ function _rerenderCachedModels() {
|
||||
uiModule.showToast('Vision is checked, but no mmproj projector is in the launch command. Refresh cached models after downloading mmproj, or add --mmproj manually.', 8000);
|
||||
return;
|
||||
}
|
||||
if (serveState.backend === 'diffusers' && _remoteWindowsDiffusersUnsupported(launchTarget)) {
|
||||
if ((serveState.backend === 'diffusers' || serveState.backend === 'mlx_image') && _remoteWindowsDiffusersUnsupported(launchTarget)) {
|
||||
_restoreLaunchBtn();
|
||||
uiModule.showToast('Diffusers serving is not supported on remote Windows servers yet. Use local Windows or a Linux server.', 9000);
|
||||
return;
|
||||
@@ -3113,37 +3359,52 @@ function _rerenderCachedModels() {
|
||||
// Only block when the new model's port genuinely collides with
|
||||
// a running serve. Different ports coexist fine (issue #4507).
|
||||
if (_active.length) {
|
||||
const _newPort = (launchCmd.match(/--port[=\s]+(\d+)/) || [])[1] || '';
|
||||
const _newPort = _serveCmdPort(launchCmd);
|
||||
const _clashing = _newPort
|
||||
? _active.filter(t => _runningMod._taskPort(t) === _newPort)
|
||||
: _active;
|
||||
if (_clashing.length) {
|
||||
const _names = _clashing.map(t => t.payload?.repo_id || t.repo || t.name || '?').filter(Boolean);
|
||||
const _portNote = _newPort ? ` on port ${_newPort}` : '';
|
||||
const _ok = await window.styledConfirm(
|
||||
`${_clashing.length} model${_clashing.length === 1 ? '' : 's'} already serving on ${_hostStr || 'local'} (${_names.join(', ')})${_portNote}. Stop it and launch this one?`,
|
||||
{ title: _newPort ? `Port ${_newPort} in use` : 'Server already running', confirmText: 'Stop & launch', cancelText: 'Cancel' },
|
||||
const _choice = await window.styledConfirm(
|
||||
`${_clashing.length} model${_clashing.length === 1 ? '' : 's'} already serving on ${_hostStr || 'local'} (${_names.join(', ')})${_portNote}. Stop it first, or launch anyway?`,
|
||||
{ title: _newPort ? `Port ${_newPort} in use` : 'Server already running', confirmText: 'Stop & launch', alternateText: 'Launch anyway', cancelText: 'Cancel' },
|
||||
);
|
||||
if (!_ok) { _restoreLaunchBtn(); return; }
|
||||
if (!_choice) { _restoreLaunchBtn(); return; }
|
||||
if (_choice === 'alternate') {
|
||||
launchAnyway = true;
|
||||
const _oldPort = _newPort || _serveCmdPort(launchCmd);
|
||||
const _nextPort = _nextServeLaunchPort(_oldPort, _runningMod, _hostStr, _serverKeyStr);
|
||||
if (_oldPort && _nextPort && _nextPort !== _oldPort) {
|
||||
launchCmd = _replaceServeCmdPort(launchCmd, _nextPort);
|
||||
serveState.port = _nextPort;
|
||||
panel._cmd = launchCmd;
|
||||
if (_cmdTextarea) _cmdTextarea.value = launchCmd;
|
||||
uiModule.showToast(`Launching anyway on port ${_nextPort}. Existing serve stays on ${_oldPort}.`, 7000);
|
||||
} else {
|
||||
uiModule.showToast('Launching anyway. If the port is already occupied, the new serve may fail.', 6000);
|
||||
}
|
||||
} else {
|
||||
// Kill each clashing serve; prefer the rendered Stop button so
|
||||
// endpoint cleanup + Ollama unload run normally. Fall back to
|
||||
// a raw tmux kill when the Active tab isn't in the DOM.
|
||||
for (const t of _clashing) {
|
||||
try {
|
||||
const _el = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
|
||||
const _btn = _el?.querySelector('.cookbook-task-action-stop');
|
||||
if (_btn) {
|
||||
_btn.click();
|
||||
} else if (_runningMod._tmuxGracefulKill) {
|
||||
await fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _runningMod._tmuxGracefulKill(t) }),
|
||||
});
|
||||
}
|
||||
} catch (_killErr) { /* best-effort */ }
|
||||
for (const t of _clashing) {
|
||||
try {
|
||||
const _el = document.querySelector(`.cookbook-task[data-task-id="${t.sessionId}"]`);
|
||||
const _btn = _el?.querySelector('.cookbook-task-action-stop');
|
||||
if (_btn) {
|
||||
_btn.click();
|
||||
} else if (_runningMod._tmuxGracefulKill) {
|
||||
await fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: _runningMod._tmuxGracefulKill(t) }),
|
||||
});
|
||||
}
|
||||
} catch (_killErr) { /* best-effort */ }
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 2500));
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 2500));
|
||||
}
|
||||
}
|
||||
} catch (_e) { /* best-effort */ }
|
||||
@@ -3387,6 +3648,8 @@ function _rerenderCachedModels() {
|
||||
const byRepo = (cur && cur._byRepo && typeof cur._byRepo === 'object') ? cur._byRepo : {};
|
||||
const _saved = { ...serveState, _forceBackend: true };
|
||||
delete _saved._replaceTaskId;
|
||||
if (_cmdManuallyEdited) _saved._manual_cmd = launchCmd;
|
||||
else delete _saved._manual_cmd;
|
||||
byRepo[repo] = _saved;
|
||||
localStorage.setItem(SERVE_STATE_KEY, JSON.stringify(_redactServeStateForStorage({ _byRepo: byRepo, _lastUsed: _saved })));
|
||||
} catch {}
|
||||
@@ -3446,7 +3709,7 @@ function _rerenderCachedModels() {
|
||||
// Pass the exact form values so the running task can be re-opened
|
||||
// in the Serve panel pre-filled with these settings (Edit button).
|
||||
const taskDisplayName = _serveTaskDisplayName(shortName, m, serveState);
|
||||
await _launchServeTask(taskDisplayName, repo, launchCmd, serveState, serveHost, { serverKey: serveServerKey, serverName: serveServerName });
|
||||
await _launchServeTask(taskDisplayName, repo, launchCmd, serveState, serveHost, { serverKey: serveServerKey, serverName: serveServerName, launchAnyway });
|
||||
});
|
||||
} finally {
|
||||
_envState.env = origEnv;
|
||||
@@ -3481,8 +3744,12 @@ function _rerenderCachedModels() {
|
||||
// Resolve the host the cached list was scanned from, mirroring
|
||||
// _fetchCachedModels — so a delete targets the SAME machine the model
|
||||
// actually lives on, not just the globally-selected serve host.
|
||||
function _resolveCacheHost() {
|
||||
function _serverFromCacheSelection() {
|
||||
let host = _envState.remoteHost || '';
|
||||
let server = host
|
||||
? (_envState.servers || []).find(s => s.host === host) || null
|
||||
: ((_envState.servers || []).find(s => !s.host || s.host === 'local') || null);
|
||||
let key = '';
|
||||
const cacheSrv = document.getElementById('hwfit-cache-server');
|
||||
|
||||
function _serverByCacheValue(val) {
|
||||
@@ -3496,14 +3763,25 @@ function _resolveCacheHost() {
|
||||
|
||||
if (cacheSrv) {
|
||||
const val = cacheSrv.value;
|
||||
key = val || '';
|
||||
if (val === 'local') {
|
||||
host = '';
|
||||
server = (_envState.servers || []).find(s => !s.host || s.host === 'local') || null;
|
||||
} else {
|
||||
const s = _serverByCacheValue(val);
|
||||
if (s) host = s.host;
|
||||
if (s) {
|
||||
host = s.host || '';
|
||||
server = s;
|
||||
key = _serverKey?.(s) || val || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
return host;
|
||||
|
||||
return { host, server, key };
|
||||
}
|
||||
|
||||
function _resolveCacheHost() {
|
||||
return _serverFromCacheSelection().host || '';
|
||||
}
|
||||
|
||||
async function _deleteCachedModel(repo, itemEl, skipConfirm = false, model = null) {
|
||||
@@ -3628,27 +3906,55 @@ async function _deleteCachedModel(repo, itemEl, skipConfirm = false, model = nul
|
||||
}
|
||||
}
|
||||
|
||||
async function _promptResumeIncompleteModel(m, itemEl = null) {
|
||||
const repo = m?.repo_id || itemEl?.dataset?.repo || '';
|
||||
if (!repo) return;
|
||||
const short = (m?.name || repo).split('/').pop();
|
||||
if (_isActivelyDownloading(repo)) {
|
||||
uiModule.showToast?.(`${short} is already downloading.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await uiModule.styledConfirm(
|
||||
`${short} is not finished downloading.\n\nResume the download on the selected cache server?`,
|
||||
{ confirmText: 'Resume download', cancelText: 'Not now' }
|
||||
);
|
||||
if (!ok) return;
|
||||
uiModule.showToast?.(`Resuming ${short}…`);
|
||||
_retryCachedModel(repo, m);
|
||||
}
|
||||
|
||||
function _retryCachedModel(repo, m) {
|
||||
const payload = { repo_id: repo };
|
||||
if (_envState.hfToken) payload.hf_token = _envState.hfToken;
|
||||
const _target = _selectedServeTarget(document.getElementById('cookbook-modal') || document);
|
||||
const _target = _serverFromCacheSelection();
|
||||
const srv = _target.server || {};
|
||||
if (_target.host) {
|
||||
payload.remote_host = _target.host;
|
||||
if (_target.port) payload.ssh_port = _target.port;
|
||||
if (_target.key && _target.key !== 'local') payload.remote_server_key = _target.key;
|
||||
if (srv.name) payload.remote_server_name = srv.name;
|
||||
const port = srv.port || _getPort(_target.host);
|
||||
if (port) payload.ssh_port = port;
|
||||
}
|
||||
if (_target.platform) payload.platform = _target.platform;
|
||||
if (_isWindows()) {
|
||||
if (_envState.env === 'venv' && _envState.envPath) {
|
||||
payload.env_prefix = '& ' + _psQuote(_envState.envPath.endsWith('\\Scripts\\Activate.ps1') ? _envState.envPath : _envState.envPath + '\\Scripts\\Activate.ps1');
|
||||
} else if (_envState.env === 'conda' && _envState.envPath) {
|
||||
payload.env_prefix = 'conda activate ' + _psQuote(_envState.envPath);
|
||||
const platform = _target.host ? (srv.platform || _getPlatform(_target.host) || '') : (_envState.hostPlatform || '');
|
||||
if (platform) payload.platform = platform;
|
||||
const env = _target.host ? (srv.env || 'none') : (_envState.env || 'none');
|
||||
const envPath = _target.host ? (srv.envPath || '') : (_envState.envPath || '');
|
||||
const downloadDir = srv.downloadDir || (m?.is_local_dir && m?.path ? m.path : '');
|
||||
if (downloadDir) payload.local_dir = _normalizeCookbookModelDir(downloadDir);
|
||||
payload.disable_hf_transfer = true;
|
||||
if (platform === 'windows') {
|
||||
if (env === 'venv' && envPath) {
|
||||
payload.env_prefix = '& ' + _psQuote(envPath.endsWith('\\Scripts\\Activate.ps1') ? envPath : envPath + '\\Scripts\\Activate.ps1');
|
||||
} else if (env === 'conda' && envPath) {
|
||||
payload.env_prefix = 'conda activate ' + _psQuote(envPath);
|
||||
}
|
||||
} else {
|
||||
if (_envState.env === 'venv' && _envState.envPath) {
|
||||
const p = _envState.envPath;
|
||||
if (env === 'venv' && envPath) {
|
||||
const p = envPath;
|
||||
payload.env_prefix = 'source ' + _shellQuote(p.endsWith('/bin/activate') ? p : p + '/bin/activate');
|
||||
} else if (_envState.env === 'conda' && _envState.envPath) {
|
||||
payload.env_prefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
|
||||
} else if (env === 'conda' && envPath) {
|
||||
payload.env_prefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(envPath);
|
||||
}
|
||||
}
|
||||
_retryDownload((m?.name || repo).split('/').pop(), payload);
|
||||
@@ -3752,17 +4058,16 @@ function _renderCachedModelsData(list, data, host) {
|
||||
const _familyMap = {};
|
||||
const _families = [
|
||||
[/qwen/i, 'qwen'], [/llama/i, 'llama'], [/mistral|mixtral/i, 'mistral'],
|
||||
[/deepseek/i, 'deepseek'], [/gemma/i, 'gemma'], [/phi/i, 'phi'],
|
||||
[/minimax/i, 'minimax'], [/glm/i, 'glm'], [/flux/i, 'flux'],
|
||||
[/stable.?diffusion|sdxl/i, 'sd'], [/z-image/i, 'z-image'],
|
||||
[/whisper/i, 'whisper'], [/command|cohere/i, 'cohere'],
|
||||
[/deepseek/i, 'deepseek'], [/gemma/i, 'gemma'], [/phi/i, 'phi'],
|
||||
[/minimax/i, 'minimax'], [/glm/i, 'glm'],
|
||||
[/whisper/i, 'whisper'], [/command|cohere/i, 'cohere'],
|
||||
[/yi-/i, 'yi'], [/intern/i, 'intern'], [/falcon/i, 'falcon'],
|
||||
];
|
||||
for (const m of allModels) {
|
||||
const n = (m.repo_id || '').toLowerCase();
|
||||
let tag = 'other';
|
||||
if (m.backend === 'ollama' || m.is_ollama) tag = 'llm';
|
||||
else if (m.is_diffusion || /flux|sdxl|stable-diffusion|z-image|qwen-image|diffusion|dreamshar/i.test(n)) tag = 'image';
|
||||
else if (m.is_diffusion || m.is_video || m.is_image_gen || /(?:^|[-_/])(diffusion|image)(?:[-_/]|$)/i.test(n)) tag = 'image';
|
||||
else if (/whisper|stt|asr/i.test(n)) tag = 'stt';
|
||||
else if (/tts|cosyvoice|parler/i.test(n)) tag = 'tts';
|
||||
else if (/embed|bge|minilm|e5-/i.test(n)) tag = 'embedding';
|
||||
|
||||
+318
-156
@@ -123,6 +123,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
let activeDocId = null; // currently visible doc
|
||||
let _lastSessionId = ''; // session context for "+" button
|
||||
const docs = new Map(); // docId -> { id, title, language, content, version, sessionId }
|
||||
let _emailSendInFlight = false;
|
||||
|
||||
const _docOpenKey = (sessionId) => 'odysseus-doc-open-' + sessionId;
|
||||
const _docMinimizedKey = (sessionId) => 'odysseus-doc-minimized-' + sessionId;
|
||||
@@ -158,6 +159,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
getDocs: () => docs,
|
||||
isOpen: () => isOpen,
|
||||
createDocument,
|
||||
newDocument,
|
||||
loadDocument,
|
||||
switchToDoc,
|
||||
openPanel,
|
||||
@@ -2244,6 +2246,18 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
// ── Email document type helpers ──
|
||||
|
||||
function _unfoldEmailHeaderLines(header) {
|
||||
const lines = [];
|
||||
for (const rawLine of String(header || '').replace(/\r\n/g, '\n').split('\n')) {
|
||||
if (/^[ \t]/.test(rawLine) && lines.length) {
|
||||
lines[lines.length - 1] += ' ' + rawLine.trim();
|
||||
} else {
|
||||
lines.push(rawLine);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function _parseEmailHeader(content) {
|
||||
const empty = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', forwardAttachments: false, attachments: [], body: content || '' };
|
||||
if (!content) return empty;
|
||||
@@ -2252,7 +2266,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const header = parts[0];
|
||||
const body = parts.slice(1).join('\n---\n');
|
||||
const fields = { to: '', cc: '', bcc: '', subject: '', inReplyTo: '', references: '', sourceUid: '', sourceFolder: '', forwardAttachments: false, attachments: [], body: body };
|
||||
for (const line of header.split('\n')) {
|
||||
for (const line of _unfoldEmailHeaderLines(header)) {
|
||||
const m = line.match(/^(To|Cc|Bcc|Subject|In-Reply-To|References|X-Source-UID|X-Source-Folder|X-Forward-Attachments|X-Attachments):\s*(.*)$/i);
|
||||
if (m) {
|
||||
let key = m[1].toLowerCase();
|
||||
@@ -2373,16 +2387,20 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
function _emailFieldsWithLocalDraft(fields) {
|
||||
const draft = _loadEmailLocalDraft(fields);
|
||||
if (!draft) return fields;
|
||||
const keepRealField = (draftValue, fieldValue) => {
|
||||
const d = draftValue == null ? '' : String(draftValue);
|
||||
return d.trim() ? d : (fieldValue || '');
|
||||
};
|
||||
return {
|
||||
...fields,
|
||||
to: draft.to ?? fields.to,
|
||||
cc: draft.cc ?? fields.cc,
|
||||
bcc: draft.bcc ?? fields.bcc,
|
||||
subject: draft.subject ?? fields.subject,
|
||||
inReplyTo: draft.inReplyTo ?? fields.inReplyTo,
|
||||
references: draft.references ?? fields.references,
|
||||
sourceUid: draft.sourceUid ?? fields.sourceUid,
|
||||
sourceFolder: draft.sourceFolder ?? fields.sourceFolder,
|
||||
to: keepRealField(draft.to, fields.to),
|
||||
cc: keepRealField(draft.cc, fields.cc),
|
||||
bcc: keepRealField(draft.bcc, fields.bcc),
|
||||
subject: keepRealField(draft.subject, fields.subject),
|
||||
inReplyTo: keepRealField(draft.inReplyTo, fields.inReplyTo),
|
||||
references: keepRealField(draft.references, fields.references),
|
||||
sourceUid: keepRealField(draft.sourceUid, fields.sourceUid),
|
||||
sourceFolder: keepRealField(draft.sourceFolder, fields.sourceFolder),
|
||||
body: _sanitizeOutgoingEmailBody(draft.body ?? fields.body),
|
||||
};
|
||||
}
|
||||
@@ -2439,7 +2457,20 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
return d.innerHTML.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function _emailBodyToHtml(text) {
|
||||
function _emailHtmlToPlainText(html) {
|
||||
if (typeof document === 'undefined') return String(html || '');
|
||||
const d = document.createElement('div');
|
||||
d.innerHTML = String(html || '');
|
||||
return d.innerText || d.textContent || '';
|
||||
}
|
||||
|
||||
function _emailQuoteMarkerMatch(text) {
|
||||
const raw = String(text || '');
|
||||
return raw.match(/(?:<p[^>]*>\s*)?-{5,}\s*Previous message\s*-{5,}(?:\s*<\/p>)?/i)
|
||||
|| raw.match(/-{5,}\s*Previous message\s*-{5,}/i);
|
||||
}
|
||||
|
||||
function _emailBodyFragmentToHtml(text) {
|
||||
const t = (text || '').trim();
|
||||
if (!t) return '';
|
||||
// If it already contains a formatting/structural HTML tag, it's a saved
|
||||
@@ -2455,6 +2486,36 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
try { return markdownModule.mdToHtml(text, { shortcodes: false }); }
|
||||
catch (_) { return _emailPlainTextToHtml(text); }
|
||||
}
|
||||
|
||||
function _emailBodyToHtml(text) {
|
||||
const raw = String(text || '');
|
||||
const marker = _emailQuoteMarkerMatch(raw);
|
||||
if (!marker) {
|
||||
const t = raw.trim();
|
||||
if (/<\/?(b|i|u|s|strong|em|del|strike|a|p|div|br|ul|ol|li|h[1-3]|blockquote|span|code|pre)\b[^>]*>/i.test(t)) {
|
||||
return markdownModule.sanitizeAllowedHtml
|
||||
? markdownModule.sanitizeAllowedHtml(t)
|
||||
: _emailPlainTextToHtml(t);
|
||||
}
|
||||
return _emailBodyFragmentToHtml(raw);
|
||||
}
|
||||
const replyPart = raw.slice(0, marker.index);
|
||||
const quotedPart = raw.slice(marker.index);
|
||||
const quotedText = _emailHtmlToPlainText(quotedPart)
|
||||
.replace(/\u00a0/g, ' ')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
const replyHtml = _emailBodyFragmentToHtml(replyPart);
|
||||
if (!quotedText) return replyHtml;
|
||||
const firstQuoted = quotedText
|
||||
.split(/\n\s*-{5,}\s*Previous message\s*-{5,}\s*\n/i)[0]
|
||||
.trim();
|
||||
const truncatedQuote = firstQuoted.length > 1800
|
||||
? `${firstQuoted.slice(0, 1800).replace(/\s+\S*$/, '').trim()}\n\n[Quoted thread truncated]`
|
||||
: firstQuoted;
|
||||
return `${replyHtml}<div class="email-quoted-history" contenteditable="false">${_emailPlainTextToHtml(truncatedQuote)}</div>`;
|
||||
}
|
||||
// Mirror the rich body's plain text into the hidden textarea so the existing
|
||||
// send / draft / change-detection plumbing (which reads the textarea) stays
|
||||
// valid. The rich body's HTML is read separately on send (body_html).
|
||||
@@ -2756,8 +2817,21 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
target.focus();
|
||||
if (target.isContentEditable) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(target);
|
||||
range.collapse(false);
|
||||
const quote = target.querySelector('.email-quoted-history');
|
||||
if (quote) {
|
||||
let slot = quote.previousElementSibling;
|
||||
if (!slot || slot.classList.contains('email-quoted-history')) {
|
||||
slot = document.createElement('div');
|
||||
slot.className = 'email-reply-edit-slot';
|
||||
slot.innerHTML = '<br>';
|
||||
target.insertBefore(slot, quote);
|
||||
}
|
||||
range.selectNodeContents(slot);
|
||||
range.collapse(false);
|
||||
} else {
|
||||
range.selectNodeContents(target);
|
||||
range.collapse(false);
|
||||
}
|
||||
const sel = window.getSelection();
|
||||
if (sel) {
|
||||
sel.removeAllRanges();
|
||||
@@ -2820,7 +2894,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
if (_shouldAutoCollapseEmailHeader()) _setEmailHeaderCollapsed(true, { manual: false });
|
||||
}
|
||||
|
||||
function _showEmailFields(doc, { applyLocalDraft = true } = {}) {
|
||||
function _showEmailFields(doc, { applyLocalDraft = true, forceHeaderFields = false } = {}) {
|
||||
const emailHeader = document.getElementById('doc-email-header');
|
||||
const emailActions = document.getElementById('doc-email-actions');
|
||||
// Show MD toolbar for email too (B, I, etc.)
|
||||
@@ -2857,8 +2931,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const preserveEmailHeader = !!(fields.sourceUid || fields.inReplyTo || fields.references);
|
||||
const subjectInput = document.getElementById('doc-email-subject');
|
||||
const textarea = document.getElementById('doc-editor-textarea');
|
||||
_setEmailHeaderInputValue('doc-email-to', fields.to, { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-subject', fields.subject, { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-to', fields.to, { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
|
||||
_setEmailHeaderInputValue('doc-email-subject', fields.subject, { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
|
||||
_setEmailHeaderCollapsed(!!(doc && doc._emailHeaderCollapsed), { manual: false });
|
||||
if (subjectInput && !subjectInput._emailTabBodyBound) {
|
||||
subjectInput._emailTabBodyBound = true;
|
||||
@@ -2869,10 +2943,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
}
|
||||
});
|
||||
}
|
||||
_setEmailHeaderInputValue('doc-email-in-reply-to', fields.inReplyTo, { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-references', fields.references, { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-source-uid', fields.sourceUid || '', { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-source-folder', fields.sourceFolder || '', { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-in-reply-to', fields.inReplyTo, { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
|
||||
_setEmailHeaderInputValue('doc-email-references', fields.references, { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
|
||||
_setEmailHeaderInputValue('doc-email-source-uid', fields.sourceUid || '', { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
|
||||
_setEmailHeaderInputValue('doc-email-source-folder', fields.sourceFolder || '', { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
|
||||
// Show/hide unread button only if we have a source UID (came from inbox)
|
||||
const unreadBtn = document.getElementById('doc-email-unread-btn');
|
||||
if (unreadBtn) unreadBtn.style.display = fields.sourceUid ? '' : 'none';
|
||||
@@ -2984,7 +3058,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const _isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints || 0) > 0;
|
||||
if (!_isTouch) _rich.focus();
|
||||
if (!_isTouch) _focusEmailBodyEnd();
|
||||
_rich.scrollTop = 0;
|
||||
} catch (_) {}
|
||||
}, 50);
|
||||
@@ -2995,8 +3069,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const ccRow = document.getElementById('doc-email-cc-row');
|
||||
const bccRow = document.getElementById('doc-email-bcc-row');
|
||||
const ccToggle = document.getElementById('doc-email-show-cc');
|
||||
_setEmailHeaderInputValue('doc-email-cc', fields.cc || '', { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-bcc', fields.bcc || '', { preserveNonEmpty: preserveEmailHeader });
|
||||
_setEmailHeaderInputValue('doc-email-cc', fields.cc || '', { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
|
||||
_setEmailHeaderInputValue('doc-email-bcc', fields.bcc || '', { preserveFocused: !forceHeaderFields, preserveNonEmpty: preserveEmailHeader && !forceHeaderFields });
|
||||
const hasCcBcc = !!(
|
||||
fields.cc ||
|
||||
fields.bcc ||
|
||||
@@ -3777,6 +3851,11 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
}
|
||||
|
||||
async function _sendEmail() {
|
||||
if (_emailSendInFlight) {
|
||||
if (uiModule) uiModule.showToast('Already sending');
|
||||
return;
|
||||
}
|
||||
if (uiModule) uiModule.showToast('Preparing send', { duration: 1200 });
|
||||
const sendDocId = activeDocId;
|
||||
const to = document.getElementById('doc-email-to')?.value?.trim();
|
||||
const cc = document.getElementById('doc-email-cc')?.value?.trim() || '';
|
||||
@@ -3810,11 +3889,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const proceed = await _confirmMissingAttachment();
|
||||
if (!proceed) return;
|
||||
}
|
||||
const btn = document.getElementById('doc-email-send-btn');
|
||||
const _sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
const btn = Array.from(document.querySelectorAll('#doc-email-send-btn')).find((candidate) => candidate.offsetParent !== null) || document.getElementById('doc-email-send-btn');
|
||||
let sendSpinner = null;
|
||||
let origBtnHtml = '';
|
||||
let detachedEmailDoc = null;
|
||||
_emailSendInFlight = true;
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
origBtnHtml = btn.innerHTML;
|
||||
@@ -3825,24 +3903,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
btn.appendChild(document.createTextNode('Sending'));
|
||||
}
|
||||
try {
|
||||
let canceled = false;
|
||||
if (uiModule) {
|
||||
uiModule.showToast('Sending', {
|
||||
duration: 3200,
|
||||
leadingIcon: 'spinner',
|
||||
action: 'Cancel',
|
||||
onAction: () => { canceled = true; },
|
||||
});
|
||||
}
|
||||
await _sleep(3000);
|
||||
if (!canceled) detachedEmailDoc = _detachActiveEmailForBackground(sendDocId);
|
||||
await _sleep(200);
|
||||
if (canceled) {
|
||||
_restoreDetachedEmailDoc(detachedEmailDoc);
|
||||
detachedEmailDoc = null;
|
||||
if (uiModule) uiModule.showToast('Send canceled');
|
||||
return;
|
||||
}
|
||||
if (uiModule) uiModule.showToast('Sending', { duration: 2200, leadingIcon: 'spinner' });
|
||||
|
||||
const activeAccountId = await _resolveComposeSendAccountId();
|
||||
const res = await fetch(`${API_BASE}/api/email/send`, {
|
||||
@@ -3873,7 +3934,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
leadingIcon: 'check',
|
||||
action: 'View Message',
|
||||
onAction: () => {
|
||||
import('./emailLibrary.js').then(mod => {
|
||||
import('./emailLibrary.js?v=20260722emailfastindex1').then(mod => {
|
||||
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
|
||||
if (open) open({
|
||||
account_id: data.account_id || activeAccountId || null,
|
||||
@@ -3912,9 +3973,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
// Tell the inbox to refresh so the answered state shows
|
||||
window.dispatchEvent(new CustomEvent('email-answered', { detail: { uid: sourceUid, folder: sourceFolder, account_id: data.account_id || activeAccountId || null } }));
|
||||
}
|
||||
// Delete the compose document after successful send. It was usually
|
||||
// already detached from the visible tabs so sending can finish in the
|
||||
// background while the user continues in the next tab.
|
||||
// Delete the compose document after successful send.
|
||||
if (sendDocId) {
|
||||
fetch(`${API_BASE}/api/document/${sendDocId}`, { method: 'DELETE' }).catch(() => {});
|
||||
const wasActiveSentDoc = activeDocId === sendDocId;
|
||||
@@ -3930,15 +3989,12 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
_syncDocIndicator();
|
||||
}
|
||||
} else {
|
||||
_restoreDetachedEmailDoc(detachedEmailDoc);
|
||||
detachedEmailDoc = null;
|
||||
if (uiModule) uiModule.showError(data.error || 'Failed to send');
|
||||
}
|
||||
} catch (e) {
|
||||
_restoreDetachedEmailDoc(detachedEmailDoc);
|
||||
detachedEmailDoc = null;
|
||||
if (uiModule) uiModule.showError(e?.message ? `Failed to send email: ${e.message}` : 'Failed to send email');
|
||||
} finally {
|
||||
_emailSendInFlight = false;
|
||||
if (sendSpinner) sendSpinner.destroy();
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
@@ -4013,41 +4069,6 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
return ids;
|
||||
}
|
||||
|
||||
function _detachActiveEmailForBackground(docId) {
|
||||
if (!docId || !docs.has(docId)) return null;
|
||||
saveCurrentToMap();
|
||||
const doc = docs.get(docId);
|
||||
const snapshot = { id: docId, doc: { ...doc } };
|
||||
const wasActive = activeDocId === docId;
|
||||
if (wasActive) saveDocument({ silent: true }).catch(() => {});
|
||||
|
||||
const visibleBefore = _visibleDocIdsForCurrentSession();
|
||||
const idx = visibleBefore.indexOf(docId);
|
||||
docs.delete(docId);
|
||||
if (wasActive) activeDocId = null;
|
||||
|
||||
if (wasActive) {
|
||||
const remaining = visibleBefore.filter(id => id !== docId && docs.has(id));
|
||||
const nextId = remaining[idx] || remaining[idx - 1] || remaining[0] || null;
|
||||
if (nextId) {
|
||||
switchToDoc(nextId);
|
||||
} else {
|
||||
closePanel();
|
||||
}
|
||||
}
|
||||
renderTabs();
|
||||
_syncDocIndicator();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function _restoreDetachedEmailDoc(snapshot) {
|
||||
if (!snapshot || !snapshot.id || !snapshot.doc) return;
|
||||
if (!docs.has(snapshot.id)) docs.set(snapshot.id, snapshot.doc);
|
||||
_ensureDocPaneMounted();
|
||||
switchToDoc(snapshot.id);
|
||||
_syncDocIndicator();
|
||||
}
|
||||
|
||||
function _closeWithoutDeleting(deleteDoc = false) {
|
||||
if (!activeDocId) return;
|
||||
if (deleteDoc) {
|
||||
@@ -4068,10 +4089,9 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
renderTabs();
|
||||
}
|
||||
|
||||
// Fast/Full + optional context popover for the doc-editor email Reply button.
|
||||
// Mirrors the email reader's AI reply choice popover so the UX is identical:
|
||||
// textarea for an optional steering note, then Fast (lightning) or Full
|
||||
// (concentric dot) buttons; both feed into _aiReply with the chosen mode.
|
||||
// Fast AI reply + optional context popover for the doc-editor email Reply button.
|
||||
// Mirrors the email reader's AI reply choice popover: textarea for an
|
||||
// optional steering note, then one Submit button.
|
||||
let _docAiReplyChoiceMenu = null;
|
||||
const _AI_REPLY_CONTEXT_STORE_PREFIX = 'odysseus:email-ai-reply-context:v1:';
|
||||
function _docAiReplyContextKey() {
|
||||
@@ -4149,15 +4169,11 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
].join(';');
|
||||
menu.innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;gap:6px;min-width:200px;">
|
||||
<textarea data-note-input rows="2" placeholder="Add context (optional)" style="width:100%;box-sizing:border-box;resize:vertical;min-height:42px;font-family:inherit;font-size:11px;padding:5px 6px;border-radius:5px;border:1px solid var(--border,#333);background:var(--bg-elev,#1a1a1a);color:var(--fg);"></textarea>
|
||||
<textarea data-note-input rows="2" placeholder="Context (optional)" style="width:100%;box-sizing:border-box;resize:vertical;min-height:42px;font-family:inherit;font-size:11px;padding:5px 6px;border-radius:5px;border:1px solid var(--border,#333);background:var(--bg-elev,#1a1a1a);color:var(--fg);"></textarea>
|
||||
<div style="display:flex;align-items:center;gap:4px;">
|
||||
<button class="memory-toolbar-btn" data-mode="ai-reply-fast" title="Shorter, faster draft" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="var(--accent, var(--red))" aria-hidden="true"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
Fast
|
||||
</button>
|
||||
<button class="memory-toolbar-btn" data-mode="ai-reply-full" title="Fuller reply with more context" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="color:var(--accent, var(--red));"><circle cx="12" cy="12" r="6"/></svg>
|
||||
Full
|
||||
<button class="memory-toolbar-btn" data-mode="ai-reply-fast" title="Draft reply" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;flex:1;">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style="color:var(--accent, var(--red));"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4203,6 +4219,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const inReplyTo = document.getElementById('doc-email-in-reply-to')?.value?.trim() || '';
|
||||
const sourceUid = document.getElementById('doc-email-source-uid')?.value?.trim() || '';
|
||||
const sourceFolder = document.getElementById('doc-email-source-folder')?.value?.trim() || 'INBOX';
|
||||
const sourceAccountId = docs.get(activeDocId)?.sourceEmailAccountId || window.__odysseusActiveEmailAccount || '';
|
||||
const cleanAiReplyText = (text) => {
|
||||
if (!text) return '';
|
||||
let t = String(text);
|
||||
@@ -4217,15 +4234,16 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
return t
|
||||
.replace(/<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/gi, '')
|
||||
.replace(/<<<\s*END\s*>>+/gi, '')
|
||||
.replace(/<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi, '')
|
||||
.trim();
|
||||
};
|
||||
const shouldUseFastAiReply = () => {
|
||||
const text = `${subject}\n${currentBody}`.toLowerCase();
|
||||
if (/\b(attach(?:ed|ment)?|pdf|document|contract|invoice|receipt|quote|estimate|proposal|question|questions|details|schedule|booking|reservation|meeting|calendar|availability|confirm|confirmation|review|sign|signature)\b/.test(text)) {
|
||||
return false;
|
||||
}
|
||||
return currentBody.length < 2500;
|
||||
};
|
||||
const splitCurrent = _splitEmailReplyQuote(currentBody);
|
||||
const ownText = String(splitCurrent.body || '').trim();
|
||||
const isReplaceableDraft = !ownText || /^(\[AI reply draft will appear here\]|Drafting AI reply)/i.test(ownText);
|
||||
if (!isReplaceableDraft) {
|
||||
if (uiModule) uiModule.showToast('Reply already has text');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the current chat model
|
||||
let currentModel = '';
|
||||
@@ -4243,9 +4261,6 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
// so the backend's "no body" guard doesn't fail. The user_hint carries
|
||||
// the user's compose intent; the model uses To/Subject + that hint.
|
||||
const bodyForApi = currentBody || (noteHint ? '(no prior email — compose a new message based on the To, Subject, and user instructions)' : currentBody);
|
||||
const fastFlag = mode === 'ai-reply-fast' ? true
|
||||
: mode === 'ai-reply-full' ? false
|
||||
: shouldUseFastAiReply();
|
||||
const res = await fetch(`${API_BASE}/api/email/ai-reply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -4258,7 +4273,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
message_id: inReplyTo,
|
||||
uid: sourceUid,
|
||||
folder: sourceFolder,
|
||||
fast: fastFlag,
|
||||
account_id: sourceAccountId,
|
||||
fast: true,
|
||||
user_hint: noteHint || '',
|
||||
}),
|
||||
});
|
||||
@@ -4271,11 +4287,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
// currentBody. Without this, AI's invented quote stacked on top
|
||||
// of the real one and looked like the history had been "edited".
|
||||
cleanReply = cleanReply.replace(/\n*On\b[\s\S]*?\bwrote:[\s\S]*$/m, '').trim();
|
||||
// Never overwrite the existing draft (user's typed text + the
|
||||
// quoted history below it). Always prepend the AI suggestion so
|
||||
// the user can read it, copy parts, or delete it — but their
|
||||
// own work and the original quote are untouched.
|
||||
const newBody = currentBody ? cleanReply + '\n\n' + currentBody : cleanReply;
|
||||
const quote = splitCurrent.quote || '';
|
||||
const newBody = cleanReply + (quote ? `\n\n${quote}` : '');
|
||||
await _streamEmailBodyText(textarea, newBody);
|
||||
_clearDocAiReplyContext(contextKey || _docAiReplyContextKey());
|
||||
if (uiModule) uiModule.showToast(`AI draft inserted (${data.model_used || 'AI'})`);
|
||||
@@ -4568,7 +4581,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const isEmail = doc.language === 'email';
|
||||
if (isEmail) {
|
||||
_setMarkdownPreviewActive(false, { remember: false });
|
||||
_showEmailFields(doc);
|
||||
const forceHeaderFields = !!doc._skipLocalDraftOnce;
|
||||
const applyLocalDraft = forceHeaderFields ? false : true;
|
||||
doc._skipLocalDraftOnce = false;
|
||||
_showEmailFields(doc, { applyLocalDraft, forceHeaderFields });
|
||||
} else {
|
||||
_hideEmailFields();
|
||||
const wantsMarkdownPreview = (doc.language || 'markdown') === 'markdown' && doc._markdownPreviewActive === true;
|
||||
@@ -4913,7 +4929,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
<button type="button" class="md-view-opt" data-renderview="code" title="Edit code"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></button>
|
||||
<button type="button" class="md-view-opt" data-renderview="run" title="Run / Preview"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
|
||||
</span>
|
||||
<button id="doc-email-ai-reply-btn" class="doc-action-icon-btn md-toolbar-email-only" type="button" title="Draft a reply with AI (Fast / Full + optional context)" style="display:none;align-items:center;gap:4px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent, var(--red));flex-shrink:0;position:relative;top:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg><span style="font-size:11px;">Reply</span></button>
|
||||
<button id="doc-email-ai-reply-btn" class="doc-action-icon-btn md-toolbar-email-only" type="button" title="Draft a reply with AI (fast + optional context)" style="display:none;align-items:center;gap:4px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="color:var(--accent, var(--red));flex-shrink:0;position:relative;top:-1px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg><span style="font-size:11px;">Reply</span></button>
|
||||
<button id="doc-fontsize-btn" class="doc-action-icon-btn" title="Font size" style="position:relative;width:28px;height:26px;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7;"><path d="M4 7V4h16v3"/><path d="M12 4v16"/><path d="M8 20h8"/></svg><span class="doc-fontsize-levels"><i data-sz="s">S</i><i data-sz="m">M</i><i data-sz="l">L</i></span></button>
|
||||
<button id="doc-diff-toggle-btn" class="doc-action-icon-btn" title="Compare changes" style="opacity:0.7;display:none;"><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 3v18"/><path d="M5 12H2l5-5 5 5H9"/><path d="M19 12h3l-5 5-5-5h3"/></svg></button>
|
||||
<span class="md-toolbar-sep md-toolbar-edit-only"></span>
|
||||
@@ -4963,8 +4979,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
<button id="doc-email-discard-btn" class="email-discard-btn" title="Close email" style="display:inline-flex;align-items:center;gap:5px;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg><span>Close</span></button>
|
||||
<span style="flex:1"></span>
|
||||
<div class="email-send-split">
|
||||
<button id="doc-email-send-btn" class="email-send-btn email-send-main" title="Send email (Ctrl+Enter)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>Send</button>
|
||||
<button id="doc-email-send-caret" class="email-send-btn email-send-caret" title="More send options" aria-haspopup="true" aria-expanded="false"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||
<button type="button" id="doc-email-send-btn" class="email-send-btn email-send-main" title="Send email (Ctrl+Enter)" onpointerdown="window.odysseusEmailSendIntent&&window.odysseusEmailSendIntent(event)" onmousedown="window.odysseusEmailSendIntent&&window.odysseusEmailSendIntent(event)" onclick="window.odysseusEmailSendIntent&&window.odysseusEmailSendIntent(event)"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>Send</button>
|
||||
<button type="button" id="doc-email-send-caret" class="email-send-btn email-send-caret" title="More send options" aria-haspopup="true" aria-expanded="false" onpointerdown="window.odysseusEmailCaretIntent&&window.odysseusEmailCaretIntent(event)" onmousedown="window.odysseusEmailCaretIntent&&window.odysseusEmailCaretIntent(event)"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg></button>
|
||||
<div id="doc-email-more-menu" class="email-more-menu" style="display:none">
|
||||
<div class="dropdown-item-compact" id="doc-email-draft-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg></span>Save Draft</div>
|
||||
<div class="dropdown-item-compact" id="doc-email-schedule-btn"><span class="dropdown-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></span>Schedule Send...</div>
|
||||
@@ -5400,13 +5416,80 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
));
|
||||
}
|
||||
|
||||
document.getElementById('doc-email-send-btn')?.addEventListener('click', () => {
|
||||
// Pressing Send must never leave the "more options" menu showing.
|
||||
const _eventInsideElement = (e, el) => {
|
||||
if (!e || !el || typeof e.clientX !== 'number' || typeof e.clientY !== 'number') return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
|
||||
};
|
||||
|
||||
const handleSendIntent = (e) => {
|
||||
if (e && e.__odysseusEmailSendHandled) return;
|
||||
const rawTarget = e && e.target;
|
||||
const target = rawTarget && rawTarget.nodeType === Node.TEXT_NODE ? rawTarget.parentElement : rawTarget;
|
||||
const sendButtons = Array.from(document.querySelectorAll('#doc-email-send-btn'));
|
||||
const targetBtn = target && target.closest ? target.closest('#doc-email-send-btn') : null;
|
||||
const rectBtn = sendButtons.find((candidate) => _eventInsideElement(e, candidate));
|
||||
const btn = targetBtn || rectBtn || null;
|
||||
if (!btn || btn.disabled) return;
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.__odysseusEmailSendHandled = true;
|
||||
}
|
||||
const _m = document.getElementById('doc-email-more-menu');
|
||||
if (_m) _m.style.display = 'none';
|
||||
document.getElementById('doc-email-send-caret')?.setAttribute('aria-expanded', 'false');
|
||||
_sendEmail();
|
||||
});
|
||||
};
|
||||
window.odysseusEmailSendIntent = handleSendIntent;
|
||||
if (!window._emailSendDelegatedBoundV3) {
|
||||
window._emailSendDelegatedBoundV3 = true;
|
||||
['pointerdown', 'mousedown', 'pointerup', 'click'].forEach((type) => {
|
||||
window.addEventListener(type, handleSendIntent, true);
|
||||
document.addEventListener(type, handleSendIntent, true);
|
||||
});
|
||||
}
|
||||
|
||||
let lastCaretToggleAt = 0;
|
||||
const toggleSendMenu = (caret) => {
|
||||
const menu = document.getElementById('doc-email-more-menu');
|
||||
if (!menu) return;
|
||||
const opening = menu.style.display === 'none';
|
||||
menu.style.display = opening ? '' : 'none';
|
||||
if (caret) caret.setAttribute('aria-expanded', String(opening));
|
||||
};
|
||||
const handleCaretIntent = (e) => {
|
||||
if (e && e.__odysseusEmailCaretHandled) return;
|
||||
const now = Date.now();
|
||||
if (e && e.type === 'click' && now - lastCaretToggleAt < 350) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.__odysseusEmailCaretHandled = true;
|
||||
return;
|
||||
}
|
||||
const rawTarget = e && e.target;
|
||||
const target = rawTarget && rawTarget.nodeType === Node.TEXT_NODE ? rawTarget.parentElement : rawTarget;
|
||||
const carets = Array.from(document.querySelectorAll('#doc-email-send-caret'));
|
||||
const targetCaret = target && target.closest ? target.closest('#doc-email-send-caret') : null;
|
||||
const rectCaret = carets.find((candidate) => _eventInsideElement(e, candidate));
|
||||
const caret = targetCaret || rectCaret || null;
|
||||
if (!caret) return;
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.__odysseusEmailCaretHandled = true;
|
||||
}
|
||||
lastCaretToggleAt = now;
|
||||
toggleSendMenu(caret);
|
||||
};
|
||||
window.odysseusEmailCaretIntent = handleCaretIntent;
|
||||
if (!window._emailCaretDelegatedBoundV1) {
|
||||
window._emailCaretDelegatedBoundV1 = true;
|
||||
['pointerdown', 'mousedown', 'click'].forEach((type) => {
|
||||
window.addEventListener(type, handleCaretIntent, true);
|
||||
document.addEventListener(type, handleCaretIntent, true);
|
||||
});
|
||||
}
|
||||
|
||||
// Ctrl+Enter / Cmd+Enter sends the email when an email doc is active
|
||||
// Bind once at module level via a guard to avoid duplicate listeners on re-open
|
||||
@@ -5493,16 +5576,8 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
window.visualViewport.addEventListener('resize', _maybeAutoCollapseEmailHeader);
|
||||
}
|
||||
|
||||
// Split-button caret toggles the send-options menu (drops up).
|
||||
document.getElementById('doc-email-send-caret')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const menu = document.getElementById('doc-email-more-menu');
|
||||
const caret = document.getElementById('doc-email-send-caret');
|
||||
if (!menu) return;
|
||||
const opening = menu.style.display === 'none';
|
||||
menu.style.display = opening ? '' : 'none';
|
||||
if (caret) caret.setAttribute('aria-expanded', String(opening));
|
||||
});
|
||||
// Split-button caret toggles the send-options menu.
|
||||
document.getElementById('doc-email-send-caret')?.addEventListener('click', handleCaretIntent);
|
||||
document.addEventListener('click', (e) => {
|
||||
const menu = document.getElementById('doc-email-more-menu');
|
||||
// Keep the menu open ONLY while interacting with the caret itself or the
|
||||
@@ -7009,6 +7084,13 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
export function injectFreshDoc(doc) {
|
||||
if (!doc || !doc.id) return;
|
||||
const sessionId = doc.session_id || _lastSessionId || null;
|
||||
if (doc.language === 'email') {
|
||||
doc._skipLocalDraftOnce = true;
|
||||
try {
|
||||
const fields = _parseEmailHeader(doc.current_content || doc.content || '');
|
||||
_clearEmailLocalDraft(fields.sourceUid, fields.sourceFolder, fields.inReplyTo);
|
||||
} catch (_) {}
|
||||
}
|
||||
addDocToTabs(doc, sessionId);
|
||||
// Use _ensureDocPaneMounted (not `if (!isOpen) openPanel()`): when a draft
|
||||
// is composed from the email modal, `isOpen` can be stale-true while the
|
||||
@@ -7016,10 +7098,13 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
// mounts into a wrong/half-built pane (rendered as a narrow sidebar on
|
||||
// mobile instead of its own full-screen window). This remounts it cleanly.
|
||||
_ensureDocPaneMounted();
|
||||
// Defer to next frame so the panel DOM exists before switchToDoc populates
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
switchToDoc(doc.id);
|
||||
}));
|
||||
// Defer to the next frame so the panel DOM exists before switchToDoc
|
||||
// populates it. Do not call switchToDoc synchronously here: it saves the
|
||||
// previously active doc and can re-enter the email draft path while a reply
|
||||
// document is still being injected.
|
||||
requestAnimationFrame(() => {
|
||||
if (docs.has(doc.id)) switchToDoc(doc.id);
|
||||
});
|
||||
}
|
||||
|
||||
export async function replaceEmailReplyBody(docId, replyText, { force = false } = {}) {
|
||||
@@ -7053,6 +7138,90 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
|
||||
}
|
||||
|
||||
function _buildEmailContentFromFields(fields, body) {
|
||||
const f = fields || {};
|
||||
let header = `To: ${f.to || ''}`;
|
||||
if (f.cc) header += `\nCc: ${f.cc}`;
|
||||
if (f.bcc) header += `\nBcc: ${f.bcc}`;
|
||||
header += `\nSubject: ${f.subject || ''}`;
|
||||
if (f.inReplyTo) header += `\nIn-Reply-To: ${f.inReplyTo}`;
|
||||
if (f.references) header += `\nReferences: ${f.references}`;
|
||||
if (f.sourceUid) header += `\nX-Source-UID: ${f.sourceUid}`;
|
||||
if (f.sourceFolder) header += `\nX-Source-Folder: ${f.sourceFolder}`;
|
||||
if (f.forwardAttachments) header += `\nX-Forward-Attachments: 1`;
|
||||
if (Array.isArray(f.attachments) && f.attachments.length) {
|
||||
const attStr = f.attachments
|
||||
.map(a => `${a.index}:${a.filename}:${a.size}`)
|
||||
.join('|');
|
||||
header += `\nX-Attachments: ${attStr}`;
|
||||
}
|
||||
return header + '\n---\n' + (body || '');
|
||||
}
|
||||
|
||||
export async function ensureEmailDraftEnvelope(docId, freshContent) {
|
||||
const doc = docs.get(docId);
|
||||
if (!doc || doc.language !== 'email') return false;
|
||||
const current = _parseEmailHeader(doc.content || '');
|
||||
const fresh = _parseEmailHeader(freshContent || '');
|
||||
if (!fresh.to && !fresh.subject && !fresh.sourceUid) return false;
|
||||
|
||||
const needsEnvelope = (
|
||||
(!current.to && !!fresh.to) ||
|
||||
(!current.subject && !!fresh.subject) ||
|
||||
(!current.inReplyTo && !!fresh.inReplyTo) ||
|
||||
(!current.references && !!fresh.references) ||
|
||||
(!current.sourceUid && !!fresh.sourceUid) ||
|
||||
(!current.sourceFolder && !!fresh.sourceFolder)
|
||||
);
|
||||
|
||||
const currentSplit = _splitEmailReplyQuote(current.body || '');
|
||||
const freshSplit = _splitEmailReplyQuote(fresh.body || '');
|
||||
const currentOwnText = String(currentSplit.body || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const freshOwnText = String(freshSplit.body || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const currentBodyText = String(current.body || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const freshBodyText = String(fresh.body || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const needsBodyRepair = (
|
||||
!!freshBodyText &&
|
||||
(
|
||||
!currentBodyText ||
|
||||
(!currentOwnText && !!freshOwnText) ||
|
||||
(!currentSplit.quote && !!freshSplit.quote)
|
||||
)
|
||||
);
|
||||
if (!needsEnvelope && !needsBodyRepair) return false;
|
||||
|
||||
let body = current.body || '';
|
||||
if (needsBodyRepair && (!currentBodyText || (!currentOwnText && !!freshOwnText))) {
|
||||
body = fresh.body || '';
|
||||
} else if (!String(body).trim()) {
|
||||
body = fresh.body || '';
|
||||
} else if (currentSplit.body && freshSplit.quote && !currentSplit.quote) {
|
||||
body = `${currentSplit.body}\n\n${freshSplit.quote}`;
|
||||
}
|
||||
|
||||
const merged = {
|
||||
...fresh,
|
||||
to: current.to || fresh.to || '',
|
||||
cc: current.cc || fresh.cc || '',
|
||||
bcc: current.bcc || fresh.bcc || '',
|
||||
subject: current.subject || fresh.subject || '',
|
||||
inReplyTo: current.inReplyTo || fresh.inReplyTo || '',
|
||||
references: current.references || fresh.references || '',
|
||||
sourceUid: current.sourceUid || fresh.sourceUid || '',
|
||||
sourceFolder: current.sourceFolder || fresh.sourceFolder || '',
|
||||
forwardAttachments: current.forwardAttachments || fresh.forwardAttachments || false,
|
||||
attachments: (current.attachments && current.attachments.length) ? current.attachments : (fresh.attachments || []),
|
||||
};
|
||||
|
||||
doc.content = _buildEmailContentFromFields(merged, body);
|
||||
if (activeDocId === docId) {
|
||||
_showEmailFields(doc, { applyLocalDraft: false });
|
||||
}
|
||||
clearTimeout(_autoSaveDebounce);
|
||||
_autoSaveDebounce = setTimeout(() => { saveDocument({ silent: true }); }, 800);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Force the panel into a genuinely-open state. `isOpen` can be true while the
|
||||
// pane was torn down by another full-screen view (e.g. opening a doc from the
|
||||
// email modal): in that case openPanel() early-returns and nothing mounts, so
|
||||
@@ -7187,31 +7356,22 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
_syncDocIndicator();
|
||||
// Switch to the most recently active one (or first)
|
||||
const target = activeDocs[0];
|
||||
if (restoreMode && shouldRestoreMinimized && !shouldRestoreOpen) {
|
||||
activeDocId = null;
|
||||
if (restoreMode && !shouldRestoreOpen) {
|
||||
// Coming back to a chat with documents should advertise the doc without
|
||||
// stealing half the screen. Default to a docked chip; only reopen the
|
||||
// full editor when this session explicitly persisted an open state.
|
||||
activeDocId = target.id;
|
||||
_minimizedDocId = target.id;
|
||||
_markDocVisibleState(sessionId, 'minimized');
|
||||
_ensureDocChipRegistered();
|
||||
Modals.minimize('doc-panel');
|
||||
if (isOpen) {
|
||||
try { switchToDoc(target.id); } catch (e) { console.error('Minimize restored doc failed:', e); }
|
||||
closePanel('down');
|
||||
} else {
|
||||
Modals.minimize('doc-panel');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Removed: the old "if restoreMode && !shouldRestoreOpen → stay
|
||||
// closed" branch. Users expect that entering a chat with an
|
||||
// attached document opens the panel automatically, not just shows
|
||||
// an indicator. The minimised branch above still respects an
|
||||
// explicit user choice to dock the panel; everything else falls
|
||||
// through to the "open panel" path below.
|
||||
if (false) {
|
||||
activeDocId = null;
|
||||
_minimizedDocId = null;
|
||||
if (Modals.isRegistered('doc-panel')) Modals.unregister('doc-panel');
|
||||
return;
|
||||
}
|
||||
// Always open when there are docs — the minimised branch above
|
||||
// already returned for users who explicitly docked the panel.
|
||||
// The previous `if (!restoreMode || shouldRestoreOpen)` gate left
|
||||
// the panel closed on first entry to a chat with docs, which
|
||||
// hides the doc unless the user manually opens the panel.
|
||||
_markDocVisibleState(sessionId, 'open');
|
||||
if (!isOpen) openPanel();
|
||||
switchToDoc(target.id);
|
||||
@@ -7231,11 +7391,12 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
id: doc.id,
|
||||
title: doc.title || '',
|
||||
language: doc.language || '',
|
||||
content: doc.current_content || '',
|
||||
content: doc.current_content || doc.content || '',
|
||||
version: doc.version_count || 1,
|
||||
sessionId: sessionId || doc.session_id,
|
||||
userSetLanguage: !!doc.language,
|
||||
_composeAtts: existing?._composeAtts,
|
||||
_skipLocalDraftOnce: !!doc._skipLocalDraftOnce,
|
||||
// Provenance for the "Send signed reply" flow
|
||||
sourceEmailUid: doc.source_email_uid || null,
|
||||
sourceEmailFolder: doc.source_email_folder || null,
|
||||
@@ -11010,6 +11171,7 @@ const documentModule = {
|
||||
loadDocument,
|
||||
injectFreshDoc,
|
||||
replaceEmailReplyBody,
|
||||
ensureEmailDraftEnvelope,
|
||||
ensurePaneMounted: _ensureDocPaneMounted,
|
||||
loadSessionDocs,
|
||||
ensureDocPanel,
|
||||
|
||||
@@ -19,6 +19,7 @@ let _esc; // HTML-escape function
|
||||
let _getDocs; // () => Map of open docs
|
||||
let _isOpenFn; // () => boolean — is doc panel open
|
||||
let _createDocument;
|
||||
let _newDocument;
|
||||
let _loadDocument;
|
||||
let _switchToDoc;
|
||||
let _openPanel;
|
||||
@@ -31,6 +32,7 @@ export function initLibrary(config) {
|
||||
_getDocs = config.getDocs;
|
||||
_isOpenFn = config.isOpen;
|
||||
_createDocument = config.createDocument;
|
||||
_newDocument = config.newDocument;
|
||||
_loadDocument = config.loadDocument;
|
||||
_switchToDoc = config.switchToDoc;
|
||||
_openPanel = config.openPanel;
|
||||
@@ -3224,16 +3226,16 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
const createBtn = document.getElementById('doclib-create-btn');
|
||||
if (createBtn) {
|
||||
createBtn.addEventListener('click', async () => {
|
||||
// Create a new session, then create a blank document in it
|
||||
try {
|
||||
const sRes = await fetch('/api/session', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Untitled Document' }) });
|
||||
const sData = await sRes.json();
|
||||
const sessionId = sData.session_id;
|
||||
await _createDocument(sessionId);
|
||||
// Close library and open the new session
|
||||
if (_newDocument) {
|
||||
await _newDocument();
|
||||
} else {
|
||||
const sessionId = sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId();
|
||||
if (!sessionId) throw new Error('No active session');
|
||||
await _createDocument(sessionId);
|
||||
}
|
||||
closeLibrary();
|
||||
if (window.sessionsModule) window.sessionsModule.loadSession(sessionId);
|
||||
setTimeout(() => _openPanel(), 300);
|
||||
setTimeout(() => _openPanel(), 50);
|
||||
} catch (e) {
|
||||
console.error('Failed to create document:', e);
|
||||
if (uiModule) uiModule.showError('Failed to create document');
|
||||
|
||||
@@ -136,6 +136,10 @@ export function wireInpaintButtons({
|
||||
const dilatedMask = dilateMask(mergedMask, padPx);
|
||||
const imageB64 = flatCanvas.toDataURL('image/png').split(',')[1];
|
||||
const maskB64 = dilatedMask.toDataURL('image/png').split(',')[1];
|
||||
const baseSnap = document.createElement('canvas');
|
||||
baseSnap.width = state.imgWidth;
|
||||
baseSnap.height = state.imgHeight;
|
||||
baseSnap.getContext('2d').drawImage(flatCanvas, 0, 0);
|
||||
const res = await fetch('/api/image/inpaint', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -180,7 +184,7 @@ export function wireInpaintButtons({
|
||||
maskSnap.width = state.maskCanvas.width;
|
||||
maskSnap.height = state.maskCanvas.height;
|
||||
maskSnap.getContext('2d').drawImage(state.maskCanvas, 0, 0);
|
||||
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, padPx };
|
||||
resultLayer.inpaintSource = { ai: aiSnap, mask: maskSnap, base: baseSnap, padPx };
|
||||
// Apply initial alpha = hard mask (no feather, no edge shift).
|
||||
applyInpaintFeather(resultLayer, 0, 0);
|
||||
state.layers.push(resultLayer);
|
||||
@@ -216,7 +220,9 @@ export function wireInpaintButtons({
|
||||
const eRow = document.getElementById('ge-inpaint-edgestroke-row');
|
||||
const eSlider = document.getElementById('ge-edgestroke-slider');
|
||||
const eLabel = document.getElementById('ge-edgestroke-label');
|
||||
const autoRow = document.getElementById('ge-inpaint-automatch-row');
|
||||
if (eRow) eRow.style.display = '';
|
||||
if (autoRow) autoRow.style.display = '';
|
||||
if (eSlider) {
|
||||
eSlider.max = String(padPx);
|
||||
eSlider.min = String(-padPx);
|
||||
|
||||
Vendored
+35
@@ -102,6 +102,35 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
</div>
|
||||
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click a region to select similar pixels. Shift+click to add, Alt+click to subtract. Esc to clear.</p>
|
||||
</div>
|
||||
<div class="ge-sam-section" id="ge-sam-section" style="display:none;">
|
||||
<div class="ge-section-title ge-section-title-with-help"><span>SAM</span><span class="ge-section-help" tabindex="0" role="img" aria-label="SAM selection help" title="Click an object for visual SAM selection, or type a neutral object label and use Find. The text is only used to locate a region before SAM creates the mask.">?</span></div>
|
||||
<div class="ge-control-row" style="display:flex;gap:4px;margin-bottom:4px;" title="How the next SAM selection combines with the current selection. Shift / Alt held during a click override this for one click.">
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn active" data-wand-mode="replace" title="Replace selection">New</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="add" title="Add to selection">+ Add</button>
|
||||
<button type="button" class="ge-btn ge-btn-sm ge-wand-mode-btn" data-wand-mode="subtract" title="Subtract from selection">− Subtract</button>
|
||||
</div>
|
||||
<div class="ge-control-row" style="display:flex;gap:6px;align-items:center;min-width:0;">
|
||||
<input type="text" class="ge-inpaint-prompt" id="ge-sam-query" placeholder="Object to select..." style="flex:1 1 auto;min-width:0;" />
|
||||
<button class="ge-btn ge-btn-sm ge-btn-ai" id="ge-sam-find" style="height:28px;display:inline-flex;align-items:center;gap:5px;" title="Find object and create a SAM mask">
|
||||
<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>
|
||||
Find
|
||||
</button>
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" style="margin-top:4px;flex-wrap:wrap;">
|
||||
<button class="ge-btn ge-btn-sm ge-mask-vis-btn visible" id="ge-sam-vis" title="Hide selection overlay" aria-label="Toggle selection overlay">
|
||||
<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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-clear" title="Clear the selection">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>
|
||||
Clear
|
||||
</button>
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel" id="ge-sam-mask" title="Add selection to the inpaint mask">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>
|
||||
To Mask
|
||||
</button>
|
||||
</div>
|
||||
<p style="font-size:9px;opacity:0.4;margin:4px 0 0;">Click an object, or type a neutral object label. Shift adds, Alt subtracts.</p>
|
||||
</div>
|
||||
<div class="ge-inpaint-section" id="ge-inpaint-section" style="display:none;">
|
||||
<div class="ge-inpaint-popover-head" data-inpaint-drag>
|
||||
<div class="ge-section-title ge-section-title-with-help ge-inpaint-popover-title"><span>INPAINT</span><span class="ge-section-help" tabindex="0" role="img" aria-label="How inpaint works" title="Brush the area you want the AI to redraw — the red preview marks the mask region. Use Paint to add, Erase to subtract (or hold Ctrl+Alt to flip for one stroke). Generate fills with what your prompt describes; Remove fills with the surrounding background.">?</span></div>
|
||||
@@ -189,6 +218,12 @@ export function controlsHTML({ color, brushSize, wandTolerance }) {
|
||||
<label>Edge stroke <span id="ge-edgestroke-label">0px</span></label>
|
||||
<input type="range" id="ge-edgestroke-slider" min="-80" max="80" value="0" title="Expand (+) or contract (−) the inpaint layer's edge before feathering. Uses the AI buffer generated around your brush." />
|
||||
</div>
|
||||
<div class="ge-control-row ge-actions" id="ge-inpaint-automatch-row" style="display:none;margin-top:6px;">
|
||||
<button class="ge-btn ge-btn-sm ge-btn-iconlabel ge-btn-ai" id="ge-inpaint-automatch" style="width:100%;justify-content:center;" title="Match the latest inpaint result to the surrounding colour and lighting using an adjustment layer.">
|
||||
<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>
|
||||
Auto match color
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ge-eraser-section" id="ge-clone-section" style="display:none;">
|
||||
<div class="ge-section-title ge-section-title-with-help"><span>Clone</span><span class="ge-section-help" tabindex="0" role="img" aria-label="How clone works" title="Alt-click (desktop) or double-tap (mobile) somewhere on the canvas to set the sample source. Then drag elsewhere to clone those pixels onto the active layer. The source point moves with your brush so the offset stays constant. Size / Opacity / Flow / Softness come from the Brush panel.">?</span></div>
|
||||
|
||||
@@ -27,6 +27,7 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
{ id: 'clone', label: 'Clone', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="9" r="3"/><path d="M9 12l-3 4h12l-3-4"/><path d="M4 20h16"/></svg>', key: 'K' },
|
||||
{ id: 'lasso', label: 'Lasso', icon: '⟡', key: 'L' },
|
||||
{ id: 'wand', label: 'Wand', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 4V2"/><path d="M15 16v-2"/><path d="M8 9h2"/><path d="M20 9h2"/><path d="M17.8 11.8L19 13"/><path d="M15 9h0"/><path d="M17.8 6.2L19 5"/><path d="M3 21l9-9"/><path d="M12.2 6.2L11 5"/></svg>', key: 'W' },
|
||||
{ id: 'sam', label: 'SAM', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7c3-3 13-3 16 0"/><path d="M4 17c3 3 13 3 16 0"/><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3"/></svg>' },
|
||||
{ sep: true },
|
||||
{ id: 'inpaint', label: 'Inpaint', ai: true, icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.06 11.9l8.07-8.06a2.85 2.85 0 1 1 4.03 4.03l-8.06 8.08"/><path d="M7.07 14.94c-1.66 0-3 1.35-3 3.02 0 1.33-2.5 1.52-2 2.02 1.08 1.1 2.49 2.02 4 2.02 2.2 0 4-1.8 4-4.04a3.01 3.01 0 0 0-3-3.02z"/></svg>', key: 'M' },
|
||||
{ id: 'rembg', ai: true, label: 'Bg Remove', icon: '✄' },
|
||||
@@ -54,8 +55,9 @@ export function buildToolbar({ currentTool, onSelectTool, onClearSelection }) {
|
||||
// Selection-clear badge — rendered only for tools that can hold a
|
||||
// selection (lasso, wand). Inpaint masks are first-class sub-layers
|
||||
// now so they get their own delete-X in the layer panel.
|
||||
const clearBadge = (t.id === 'lasso' || t.id === 'wand')
|
||||
? '<span class="ge-tool-clear" title="Clear selection" data-clear-tool="' + t.id + '">' +
|
||||
const clearTitle = t.id === 'sam' ? 'Open SAM prompt' : 'Clear selection';
|
||||
const clearBadge = (t.id === 'lasso' || t.id === 'wand' || t.id === 'sam')
|
||||
? '<span class="ge-tool-clear" title="' + clearTitle + '" data-clear-tool="' + t.id + '">' +
|
||||
'<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg>' +
|
||||
'</span>'
|
||||
: '';
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
* openFxPopup: (layer: object, anchor: HTMLElement) => void,
|
||||
* editAdjLayer: (layer: object, adj: object, anchor: HTMLElement) => void,
|
||||
* createLayer: (name: string, w: number, h: number) => object,
|
||||
* renderLayer?: (layer: object) => HTMLCanvasElement,
|
||||
* lassoToMask: () => void,
|
||||
* wandToMask: () => void,
|
||||
* getActiveMaskLayer: () => object | null,
|
||||
@@ -54,7 +55,7 @@ export function createLayerPanelRenderer(deps) {
|
||||
const {
|
||||
composite, saveState, showLayerThumb, hideLayerThumb,
|
||||
loadLayerAlphaAsSelection, openFxPopup, editAdjLayer,
|
||||
createLayer, lassoToMask, wandToMask, getActiveMaskLayer,
|
||||
createLayer, renderLayer, lassoToMask, wandToMask, getActiveMaskLayer,
|
||||
syncFxPanelToActiveLayerIfPresent,
|
||||
dragSortModule, uiModule,
|
||||
} = deps;
|
||||
@@ -336,7 +337,7 @@ export function createLayerPanelRenderer(deps) {
|
||||
mergeDownBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
saveState(`Merge "${layer.name}" down`);
|
||||
mergeLayerDownAtIndex(i);
|
||||
mergeLayerDownAtIndex(i, renderLayer);
|
||||
composite();
|
||||
render();
|
||||
uiModule.showToast('Layer merged down');
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
* @param {{
|
||||
* composite: () => void,
|
||||
* applyInpaintFeather: (layer: object, featherPx: number, edgeShiftPx: number) => void,
|
||||
* autoMatchInpaint: () => void,
|
||||
* syncToolClearIndicators: () => void,
|
||||
* attachColorPicker: (el: HTMLInputElement) => void,
|
||||
* uiModule: object,
|
||||
@@ -37,7 +38,7 @@ const EYE_OPEN_SM = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
|
||||
const EYE_OFF_SM = '<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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><line x1="8" y1="16" x2="16" y2="8"/><line x1="8" y1="8" x2="16" y2="16"/></svg>';
|
||||
|
||||
export function wireInpaintControls({
|
||||
composite, applyInpaintFeather, syncToolClearIndicators,
|
||||
composite, applyInpaintFeather, autoMatchInpaint, syncToolClearIndicators,
|
||||
attachColorPicker, uiModule,
|
||||
}) {
|
||||
// ── Feather + Strength preview swatches ──
|
||||
@@ -93,6 +94,9 @@ export function wireInpaintControls({
|
||||
document.getElementById('ge-strength-label').textContent = (e.target.value / 100).toFixed(2);
|
||||
syncStrengthPreview(parseInt(e.target.value, 10));
|
||||
});
|
||||
document.getElementById('ge-inpaint-automatch')?.addEventListener('click', () => {
|
||||
if (typeof autoMatchInpaint === 'function') autoMatchInpaint();
|
||||
});
|
||||
syncFeatherPreview(0);
|
||||
syncStrengthPreview(75);
|
||||
|
||||
|
||||
@@ -15,32 +15,60 @@
|
||||
* createLayer: (name, w, h) => object,
|
||||
* renderLayerPanel: () => void,
|
||||
* composite: () => void,
|
||||
* renderLayer?: (layer) => HTMLCanvasElement,
|
||||
* uiModule: object,
|
||||
* }} deps
|
||||
*/
|
||||
import { state } from './state.js';
|
||||
|
||||
export function mergeLayerDownAtIndex(idx) {
|
||||
function _renderSource(layer, renderLayer) {
|
||||
if (!layer) return null;
|
||||
try {
|
||||
return typeof renderLayer === 'function' ? (renderLayer(layer) || layer.canvas) : layer.canvas;
|
||||
} catch {
|
||||
return layer.canvas;
|
||||
}
|
||||
}
|
||||
|
||||
function _clearBakedAdjustments(layer) {
|
||||
if (!layer) return;
|
||||
layer.adjLayers = [];
|
||||
layer._adjFinal = null;
|
||||
layer._adjFinalKey = '';
|
||||
layer._adjCache = null;
|
||||
layer._adjCacheKey = '';
|
||||
}
|
||||
|
||||
export function mergeLayerDownAtIndex(idx, renderLayer = null) {
|
||||
if (idx < 1 || idx >= state.layers.length) return null;
|
||||
const upper = state.layers[idx];
|
||||
const lower = state.layers[idx - 1];
|
||||
const upperOff = state.layerOffsets.get(upper.id) || { x: 0, y: 0 };
|
||||
const lowerOff = state.layerOffsets.get(lower.id) || { x: 0, y: 0 };
|
||||
lower.ctx.save();
|
||||
lower.ctx.globalAlpha = upper.opacity;
|
||||
lower.ctx.drawImage(
|
||||
upper.canvas,
|
||||
upperOff.x - lowerOff.x,
|
||||
upperOff.y - lowerOff.y,
|
||||
);
|
||||
lower.ctx.restore();
|
||||
const lowerSource = _renderSource(lower, renderLayer);
|
||||
const upperSource = _renderSource(upper, renderLayer);
|
||||
const merged = document.createElement('canvas');
|
||||
merged.width = state.imgWidth;
|
||||
merged.height = state.imgHeight;
|
||||
const mctx = merged.getContext('2d');
|
||||
mctx.globalAlpha = lower.opacity;
|
||||
mctx.drawImage(lowerSource, lowerOff.x, lowerOff.y);
|
||||
mctx.globalAlpha = upper.opacity;
|
||||
mctx.drawImage(upperSource, upperOff.x, upperOff.y);
|
||||
mctx.globalAlpha = 1;
|
||||
lower.canvas = merged;
|
||||
lower.ctx = lower.canvas.getContext('2d');
|
||||
lower.opacity = 1;
|
||||
lower.visible = true;
|
||||
state.layerOffsets.set(lower.id, { x: 0, y: 0 });
|
||||
_clearBakedAdjustments(lower);
|
||||
state.layers.splice(idx, 1);
|
||||
state.layerOffsets.delete(upper.id);
|
||||
state.activeLayerId = lower.id;
|
||||
return lower;
|
||||
}
|
||||
|
||||
export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, composite, uiModule }) {
|
||||
export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, composite, renderLayer, uiModule }) {
|
||||
// Flatten Copy.
|
||||
document.getElementById('ge-flatten')?.addEventListener('click', () => {
|
||||
if (state.layers.length < 2) return;
|
||||
@@ -51,9 +79,10 @@ export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, com
|
||||
if (!l.visible) continue;
|
||||
const off = state.layerOffsets.get(l.id) || { x: 0, y: 0 };
|
||||
ctx.globalAlpha = l.opacity;
|
||||
ctx.drawImage(l.canvas, off.x, off.y);
|
||||
ctx.drawImage(_renderSource(l, renderLayer), off.x, off.y);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
_clearBakedAdjustments(merged);
|
||||
state.layers.push(merged);
|
||||
state.activeLayerId = merged.id;
|
||||
renderLayerPanel();
|
||||
@@ -70,14 +99,23 @@ export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, com
|
||||
}
|
||||
saveState('Merge all');
|
||||
const base = visibleLayers[0];
|
||||
const baseCtx = base.ctx;
|
||||
for (let i = 1; i < visibleLayers.length; i++) {
|
||||
const merged = document.createElement('canvas');
|
||||
merged.width = state.imgWidth;
|
||||
merged.height = state.imgHeight;
|
||||
const baseCtx = merged.getContext('2d');
|
||||
for (let i = 0; i < visibleLayers.length; i++) {
|
||||
const l = visibleLayers[i];
|
||||
const off = state.layerOffsets.get(l.id) || { x: 0, y: 0 };
|
||||
baseCtx.globalAlpha = l.opacity;
|
||||
baseCtx.drawImage(l.canvas, off.x, off.y);
|
||||
baseCtx.drawImage(_renderSource(l, renderLayer), off.x, off.y);
|
||||
baseCtx.globalAlpha = 1;
|
||||
}
|
||||
base.canvas = merged;
|
||||
base.ctx = base.canvas.getContext('2d');
|
||||
base.opacity = 1;
|
||||
base.visible = true;
|
||||
state.layerOffsets.set(base.id, { x: 0, y: 0 });
|
||||
_clearBakedAdjustments(base);
|
||||
// Free offset entries for the discarded layers; keep base.
|
||||
for (const l of state.layers) {
|
||||
if (l === base) continue;
|
||||
@@ -95,7 +133,7 @@ export function wireMergeButtons({ saveState, createLayer, renderLayerPanel, com
|
||||
const idx = state.layers.findIndex(l => l.id === state.activeLayerId);
|
||||
if (idx < 1) return; // can't merge the bottom layer
|
||||
saveState('Merge down');
|
||||
mergeLayerDownAtIndex(idx);
|
||||
mergeLayerDownAtIndex(idx, renderLayer);
|
||||
renderLayerPanel();
|
||||
composite();
|
||||
uiModule.showToast('Layer merged down');
|
||||
|
||||
@@ -71,10 +71,15 @@ export function wireTopbar(deps) {
|
||||
// original IDs so the standalone handlers below wire to them
|
||||
// unchanged.
|
||||
{
|
||||
const saveBtn = document.getElementById('ge-save-menu-btn');
|
||||
const saveMenu = document.getElementById('ge-save-menu');
|
||||
const editorRoot = document.getElementById('gallery-editor-container') || document;
|
||||
const saveBtn = editorRoot.querySelector('#ge-save-menu-btn');
|
||||
const saveWrap = saveBtn?.closest('.ge-save-wrap');
|
||||
const saveMenu = saveWrap?.querySelector('#ge-save-menu');
|
||||
if (saveBtn && saveMenu) {
|
||||
const saveTopbar = saveBtn.closest('.ge-topbar');
|
||||
document.querySelectorAll('body > #ge-save-menu').forEach((menu) => {
|
||||
if (menu !== saveMenu) menu.remove();
|
||||
});
|
||||
// Reparent the menu to <body>. Without this, the menu inherits
|
||||
// the gallery modal's containing block (the modal applies a
|
||||
// `transform: scale(...)` for its enter animation — and any
|
||||
@@ -105,7 +110,7 @@ export function wireTopbar(deps) {
|
||||
saveMenu.addEventListener('click', () => { setSaveMenuOpen(false); });
|
||||
window.addEventListener('resize', () => { if (!saveMenu.hidden) positionSaveMenu(); });
|
||||
registerDocClickAway((e) => {
|
||||
if (!saveMenu.hidden && !saveMenu.contains(e.target) && e.target !== saveBtn) {
|
||||
if (!saveMenu.hidden && !saveMenu.contains(e.target) && !saveBtn.contains(e.target)) {
|
||||
setSaveMenuOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
+85
-42
@@ -5,7 +5,7 @@
|
||||
|
||||
import spinnerModule from './spinner.js';
|
||||
import sessionModule from './sessions.js';
|
||||
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen } from './emailLibrary.js';
|
||||
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen, prewarmEmailLibrary, prewarmUnreadEmails } from './emailLibrary.js?v=20260722emailfastindex1';
|
||||
import * as Modals from './modalManager.js';
|
||||
import { applyEdgeDock } from './modalSnap.js';
|
||||
import { buildReplyAllCc, extractEmail } from './emailLibrary/replyRecipients.js';
|
||||
@@ -112,21 +112,10 @@ function _cleanAiReplyText(text) {
|
||||
return t
|
||||
.replace(/<<<\s*(?:REPLY|SUMMARY|OUTPUT)\s*>>+/gi, '')
|
||||
.replace(/<<<\s*END\s*>>+/gi, '')
|
||||
.replace(/<\/?\|(?:assistant|assistan|user|system|tool)\|>?|<\/\|end\|>?/gi, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function _shouldUseFastAiReply(data) {
|
||||
const body = String(data?.body || data?.body_html || '');
|
||||
const subject = String(data?.subject || '');
|
||||
const atts = Array.isArray(data?.attachments) ? data.attachments : [];
|
||||
if (atts.length > 0) return false;
|
||||
const text = `${subject}\n${body}`.toLowerCase();
|
||||
if (/\b(attach(?:ed|ment)?|pdf|document|contract|invoice|receipt|quote|estimate|proposal|question|questions|details|schedule|booking|reservation|meeting|calendar|availability|confirm|confirmation|review|sign|signature)\b/.test(text)) {
|
||||
return false;
|
||||
}
|
||||
return body.length < 2500;
|
||||
}
|
||||
|
||||
let _emails = [];
|
||||
let _currentFolder = 'INBOX';
|
||||
let _offset = 0;
|
||||
@@ -202,6 +191,7 @@ export function init(documentModule) {
|
||||
}
|
||||
},
|
||||
});
|
||||
prewarmEmailLibrary({ delay: 1800 });
|
||||
_watchDocOpenToReDockEmail();
|
||||
}
|
||||
|
||||
@@ -348,7 +338,11 @@ async function _refreshUnreadCount() {
|
||||
const maxUid = parseInt(data.max_uid || '0', 10) || 0;
|
||||
|
||||
// Only show dot if there's a new email above the threshold
|
||||
dot.style.display = maxUid > lastSeen ? '' : 'none';
|
||||
const hasNewUnread = maxUid > lastSeen;
|
||||
dot.style.display = hasNewUnread ? '' : 'none';
|
||||
if (hasNewUnread && !isLibOpen()) {
|
||||
prewarmUnreadEmails({ limit: Math.min(10, Math.max(1, unreadCount)), maxUid }).catch(() => {});
|
||||
}
|
||||
|
||||
// Color the dot by urgency tier. Cache the per-uid map so the per-row
|
||||
// renderer can reuse it without a second fetch.
|
||||
@@ -405,22 +399,29 @@ export async function loadEmails(append = false) {
|
||||
|
||||
try {
|
||||
const fromQS = _senderFilter ? `&from=${encodeURIComponent(_senderFilter)}` : '';
|
||||
const applyListData = (data) => {
|
||||
if (!append) _emails = [];
|
||||
_emails.push(...(data.emails || []));
|
||||
_total = data.total || 0;
|
||||
if (_listSpinner) { _listSpinner.destroy(); _listSpinner = null; }
|
||||
_renderList();
|
||||
const unreadCount = _emails.filter(e => !e.is_read).length;
|
||||
const dot = document.getElementById('email-unread-dot');
|
||||
if (dot) dot.style.display = unreadCount > 0 ? '' : 'none';
|
||||
};
|
||||
if (!append && !_senderFilter) {
|
||||
try {
|
||||
const cachedRes = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(_currentFolder)}&limit=50&offset=${_offset}&cached_only=1${_acct()}`);
|
||||
const cachedData = await cachedRes.json();
|
||||
if (!cachedData.error && (cachedData.emails || []).length) {
|
||||
applyListData(cachedData);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/api/email/list?folder=${encodeURIComponent(_currentFolder)}&limit=50&offset=${_offset}${fromQS}${_acct()}`);
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error(data.error);
|
||||
|
||||
if (!append) _emails = [];
|
||||
_emails.push(...(data.emails || []));
|
||||
_total = data.total || 0;
|
||||
|
||||
// Remove spinner
|
||||
if (_listSpinner) { _listSpinner.destroy(); _listSpinner = null; }
|
||||
|
||||
_renderList();
|
||||
|
||||
const unreadCount = _emails.filter(e => !e.is_read).length;
|
||||
const dot = document.getElementById('email-unread-dot');
|
||||
if (dot) dot.style.display = unreadCount > 0 ? '' : 'none';
|
||||
applyListData(data);
|
||||
} catch (e) {
|
||||
console.error('Failed to load emails:', e);
|
||||
if (_listSpinner) { _listSpinner.destroy(); _listSpinner = null; }
|
||||
@@ -751,7 +752,7 @@ function _createEmailItem(em) {
|
||||
}
|
||||
|
||||
async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', noteHint = '', prefilledBody = '') {
|
||||
const aiReplyMode = mode === 'ai-reply-fast' ? 'fast' : (mode === 'ai-reply-full' ? 'full' : '');
|
||||
const aiReplyMode = mode === 'ai-reply-fast' ? 'fast' : '';
|
||||
const wantsAiReply = mode === 'ai-reply' || !!aiReplyMode;
|
||||
// Body pre-fill from the agent's open_email_reply tool call takes the
|
||||
// same insertion slot as an AI-suggested body — both land just before
|
||||
@@ -786,8 +787,29 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
console.error('Failed to read email:', data.error);
|
||||
return;
|
||||
}
|
||||
// The list row is already populated from the durable email index. Some
|
||||
// IMAP/read paths can return a partial object for long Outlook threads;
|
||||
// never let that create a reply draft with blank To/Subject.
|
||||
const _fallback = (primary, fallback) => {
|
||||
const p = primary == null ? '' : String(primary).trim();
|
||||
if (p) return primary;
|
||||
return fallback == null ? '' : fallback;
|
||||
};
|
||||
data = {
|
||||
...em,
|
||||
...data,
|
||||
uid: data.uid || em.uid,
|
||||
subject: _fallback(data.subject, em.subject),
|
||||
from_name: _fallback(data.from_name, em.from_name || em.from_address),
|
||||
from_address: _fallback(data.from_address, em.from_address),
|
||||
to: _fallback(data.to, em.to),
|
||||
cc: _fallback(data.cc, em.cc),
|
||||
date: _fallback(data.date, em.date),
|
||||
message_id: _fallback(data.message_id, em.message_id),
|
||||
};
|
||||
if (wantsAiReply) {
|
||||
if (data.cached_ai_reply) {
|
||||
const activeReplyAccount = data.account_id || em.account_id || window.__odysseusActiveEmailAccount || '';
|
||||
if (data.cached_ai_reply && !noteHint && !activeReplyAccount) {
|
||||
aiSuggestedBody = _cleanAiReplyText(data.cached_ai_reply);
|
||||
} else {
|
||||
let draftToastTimer = null;
|
||||
@@ -813,7 +835,8 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
message_id: data.message_id || '',
|
||||
uid: String(em.uid || ''),
|
||||
folder: _currentFolder,
|
||||
fast: aiReplyMode ? aiReplyMode === 'fast' : _shouldUseFastAiReply(data),
|
||||
account_id: activeReplyAccount,
|
||||
fast: true,
|
||||
user_hint: (noteHint || '').trim() || undefined,
|
||||
}),
|
||||
});
|
||||
@@ -880,6 +903,9 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
let _baseSubject = (data.subject || '').trim();
|
||||
if (subjectPrefix === 'Re: ' && /^re\s*:/i.test(_baseSubject)) subjectPrefix = '';
|
||||
else if (subjectPrefix === 'Fwd: ' && /^fwd?\s*:/i.test(_baseSubject)) subjectPrefix = '';
|
||||
if (mode !== 'forward' && !String(toAddress || '').trim()) {
|
||||
throw new Error('Cannot create reply: sender address is missing from this email.');
|
||||
}
|
||||
let content = `To: ${toAddress}\nSubject: ${subjectPrefix}${_baseSubject}`;
|
||||
if (ccAddresses) content += `\nCc: ${ccAddresses}`;
|
||||
if (mode !== 'forward' && data.message_id) content += `\nIn-Reply-To: ${data.message_id}`;
|
||||
@@ -949,9 +975,10 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
|
||||
if (_docModule) {
|
||||
// Agent-provided reply text should land in the email draft the user
|
||||
// already has open. Otherwise mobile users see the source email while the
|
||||
// agent silently creates a second draft elsewhere.
|
||||
const reuseExisting = mode !== 'forward';
|
||||
// already has open. Plain Reply clicks must create a fresh draft: reusing
|
||||
// old source-UID drafts can reopen stale quote-only/malformed compose docs
|
||||
// and block Send on long threads.
|
||||
const reuseExisting = mode !== 'forward' && !!aiSuggestedBody;
|
||||
const existingDocId = (reuseExisting && _docModule.findEmailDocId)
|
||||
? _docModule.findEmailDocId(em.uid, _currentFolder)
|
||||
: null;
|
||||
@@ -959,28 +986,37 @@ async function _openEmail(em, itemEl, preloadedData = null, mode = 'reply', note
|
||||
if (!_docModule.isPanelOpen()) _docModule.openPanel();
|
||||
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
await _docModule.loadDocument(existingDocId);
|
||||
if (typeof _docModule.ensureEmailDraftEnvelope === 'function') {
|
||||
await _docModule.ensureEmailDraftEnvelope(existingDocId, content);
|
||||
}
|
||||
if (aiSuggestedBody && typeof _docModule.replaceEmailReplyBody === 'function') {
|
||||
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody, { force: true });
|
||||
await _docModule.replaceEmailReplyBody(existingDocId, aiSuggestedBody, { force: false });
|
||||
}
|
||||
_bringEmailReplyDraftToFrontOnMobile();
|
||||
} else {
|
||||
const activeSid = await _createEmailChat(data);
|
||||
let activeSid = await _createEmailChat(data, { forceNew: true });
|
||||
if (!activeSid) {
|
||||
console.error('reply: could not obtain a session_id');
|
||||
import('./ui.js').then(m => m.showError && m.showError('Could not start a reply chat.')).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
const docRes = await fetch(`${API_BASE}/api/document`, {
|
||||
const createReplyDoc = (sessionId) => fetch(`${API_BASE}/api/document`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: activeSid,
|
||||
session_id: sessionId,
|
||||
title: data.subject,
|
||||
content: content,
|
||||
language: 'email',
|
||||
}),
|
||||
});
|
||||
let docRes = await createReplyDoc(activeSid);
|
||||
if (docRes.status === 404) {
|
||||
console.warn('[reply-debug] draft session rejected; retrying in a fresh email chat', activeSid);
|
||||
activeSid = await _createEmailChat(data, { forceNew: true });
|
||||
if (activeSid) docRes = await createReplyDoc(activeSid);
|
||||
}
|
||||
if (!docRes.ok) {
|
||||
const errText = await docRes.text();
|
||||
console.error('[reply-debug] POST /api/document failed', docRes.status, errText);
|
||||
@@ -1255,9 +1291,10 @@ async function _toggleDone(em, itemEl) {
|
||||
}
|
||||
}
|
||||
|
||||
async function _createEmailChat(emailData) {
|
||||
async function _createEmailChat(emailData, opts = {}) {
|
||||
const subject = String(emailData?.subject || 'New Email').trim() || 'New Email';
|
||||
const title = subject === 'New Email' ? 'New Email' : `Email: ${subject.slice(0, 60)}`;
|
||||
const forceNew = !!opts.forceNew;
|
||||
try {
|
||||
const currentSid = sessionModule.getCurrentSessionId?.() || '';
|
||||
const current = sessionModule.getSessions?.().find(s => s.id === currentSid);
|
||||
@@ -1268,7 +1305,7 @@ async function _createEmailChat(emailData) {
|
||||
&& Number(current.message_count || 0) === 0
|
||||
&& current.folder !== 'Assistant'
|
||||
&& current.folder !== 'Tasks';
|
||||
if (currentIsBlank) {
|
||||
if (!forceNew && currentIsBlank) {
|
||||
const meta = document.getElementById('current-meta');
|
||||
if (meta) meta.textContent = title;
|
||||
return current.id;
|
||||
@@ -1319,22 +1356,28 @@ async function _composeNew() {
|
||||
// (doc shows for a frame, then slides up again). Mount once, at injectFreshDoc,
|
||||
// after the session + doc exist.
|
||||
try {
|
||||
const sid = await _createEmailChat({ subject: 'New Email' });
|
||||
let sid = await _createEmailChat({ subject: 'New Email' });
|
||||
if (!sid) {
|
||||
console.error('compose: could not obtain a session_id');
|
||||
import('./ui.js').then(m => m.showError && m.showError('Could not start a new email (no session).')).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/api/document`, {
|
||||
const createComposeDoc = (sessionId) => fetch(`${API_BASE}/api/document`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: sid,
|
||||
session_id: sessionId,
|
||||
title: 'New Email',
|
||||
content: 'To: \nSubject: \n---\n',
|
||||
language: 'email',
|
||||
}),
|
||||
});
|
||||
let res = await createComposeDoc(sid);
|
||||
if (res.status === 404) {
|
||||
console.warn('[compose-debug] draft session rejected; retrying in a fresh email chat', sid);
|
||||
sid = await _createEmailChat({ subject: 'New Email' }, { forceNew: true });
|
||||
if (sid) res = await createComposeDoc(sid);
|
||||
}
|
||||
if (!res.ok) {
|
||||
console.error('compose POST failed', res.status, await res.text().catch(() => ''));
|
||||
import('./ui.js').then(m => m.showError && m.showError('Failed to create new email (' + res.status + ')')).catch(() => {});
|
||||
|
||||
+1506
-785
File diff suppressed because it is too large
Load Diff
@@ -356,14 +356,14 @@ export async function uploadPending(opts = {}) {
|
||||
/**
|
||||
* Add files to pending list (capped at MAX_FILES)
|
||||
*/
|
||||
export async function addFiles(files) {
|
||||
export async function addFiles(files, opts = {}) {
|
||||
for (const f of files) {
|
||||
if (pendingFiles.length >= MAX_FILES) {
|
||||
_showToast(`Max ${MAX_FILES} files allowed`);
|
||||
break;
|
||||
}
|
||||
let nextFile = f;
|
||||
if (_isMobileViewport() && _isCroppableImage(f)) {
|
||||
if (!opts.skipCrop && _isMobileViewport() && _isCroppableImage(f)) {
|
||||
try {
|
||||
nextFile = await _openMobileCropper(f);
|
||||
} catch (_) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import uiModule from './ui.js';
|
||||
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js';
|
||||
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js?v=20260708match1';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
|
||||
+471
-13
@@ -54,12 +54,12 @@ import {
|
||||
buildThumbnail as _buildThumbnailImpl,
|
||||
buildMergedMaskCanvas as _buildMergedMaskCanvasImpl,
|
||||
} from './editor/composite-helpers.js';
|
||||
import { buildToolbar as _buildToolbar } from './editor/build/toolbar.js';
|
||||
import { buildToolbar as _buildToolbar } from './editor/build/toolbar.js?v=20260708sam3';
|
||||
import { buildTopbar as _buildTopbar } from './editor/build/topbar.js';
|
||||
import {
|
||||
controlsHTML as _controlsHTML,
|
||||
layerPanelHTML as _layerPanelHTML,
|
||||
} from './editor/build/controls.js';
|
||||
} from './editor/build/controls.js?v=20260708match1';
|
||||
import {
|
||||
transformPopupHTML as _transformPopupHTML,
|
||||
attachSpinRepeat as _attachSpinRepeat,
|
||||
@@ -96,14 +96,14 @@ import { createShortcutsPopover } from './editor/shortcuts-popover.js';
|
||||
import { wireKeyboardShortcuts } from './editor/keyboard-shortcuts.js';
|
||||
import { wireClipboardAndDrop } from './editor/clipboard-and-drop.js';
|
||||
import { wireAIModelSelectors } from './editor/ai-models.js';
|
||||
import { wireInpaintButtons } from './editor/ai-inpaint.js';
|
||||
import { wireInpaintButtons } from './editor/ai-inpaint.js?v=20260708match1';
|
||||
import { wireAIToolsMisc } from './editor/ai-tools-misc.js';
|
||||
import { wireRembgAndSharpen } from './editor/ai-rembg.js';
|
||||
import { wireStrokeToolSliders } from './editor/stroke-tool-sliders.js';
|
||||
import { wireImport } from './editor/wire-import.js';
|
||||
import { wireMergeButtons } from './editor/wire-merge-buttons.js';
|
||||
import { wireSelectionControls } from './editor/wire-selection-controls.js';
|
||||
import { wireInpaintControls } from './editor/wire-inpaint-controls.js';
|
||||
import { wireInpaintControls } from './editor/wire-inpaint-controls.js?v=20260708match1';
|
||||
import { wireTopbar, closeOtherTopbarMenus as _closeOtherTopbarMenus } from './editor/wire-topbar.js';
|
||||
import { wireTopbarOverflow } from './editor/wire-topbar-overflow.js';
|
||||
import { wireTopbarMenus } from './editor/wire-topbar-menus.js';
|
||||
@@ -125,6 +125,14 @@ function _syncTransformOverlay() { _syncTransformOverlayImpl(_TRANSFORM_OVERLAY_
|
||||
// the inpaint tool for the first time in this editor session we bump
|
||||
// the slider to this value (without touching other tools).
|
||||
const _INPAINT_DEFAULT_BRUSH = 100;
|
||||
let _samAbortController = null;
|
||||
|
||||
function _cancelSamQuery(showToast = true) {
|
||||
if (!_samAbortController) return false;
|
||||
try { _samAbortController.abort(); } catch {}
|
||||
if (showToast && uiModule) uiModule.showToast('SAM query cancelled');
|
||||
return true;
|
||||
}
|
||||
|
||||
function _galleryEditMounted() {
|
||||
return !!document.querySelector('#gallery-editor-container .gallery-editor');
|
||||
@@ -133,6 +141,15 @@ function _galleryEditMounted() {
|
||||
if (!window.__galleryEditEscHardGuardInstalled) {
|
||||
window.__galleryEditEscHardGuardInstalled = true;
|
||||
window.addEventListener('keydown', (e) => {
|
||||
const isSamCancel = !!_samAbortController
|
||||
&& (e.key === 'Escape' || ((e.ctrlKey || e.metaKey) && String(e.key || '').toLowerCase() === 'c'));
|
||||
if (isSamCancel) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
_cancelSamQuery();
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Escape') return;
|
||||
if (window.__galleryEditLive || _galleryEditMounted()) {
|
||||
e.preventDefault();
|
||||
@@ -272,6 +289,16 @@ function _setAiCommandStatus(text, kind = '') {
|
||||
el.dataset.kind = kind || '';
|
||||
}
|
||||
|
||||
function _escapeAiCommandText(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
function _clickToolButton(toolId) {
|
||||
const btn = state.container?.querySelector(`.ge-tool-btn[data-tool="${toolId}"]`);
|
||||
if (btn) btn.click();
|
||||
@@ -288,6 +315,21 @@ function _runExistingButton(id, status) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function _openSamPrompt() {
|
||||
if (state.tool !== 'sam') {
|
||||
_clickToolButton('sam');
|
||||
} else {
|
||||
const controls = document.getElementById('ge-controls') || document.querySelector('.ge-controls');
|
||||
controls?.classList.remove('dismissed');
|
||||
document.getElementById('ge-sam-section')?.style.removeProperty('display');
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
const input = document.getElementById('ge-sam-query');
|
||||
input?.focus();
|
||||
input?.select?.();
|
||||
});
|
||||
}
|
||||
|
||||
function _buildAiCommandBox() {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'ge-ai-command ge-ai-command-collapsed';
|
||||
@@ -295,7 +337,10 @@ function _buildAiCommandBox() {
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="ge-ai-command-toggle" id="ge-ai-command-toggle" aria-expanded="false">
|
||||
<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>
|
||||
<span>AI Edit</span>
|
||||
<span>Quick Edit</span>
|
||||
<svg class="ge-ai-command-toggle-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 15 12 9 18 15"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<form class="ge-ai-command-form" id="ge-ai-command-form">
|
||||
<input type="text" class="ge-ai-command-input" id="ge-ai-command-input" autocomplete="off" />
|
||||
@@ -305,18 +350,49 @@ function _buildAiCommandBox() {
|
||||
<polyline points="5 12 12 5 19 12"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="ge-ai-command-close" id="ge-ai-command-close" title="Close AI edit" aria-label="Close AI edit">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
<button type="button" class="ge-ai-command-close" id="ge-ai-command-close" title="Collapse AI edit" aria-label="Collapse AI edit">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
<div class="ge-ai-command-suggestions" id="ge-ai-command-suggestions" hidden></div>
|
||||
<div class="ge-ai-command-status" id="ge-ai-command-status" aria-live="polite"></div>
|
||||
`;
|
||||
return wrap;
|
||||
}
|
||||
|
||||
const _AI_COMMAND_SUGGESTIONS = [
|
||||
{ label: 'Rotate 90', insert: 'rotate 90', hint: 'Turn the image clockwise', aliases: ['ro', 'rotate', 'right', 'clockwise', 'turn'] },
|
||||
{ label: 'Rotate left', insert: 'rotate left', hint: 'Turn the image counter-clockwise', aliases: ['rotate left', 'left', 'counter clockwise', 'ccw'] },
|
||||
{ label: 'Rotate 180', insert: 'rotate 180', hint: 'Flip the canvas upside down', aliases: ['rotate 180', 'upside down'] },
|
||||
{ label: 'Flip horizontal', insert: 'flip horizontal', hint: 'Mirror left to right', aliases: ['flip', 'mirror', 'horizontal'] },
|
||||
{ label: 'Flip vertical', insert: 'flip vertical', hint: 'Mirror top to bottom', aliases: ['flip vertical', 'vertical'] },
|
||||
{ label: 'Remove background', insert: 'remove background', hint: 'Make the background transparent', aliases: ['remove bg', 'background', 'transparent', 'cut out'] },
|
||||
{ label: 'Upscale', insert: 'upscale 2x', hint: 'Increase image resolution', aliases: ['upscale', 'bigger', 'larger', '2x', '4x'] },
|
||||
{ label: 'Denoise', insert: 'denoise', hint: 'Reduce grain and noise', aliases: ['denoise', 'noise', 'grain', 'clean up'] },
|
||||
{ label: 'Sharpen', insert: 'sharpen', hint: 'Make details crisper', aliases: ['sharpen', 'sharp', 'clearer', 'crisp', 'enhance'] },
|
||||
{ label: 'Enhance face', insert: 'enhance face', hint: 'Restore portrait and skin detail', aliases: ['face', 'portrait', 'skin', 'selfie', 'restore'] },
|
||||
{ label: 'Style edit', insert: 'style: ', hint: 'Run a full-image prompt edit', aliases: ['style', 'paint', 'anime', 'photo', 'prompt'] },
|
||||
];
|
||||
|
||||
function _matchAiCommandSuggestions(query) {
|
||||
const q = (query || '').trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
return _AI_COMMAND_SUGGESTIONS
|
||||
.map((item) => {
|
||||
const hay = [item.label, item.insert, ...(item.aliases || [])].map(v => String(v || '').toLowerCase());
|
||||
const starts = hay.some(v => v.startsWith(q));
|
||||
const contains = hay.some(v => v.includes(q));
|
||||
if (!starts && !contains) return null;
|
||||
return { item, score: starts ? 0 : 1 };
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.score - b.score || a.item.label.localeCompare(b.item.label))
|
||||
.slice(0, 6)
|
||||
.map(hit => hit.item);
|
||||
}
|
||||
|
||||
function _wireAiCommandBox() {
|
||||
const wrap = document.getElementById('ge-ai-command');
|
||||
const toggle = document.getElementById('ge-ai-command-toggle');
|
||||
@@ -324,18 +400,91 @@ function _wireAiCommandBox() {
|
||||
const form = document.getElementById('ge-ai-command-form');
|
||||
const input = document.getElementById('ge-ai-command-input');
|
||||
const runBtn = document.getElementById('ge-ai-command-run');
|
||||
const suggestions = document.getElementById('ge-ai-command-suggestions');
|
||||
if (!wrap || !form || !input || !runBtn) return;
|
||||
let suggestionItems = [];
|
||||
let suggestionIndex = 0;
|
||||
const hideSuggestions = () => {
|
||||
suggestionItems = [];
|
||||
suggestionIndex = 0;
|
||||
if (suggestions) {
|
||||
suggestions.hidden = true;
|
||||
suggestions.innerHTML = '';
|
||||
}
|
||||
};
|
||||
const renderSuggestions = () => {
|
||||
if (!suggestions || wrap.classList.contains('ge-ai-command-collapsed')) return;
|
||||
suggestionItems = _matchAiCommandSuggestions(input.value);
|
||||
suggestionIndex = Math.min(suggestionIndex, Math.max(0, suggestionItems.length - 1));
|
||||
if (!suggestionItems.length) {
|
||||
hideSuggestions();
|
||||
return;
|
||||
}
|
||||
suggestions.hidden = false;
|
||||
suggestions.innerHTML = suggestionItems.map((item, idx) => `
|
||||
<button type="button" class="ge-ai-command-suggestion${idx === suggestionIndex ? ' active' : ''}" data-ai-command-suggestion="${idx}">
|
||||
<span class="ge-ai-command-suggestion-main">${_escapeAiCommandText(item.label)}</span>
|
||||
<span class="ge-ai-command-suggestion-hint">${_escapeAiCommandText(item.hint || item.insert)}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
};
|
||||
const pickSuggestion = (idx, run = false) => {
|
||||
const item = suggestionItems[idx];
|
||||
if (!item) return false;
|
||||
input.value = item.insert;
|
||||
hideSuggestions();
|
||||
input.focus();
|
||||
if (run) form.requestSubmit();
|
||||
return true;
|
||||
};
|
||||
wrap.addEventListener('pointerdown', (e) => e.stopPropagation());
|
||||
wrap.addEventListener('click', (e) => e.stopPropagation());
|
||||
suggestions?.addEventListener('pointerdown', (e) => e.preventDefault());
|
||||
suggestions?.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-ai-command-suggestion]');
|
||||
if (!btn) return;
|
||||
pickSuggestion(Number(btn.dataset.aiCommandSuggestion), true);
|
||||
});
|
||||
const setOpen = (open) => {
|
||||
wrap.classList.toggle('ge-ai-command-collapsed', !open);
|
||||
toggle?.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
if (open) requestAnimationFrame(() => input.focus());
|
||||
if (open) requestAnimationFrame(() => {
|
||||
input.focus();
|
||||
renderSuggestions();
|
||||
});
|
||||
else hideSuggestions();
|
||||
};
|
||||
toggle?.addEventListener('click', () => setOpen(wrap.classList.contains('ge-ai-command-collapsed')));
|
||||
closeBtn?.addEventListener('click', () => setOpen(false));
|
||||
input.addEventListener('input', renderSuggestions);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
const open = suggestions && !suggestions.hidden && suggestionItems.length;
|
||||
if (open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
||||
e.preventDefault();
|
||||
suggestionIndex = e.key === 'ArrowDown'
|
||||
? (suggestionIndex + 1) % suggestionItems.length
|
||||
: (suggestionIndex - 1 + suggestionItems.length) % suggestionItems.length;
|
||||
renderSuggestions();
|
||||
return;
|
||||
}
|
||||
if (open && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
pickSuggestion(suggestionIndex, true);
|
||||
return;
|
||||
}
|
||||
if (open && e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
pickSuggestion(suggestionIndex, false);
|
||||
return;
|
||||
}
|
||||
if (open && e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
hideSuggestions();
|
||||
}
|
||||
});
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideSuggestions();
|
||||
const prompt = input.value.trim();
|
||||
if (!prompt) {
|
||||
_setAiCommandStatus('Type what you want changed.', 'error');
|
||||
@@ -344,6 +493,36 @@ function _wireAiCommandBox() {
|
||||
}
|
||||
const p = prompt.toLowerCase();
|
||||
try {
|
||||
if (/\brotate\b.*\b180\b|\bupside\s*down\b/.test(p)) {
|
||||
_saveState('Rotate 180');
|
||||
_rotateAllLayers(180);
|
||||
_setAiCommandStatus('Rotated 180.', 'done');
|
||||
return;
|
||||
}
|
||||
if (/\brotate\b.*\b(left|ccw|counter)\b|\bturn\s+left\b/.test(p)) {
|
||||
_saveState('Rotate left');
|
||||
_rotateAllLayers(270);
|
||||
_setAiCommandStatus('Rotated left.', 'done');
|
||||
return;
|
||||
}
|
||||
if (/\brotate\b|\bturn\s+right\b|\bclockwise\b/.test(p)) {
|
||||
_saveState('Rotate 90');
|
||||
_rotateAllLayers(90);
|
||||
_setAiCommandStatus('Rotated 90.', 'done');
|
||||
return;
|
||||
}
|
||||
if (/\bflip\b.*\b(vertical|v)\b|\bmirror\b.*\b(vertical|v)\b/.test(p)) {
|
||||
_saveState('Flip vertical');
|
||||
_flipAllLayers('v');
|
||||
_setAiCommandStatus('Flipped vertical.', 'done');
|
||||
return;
|
||||
}
|
||||
if (/\bflip\b|\bmirror\b/.test(p)) {
|
||||
_saveState('Flip horizontal');
|
||||
_flipAllLayers('h');
|
||||
_setAiCommandStatus('Flipped horizontal.', 'done');
|
||||
return;
|
||||
}
|
||||
if (/\b(remove|erase|cut\s*out|transparent)\b.*\b(bg|background)\b|\b(bg|background)\b.*\b(remove|erase|transparent)\b/.test(p)) {
|
||||
_clickToolButton('rembg');
|
||||
_runExistingButton('ge-rembg-run', 'Removing background...');
|
||||
@@ -1220,6 +1399,7 @@ function _beginDraw(e) {
|
||||
// it doesn't mutate the layer until an action (Erase/Copy) is taken.
|
||||
// Full implementation in editor/tools/wand.js.
|
||||
if (state.tool === 'wand') return _wandTool.click(e);
|
||||
if (state.tool === 'sam') return _runSamSelection(e);
|
||||
// Inpaint can create its own layer + mask on the fly, so skip the
|
||||
// "no active layer → bail" gate for it specifically.
|
||||
if (state.tool !== 'inpaint' && (!layer || layer.locked)) return;
|
||||
@@ -1740,6 +1920,146 @@ function _runMagicWand(cx, cy, mode = 'replace', opts = {}) {
|
||||
_syncToolClearIndicators();
|
||||
}
|
||||
|
||||
async function _runSamSelection(e) {
|
||||
const layer = activeLayer();
|
||||
if (!layer || layer.locked) {
|
||||
if (uiModule) uiModule.showToast('Select an unlocked layer');
|
||||
return;
|
||||
}
|
||||
const coords = _canvasCoords(e, state.mainCanvas);
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
const lx = Math.floor(coords.x - off.x);
|
||||
const ly = Math.floor(coords.y - off.y);
|
||||
if (lx < 0 || ly < 0 || lx >= layer.canvas.width || ly >= layer.canvas.height) return;
|
||||
|
||||
let mode = state.wandMode || 'replace';
|
||||
if (e.shiftKey) mode = 'add';
|
||||
else if (e.altKey) mode = 'subtract';
|
||||
|
||||
_cancelSamQuery(false);
|
||||
const controller = new AbortController();
|
||||
_samAbortController = controller;
|
||||
const cleanup = _showWandLoading();
|
||||
try {
|
||||
await _requestAndApplySamMask(layer, {
|
||||
points: [{ x: lx, y: ly, label: 1 }],
|
||||
}, mode, { x: coords.x, y: coords.y }, { signal: controller.signal });
|
||||
} catch (err) {
|
||||
if (err?.name !== 'AbortError' && uiModule) {
|
||||
uiModule.showToast(err.message || String(err), 7000);
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
if (_samAbortController === controller) _samAbortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function _runSamTextSelection() {
|
||||
const layer = activeLayer();
|
||||
if (!layer || layer.locked) {
|
||||
if (uiModule) uiModule.showToast('Select an unlocked layer');
|
||||
return;
|
||||
}
|
||||
const input = document.getElementById('ge-sam-query');
|
||||
const text = (input?.value || '').trim();
|
||||
if (!text) {
|
||||
if (uiModule) uiModule.showToast('Type an object to find');
|
||||
input?.focus();
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById('ge-sam-find');
|
||||
const old = btn?.innerHTML;
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>Finding…';
|
||||
}
|
||||
_cancelSamQuery(false);
|
||||
const controller = new AbortController();
|
||||
_samAbortController = controller;
|
||||
const cleanup = _showWandLoading();
|
||||
try {
|
||||
await _requestAndApplySamMask(layer, { text }, state.wandMode || 'replace', null, { signal: controller.signal });
|
||||
} catch (err) {
|
||||
if (err?.name !== 'AbortError' && uiModule) {
|
||||
uiModule.showToast(err.message || String(err), 7000);
|
||||
}
|
||||
} finally {
|
||||
cleanup();
|
||||
if (_samAbortController === controller) _samAbortController = null;
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = old || '<span class="ge-btn-ai-mark" aria-hidden="true">✦</span>Find';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function _requestAndApplySamMask(layer, payload, mode, seedPoint, opts = {}) {
|
||||
const res = await fetch('/api/image/mask', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: opts.signal,
|
||||
body: JSON.stringify({
|
||||
image: layer.canvas.toDataURL('image/png').split(',')[1],
|
||||
...payload,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !data.mask) {
|
||||
throw new Error(data.detail || data.error || `Mask failed (${res.status})`);
|
||||
}
|
||||
if (!data.bbox) {
|
||||
throw new Error(data.grounding ? `Found ${data.grounding.label || 'object'}, but SAM returned an empty mask` : 'SAM returned an empty mask');
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = () => reject(new Error('Failed to decode mask'));
|
||||
img.src = 'data:image/png;base64,' + data.mask;
|
||||
});
|
||||
const mask = document.createElement('canvas');
|
||||
mask.width = layer.canvas.width;
|
||||
mask.height = layer.canvas.height;
|
||||
const mctx = mask.getContext('2d');
|
||||
mctx.drawImage(img, 0, 0, mask.width, mask.height);
|
||||
const maskData = mctx.getImageData(0, 0, mask.width, mask.height);
|
||||
const md = maskData.data;
|
||||
for (let i = 0; i < md.length; i += 4) {
|
||||
const alpha = md[i]; // server mask is white selected / black unselected
|
||||
md[i] = 255;
|
||||
md[i + 1] = 255;
|
||||
md[i + 2] = 255;
|
||||
md[i + 3] = alpha;
|
||||
}
|
||||
mctx.putImageData(maskData, 0, 0);
|
||||
|
||||
_saveState();
|
||||
const compatible = state.wandMask && state.wandLayerId === layer.id &&
|
||||
state.wandMask.width === mask.width && state.wandMask.height === mask.height;
|
||||
if (compatible && mode === 'add') {
|
||||
state.wandMask.getContext('2d').drawImage(mask, 0, 0);
|
||||
} else if (compatible && mode === 'subtract') {
|
||||
const ec = state.wandMask.getContext('2d');
|
||||
ec.save();
|
||||
ec.globalCompositeOperation = 'destination-out';
|
||||
ec.drawImage(mask, 0, 0);
|
||||
ec.restore();
|
||||
} else {
|
||||
state.wandMask = mask;
|
||||
state.wandLayerId = layer.id;
|
||||
}
|
||||
state.wandLastSeed = seedPoint
|
||||
? { x: seedPoint.x, y: seedPoint.y, mode, source: 'sam' }
|
||||
: { x: 0, y: 0, mode, source: 'sam-text' };
|
||||
state.wandMaskVisible = true;
|
||||
composite();
|
||||
_syncToolClearIndicators();
|
||||
if (data.grounding && uiModule) {
|
||||
const pct = Math.round((data.grounding.score || 0) * 100);
|
||||
uiModule.showToast(`Selected ${data.grounding.label || 'object'}${pct ? ` (${pct}%)` : ''}`, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
function _showWandLoading() {
|
||||
const area = state.container?.querySelector('.ge-canvas-area');
|
||||
if (!area) return () => {};
|
||||
@@ -1981,12 +2301,108 @@ function _wandToMask() {
|
||||
state.wandMask = null;
|
||||
state.wandLayerId = null;
|
||||
state.wandLastSeed = null;
|
||||
mask.visible = true;
|
||||
layer.activeMaskId = mask.id;
|
||||
state.maskVisible = true;
|
||||
composite();
|
||||
_renderLayerPanel();
|
||||
if (uiModule) uiModule.showToast('Selection added to mask');
|
||||
}
|
||||
|
||||
// Reveal/hide the small "X" badge on the Lasso and Wand tool buttons
|
||||
function _autoMatchLastInpaintLayer() {
|
||||
const layer = state.layers.find(l => l.id === state.lastInpaintLayerId);
|
||||
const src = layer?.inpaintSource;
|
||||
if (!layer || !src?.base || !src?.mask) {
|
||||
if (uiModule) uiModule.showToast('Run inpaint first');
|
||||
return;
|
||||
}
|
||||
const w = state.imgWidth;
|
||||
const h = state.imgHeight;
|
||||
let baseData, resultData, maskData;
|
||||
try {
|
||||
baseData = src.base.getContext('2d').getImageData(0, 0, w, h).data;
|
||||
resultData = layer.canvas.getContext('2d').getImageData(0, 0, w, h).data;
|
||||
maskData = src.mask.getContext('2d').getImageData(0, 0, w, h).data;
|
||||
} catch (err) {
|
||||
if (uiModule) uiModule.showToast('Auto match failed: cannot read pixels');
|
||||
return;
|
||||
}
|
||||
|
||||
const inside = { r: 0, g: 0, b: 0, y: 0, n: 0 };
|
||||
const outside = { r: 0, g: 0, b: 0, y: 0, n: 0 };
|
||||
const step = Math.max(1, Math.round(Math.max(w, h) / 900));
|
||||
const radius = Math.max(2, Math.round(Math.min(w, h) * 0.006));
|
||||
const sample = (bucket, data, idx) => {
|
||||
const r = data[idx], g = data[idx + 1], b = data[idx + 2];
|
||||
bucket.r += r; bucket.g += g; bucket.b += b;
|
||||
bucket.y += 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
bucket.n++;
|
||||
};
|
||||
const isMasked = (x, y) => {
|
||||
if (x < 0 || y < 0 || x >= w || y >= h) return false;
|
||||
return maskData[(y * w + x) * 4 + 3] > 24;
|
||||
};
|
||||
for (let y = radius; y < h - radius; y += step) {
|
||||
for (let x = radius; x < w - radius; x += step) {
|
||||
const idx = (y * w + x) * 4;
|
||||
const m = maskData[idx + 3] > 24;
|
||||
let touchesOther = false;
|
||||
for (let dy = -radius; dy <= radius && !touchesOther; dy += radius) {
|
||||
for (let dx = -radius; dx <= radius; dx += radius) {
|
||||
if (!dx && !dy) continue;
|
||||
if (isMasked(x + dx, y + dy) !== m) {
|
||||
touchesOther = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!touchesOther) continue;
|
||||
if (m && resultData[idx + 3] > 24) sample(inside, resultData, idx);
|
||||
else if (!m && baseData[idx + 3] > 24) sample(outside, baseData, idx);
|
||||
}
|
||||
}
|
||||
if (inside.n < 20 || outside.n < 20) {
|
||||
if (uiModule) uiModule.showToast('Auto match needs a larger mask edge');
|
||||
return;
|
||||
}
|
||||
for (const b of [inside, outside]) {
|
||||
b.r /= b.n; b.g /= b.n; b.b /= b.n; b.y /= b.n;
|
||||
}
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const dr = clamp((outside.r - inside.r) * 0.55, -55, 55);
|
||||
const dg = clamp((outside.g - inside.g) * 0.55, -55, 55);
|
||||
const db = clamp((outside.b - inside.b) * 0.55, -55, 55);
|
||||
const dy = clamp((outside.y - inside.y) * 0.35, -35, 35);
|
||||
if (!layer.adjLayers) layer.adjLayers = [];
|
||||
layer.adjLayers = layer.adjLayers.filter(a => a.id !== 'auto-match-color' && a.id !== 'auto-match-light');
|
||||
layer.adjLayers.push({
|
||||
id: 'auto-match-light',
|
||||
type: 'brightness-contrast',
|
||||
params: {
|
||||
brightness: clamp(1 + (dy / 255), 0.75, 1.25),
|
||||
contrast: 1,
|
||||
},
|
||||
opacity: 0.7,
|
||||
visible: true,
|
||||
});
|
||||
layer.adjLayers.push({
|
||||
id: 'auto-match-color',
|
||||
type: 'color-balance',
|
||||
params: {
|
||||
shadows: { r: dr * 0.45, g: dg * 0.45, b: db * 0.45 },
|
||||
midtones: { r: dr, g: dg, b: db },
|
||||
highlights: { r: dr * 0.35, g: dg * 0.35, b: db * 0.35 },
|
||||
},
|
||||
opacity: 0.75,
|
||||
visible: true,
|
||||
});
|
||||
_saveState('Auto match inpaint color');
|
||||
composite();
|
||||
_renderLayerPanel();
|
||||
if (uiModule) uiModule.showToast('Auto matched color');
|
||||
}
|
||||
|
||||
// Reveal/hide the small "X" badge on the Lasso, Wand, and SAM tool buttons
|
||||
// based on whether each tool currently holds a selection. Called from
|
||||
// anywhere selection state mutates (wand click, lasso close, undo, etc.).
|
||||
function _syncToolClearIndicators() {
|
||||
@@ -2026,9 +2442,11 @@ function _syncToolClearIndicators() {
|
||||
if (!state.container) return;
|
||||
const lassoBtn = state.container.querySelector('.ge-tool-btn[data-tool="lasso"]');
|
||||
const wandBtn = state.container.querySelector('.ge-tool-btn[data-tool="wand"]');
|
||||
const samBtn = state.container.querySelector('.ge-tool-btn[data-tool="sam"]');
|
||||
const inpaintBtn = state.container.querySelector('.ge-tool-btn[data-tool="inpaint"]');
|
||||
if (lassoBtn) lassoBtn.classList.toggle('has-selection', state.lassoPoints.length >= 3 && !state.lassoActive);
|
||||
if (wandBtn) wandBtn.classList.toggle('has-selection', !!state.wandMask);
|
||||
if (samBtn) samBtn.classList.toggle('has-selection', !!state.wandMask);
|
||||
// Inpaint no longer carries a clear-X badge; masks live as sub-layers
|
||||
// in the layer panel and are deleted from there.
|
||||
if (inpaintBtn) inpaintBtn.classList.remove('has-selection');
|
||||
@@ -2470,6 +2888,7 @@ function _wireInpaintPopoverWindow() {
|
||||
// ── Build DOM ──
|
||||
|
||||
function _buildEditor(container) {
|
||||
document.querySelectorAll('body > #ge-save-menu').forEach(el => el.remove());
|
||||
container.innerHTML = '';
|
||||
container.className = 'gallery-editor';
|
||||
|
||||
@@ -2484,6 +2903,9 @@ function _buildEditor(container) {
|
||||
composite();
|
||||
} else if (which === 'wand') {
|
||||
_wandClear();
|
||||
} else if (which === 'sam') {
|
||||
_openSamPrompt();
|
||||
return;
|
||||
}
|
||||
_syncToolClearIndicators();
|
||||
},
|
||||
@@ -2505,7 +2927,7 @@ function _buildEditor(container) {
|
||||
// panel auto-minimises the layers sheet so the controls aren't
|
||||
// covered. Swiping the layers handle back up restores it.
|
||||
const isMobile = window.innerWidth <= 820;
|
||||
const hasToolControls = ['brush', 'eraser', 'clone', 'inpaint'].includes(toolId);
|
||||
const hasToolControls = ['brush', 'eraser', 'clone', 'inpaint', 'sam'].includes(toolId);
|
||||
const controlsVisible = controls && !controls.classList.contains('dismissed');
|
||||
if (isMobile && hasToolControls && controlsVisible) {
|
||||
const rp = document.querySelector('.ge-right-panel');
|
||||
@@ -2540,6 +2962,8 @@ function _buildEditor(container) {
|
||||
if (lassoSection) lassoSection.style.display = state.tool === 'lasso' ? '' : 'none';
|
||||
const wandSection = document.getElementById('ge-wand-section');
|
||||
if (wandSection) wandSection.style.display = state.tool === 'wand' ? '' : 'none';
|
||||
const samSection = document.getElementById('ge-sam-section');
|
||||
if (samSection) samSection.style.display = state.tool === 'sam' ? '' : 'none';
|
||||
const inpaintSection = document.getElementById('ge-inpaint-section');
|
||||
if (inpaintSection) {
|
||||
if (state.tool === 'inpaint') {
|
||||
@@ -2561,6 +2985,13 @@ function _buildEditor(container) {
|
||||
// Generate cleared it, but on re-entry the user expects to see
|
||||
// their mask again.
|
||||
if (state.tool === 'inpaint') {
|
||||
// If the user just made a SAM/Wand selection and then moves to
|
||||
// Inpaint, do the obvious thing: bake that selection into the
|
||||
// inpaint mask. Otherwise Generate says "draw the area first"
|
||||
// even though a red selection is visible on screen.
|
||||
if (state.wandMask && state.wandLayerId) {
|
||||
_wandToMask();
|
||||
}
|
||||
// First inpaint entry per session: bump the brush size to the
|
||||
// mask-friendly default (other tools keep their own size).
|
||||
if (!state.inpaintBrushInitialised) {
|
||||
@@ -2923,6 +3354,7 @@ function _buildEditor(container) {
|
||||
wireInpaintControls({
|
||||
composite,
|
||||
applyInpaintFeather: _applyInpaintFeather,
|
||||
autoMatchInpaint: _autoMatchLastInpaintLayer,
|
||||
syncToolClearIndicators: () => _syncToolClearIndicators(),
|
||||
attachColorPicker,
|
||||
uiModule,
|
||||
@@ -3008,6 +3440,27 @@ function _buildEditor(container) {
|
||||
applyImageTool: _applyImageTool,
|
||||
uiModule,
|
||||
});
|
||||
document.getElementById('ge-sam-find')?.addEventListener('click', () => _runSamTextSelection());
|
||||
document.getElementById('ge-sam-query')?.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
_runSamTextSelection();
|
||||
}
|
||||
});
|
||||
document.getElementById('ge-sam-clear')?.addEventListener('click', () => _wandClear());
|
||||
document.getElementById('ge-sam-mask')?.addEventListener('click', () => _wandToMask());
|
||||
document.getElementById('ge-sam-vis')?.addEventListener('click', () => {
|
||||
state.wandMaskVisible = !state.wandMaskVisible;
|
||||
const btn = document.getElementById('ge-sam-vis');
|
||||
if (btn) {
|
||||
btn.innerHTML = state.wandMaskVisible
|
||||
? '<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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>'
|
||||
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 20C5 20 1 12 1 12a20.29 20.29 0 0 1 5.06-5.94"/><path d="M9.9 4.24A10.45 10.45 0 0 1 12 4c7 0 11 8 11 8a20.65 20.65 0 0 1-2.16 3.19"/><path d="M14.12 14.12A3 3 0 0 1 9.88 9.88"/><path d="M1 1l22 22"/></svg>';
|
||||
btn.title = state.wandMaskVisible ? 'Hide selection overlay' : 'Show selection overlay';
|
||||
btn.classList.toggle('visible', state.wandMaskVisible);
|
||||
}
|
||||
composite();
|
||||
});
|
||||
_wireAiCommandBox();
|
||||
|
||||
// Merge / Flatten buttons (layer-panel footer) — full
|
||||
@@ -3017,6 +3470,7 @@ function _buildEditor(container) {
|
||||
createLayer,
|
||||
renderLayerPanel: () => _renderLayerPanel(),
|
||||
composite,
|
||||
renderLayer: (layer) => _renderLayerWithAdjLayers(layer),
|
||||
uiModule,
|
||||
});
|
||||
|
||||
@@ -3107,6 +3561,7 @@ const _layerPanelRenderer = createLayerPanelRenderer({
|
||||
openFxPopup: (layer, anchor) => _openFxPopup(layer, anchor),
|
||||
editAdjLayer: (layer, adj, anchor) => _editAdjLayer(layer, adj, anchor),
|
||||
createLayer,
|
||||
renderLayer: (layer) => _renderLayerWithAdjLayers(layer),
|
||||
lassoToMask: () => _lassoToMask(),
|
||||
wandToMask: () => _wandToMask(),
|
||||
getActiveMaskLayer: () => _getActiveMaskLayer(),
|
||||
@@ -3137,7 +3592,7 @@ function flatten() {
|
||||
if (!layer.visible) continue;
|
||||
ctx.globalAlpha = layer.opacity;
|
||||
const off = state.layerOffsets.get(layer.id) || { x: 0, y: 0 };
|
||||
ctx.drawImage(layer.canvas, off.x, off.y);
|
||||
ctx.drawImage(_renderLayerWithAdjLayers(layer), off.x, off.y);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
return out;
|
||||
@@ -3881,6 +4336,9 @@ export function closeEditor() {
|
||||
el.remove();
|
||||
});
|
||||
} catch {}
|
||||
try {
|
||||
document.querySelectorAll('body > #ge-save-menu').forEach(el => el.remove());
|
||||
} catch {}
|
||||
// Belt-and-suspenders: scrub any minimized-dock chip + modalManager
|
||||
// entry whose id matches our ephemeral popups (in case the DOM node
|
||||
// was already removed when the user dragged the chip to trash).
|
||||
|
||||
+14
-1
@@ -3,22 +3,35 @@
|
||||
|
||||
import Storage from './storage.js';
|
||||
|
||||
function markComposerUserEdited() {
|
||||
const msgInput = document.getElementById('message');
|
||||
if (!msgInput || msgInput.dataset.startupPreserveBound === '1') return;
|
||||
msgInput.dataset.startupPreserveBound = '1';
|
||||
msgInput.addEventListener('input', () => {
|
||||
window.__odysseusComposerUserEdited = !!msgInput.value;
|
||||
});
|
||||
}
|
||||
|
||||
function clearFreshComposerRestore() {
|
||||
const msgInput = document.getElementById('message');
|
||||
if (!msgInput) return;
|
||||
markComposerUserEdited();
|
||||
const hash = window.location.hash || '';
|
||||
const isEntityHash = /^#(?:document|note|image|email|event|task|skill|research)-/.test(hash)
|
||||
|| /^#open=notes¬e=/.test(hash);
|
||||
const hasSessionTarget = !!((hash && !isEntityHash) || Storage.get('lastSessionId'));
|
||||
const hasSessionTarget = !!(hash && !isEntityHash);
|
||||
if (hasSessionTarget) return;
|
||||
if (window.__odysseusComposerUserEdited || document.activeElement === msgInput) return;
|
||||
if (msgInput.value) {
|
||||
msgInput.value = '';
|
||||
msgInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
markComposerUserEdited();
|
||||
clearFreshComposerRestore();
|
||||
window.addEventListener('pageshow', clearFreshComposerRestore);
|
||||
document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: true });
|
||||
|
||||
// SECURITY: defense-in-depth state wipe on user switch. If the authenticated
|
||||
// user is different from the one whose state is cached in this browser,
|
||||
|
||||
@@ -15,6 +15,7 @@ let activeCategory = 'all';
|
||||
let sortOrder = 'newest';
|
||||
let selectMode = false;
|
||||
let selectedIds = new Set();
|
||||
let memoriesLoading = false;
|
||||
|
||||
|
||||
const MEMORY_CATEGORIES = ['fact', 'identity', 'preference', 'contact', 'project', 'goal', 'task'];
|
||||
@@ -370,12 +371,16 @@ async function syncPrefToggle(elementId, prefKey, onMsg, offMsg, dimBelow = true
|
||||
|
||||
export async function loadMemories() {
|
||||
_ensureNewMemoryCategorySelect();
|
||||
memoriesLoading = true;
|
||||
renderMemoryList();
|
||||
updateMemoryCount();
|
||||
try {
|
||||
const response = await fetch(`${window.location.origin}/api/memory`);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Memory fetch failed with status:', response.status);
|
||||
memories = [];
|
||||
memoriesLoading = false;
|
||||
buildCategoryChips();
|
||||
renderMemoryList();
|
||||
updateMemoryCount();
|
||||
@@ -393,12 +398,14 @@ export async function loadMemories() {
|
||||
memories = [];
|
||||
}
|
||||
|
||||
memoriesLoading = false;
|
||||
buildCategoryChips();
|
||||
renderMemoryList();
|
||||
updateMemoryCount();
|
||||
} catch (error) {
|
||||
console.error('Failed to load memories:', error);
|
||||
memories = [];
|
||||
memoriesLoading = false;
|
||||
buildCategoryChips();
|
||||
renderMemoryList();
|
||||
updateMemoryCount();
|
||||
@@ -689,6 +696,12 @@ export function renderMemoryList() {
|
||||
const selectBtn = document.getElementById('memory-select-btn');
|
||||
if (selectBtn) selectBtn.disabled = true;
|
||||
if (selectMode) exitSelectMode();
|
||||
if (memoriesLoading) {
|
||||
const row = spinnerModule.createLoadingRow('Loading memories...', 14);
|
||||
row.classList.add('memory-empty');
|
||||
memoryList.replaceChildren(row);
|
||||
return;
|
||||
}
|
||||
const searchTerm = document.getElementById('memory-search')?.value?.trim() || '';
|
||||
const _smiley = '<span style="vertical-align:-3px;margin-left:6px;">' + uiModule.emptyStateIcon('smiley') + '</span>';
|
||||
if (searchTerm || activeCategory !== 'all') {
|
||||
@@ -1065,6 +1078,11 @@ export function updateMemoryCount() {
|
||||
const h2Count = document.getElementById('memory-count-h2');
|
||||
const tabCount = document.getElementById('memory-count'); // optional (may be absent)
|
||||
if (!h2Count && !tabCount) return;
|
||||
if (memoriesLoading) {
|
||||
if (h2Count) h2Count.textContent = 'loading...';
|
||||
if (tabCount) tabCount.textContent = '...';
|
||||
return;
|
||||
}
|
||||
|
||||
const searchInput = document.getElementById('memory-search');
|
||||
const searchTerm = searchInput ? searchInput.value.toLowerCase().trim() : '';
|
||||
|
||||
@@ -143,7 +143,7 @@ const _LABELS = {
|
||||
'custom-preset-modal': { label: 'Prompt', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 2 4 4"/><path d="m17 7 3-3"/><path d="M19 9 8.7 19.3c-1 1-2.5 1-3.4 0l-.6-.6c-1-1-1-2.5 0-3.4L15 5"/><path d="m9 11 4 4"/><path d="m5 19-3 3"/><path d="m14 4 6 6"/></svg>' },
|
||||
'research-overlay': { label: 'Research', icon: 'M3 11a8 8 0 1 0 16 0a8 8 0 1 0-16 0M21 21l-4.35-4.35M11 8L11 14M8 11L14 11' },
|
||||
'theme-modal': { label: 'Theme', icon: 'M12 2a10 10 0 1 0 10 10c0-1-1-2-2-2h-2a2 2 0 0 1 0-4h1a2 2 0 0 0 0-4 10 10 0 0 0-7-2zM7.5 12a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM12 7.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM16.5 12a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z' },
|
||||
'compare-model-overlay': { label: 'Compare', icon: 'M8 3v18M16 3v18M3 8h5M16 16h5' },
|
||||
'compare-model-overlay': { label: 'Compare', icon: 'M4.5 4h5A1.5 1.5 0 0 1 11 5.5v13A1.5 1.5 0 0 1 9.5 20h-5A1.5 1.5 0 0 1 3 18.5v-13A1.5 1.5 0 0 1 4.5 4ZM15.5 4h5A1.5 1.5 0 0 1 22 5.5v13a1.5 1.5 0 0 1-1.5 1.5h-5a1.5 1.5 0 0 1-1.5-1.5v-13A1.5 1.5 0 0 1 15.5 4ZM10 8h4M10 16h4' },
|
||||
'settings-modal': { label: 'Settings', icon: 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.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-4 0v-.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-2.83l.06-.06A1.65 1.65 0 0 0 4.6 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.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 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.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 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9c.4.4.62.94.6 1.51V11a2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z' },
|
||||
'ge-shortcuts-modal':{ label: 'Shortcuts', icon: 'M2 6h20v12H2zM6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10' },
|
||||
// Virtual id — the doc editor pane isn't a modal, but it minimizes to a
|
||||
|
||||
+205
-44
@@ -5,6 +5,7 @@ import { providerLogo } from './providers.js';
|
||||
import uiModule from './ui.js';
|
||||
import settingsModule from './settings.js';
|
||||
import { sortModelObjects } from './modelSort.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
|
||||
@@ -51,6 +52,11 @@ function _toggleFavorite(mid) {
|
||||
return i < 0; // true when now favorited
|
||||
}
|
||||
|
||||
function _pickerModelKey(m) {
|
||||
if (!m) return '';
|
||||
return `${m.endpointId || m.url || m.epName || 'model'}::${m.mid || ''}`;
|
||||
}
|
||||
|
||||
// ── Shared keyboard nav for model pickers ──
|
||||
function _handlePickerKeydown(e, listEl, itemSelector, closeFn) {
|
||||
if (e.key === 'Escape') { closeFn(); return; }
|
||||
@@ -78,6 +84,7 @@ function _handlePickerKeydown(e, listEl, itemSelector, closeFn) {
|
||||
let _deps = null;
|
||||
let _autoSelectingDefault = false;
|
||||
let _defaultChatPickInFlight = false;
|
||||
let _defaultPendingSeq = 0;
|
||||
|
||||
function _modelExists(modelId, url) {
|
||||
if (!modelId || !window.modelsModule || !window.modelsModule.getCachedItems) return false;
|
||||
@@ -121,17 +128,29 @@ async function _ensureDefaultPendingChat() {
|
||||
if (!_deps || _defaultChatPickInFlight) return;
|
||||
if (_deps.getCurrentSessionId && _deps.getCurrentSessionId()) return;
|
||||
const pending = _deps.getPendingChat && _deps.getPendingChat();
|
||||
if (pending && pending.modelId && pending.source === 'manual') return;
|
||||
if (pending && pending.modelId) return;
|
||||
_defaultChatPickInFlight = true;
|
||||
const seq = ++_defaultPendingSeq;
|
||||
try {
|
||||
await _ensureModelCacheForFallback();
|
||||
let dc = null;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/default-chat`, { credentials: 'same-origin' });
|
||||
if (res.ok) dc = await res.json();
|
||||
dc = window.__odysseusDefaultChat || null;
|
||||
} catch (_) {}
|
||||
if (dc && dc.endpoint_url && dc.model && _modelExists(dc.model, dc.endpoint_url)) {
|
||||
const pendingUrl = String((pending && pending.url) || '').replace(/\/+$/, '');
|
||||
if (!dc || !dc.endpoint_url || !dc.model) {
|
||||
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) {
|
||||
if (seq !== _defaultPendingSeq) return;
|
||||
const latest = _deps.getPendingChat && _deps.getPendingChat();
|
||||
if (latest && latest.modelId && latest.source !== 'default' && latest.source !== 'fallback') return;
|
||||
try {
|
||||
window.__odysseusDefaultChat = dc;
|
||||
localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(dc));
|
||||
} catch (_) {}
|
||||
const pendingUrl = String((latest && latest.url) || '').replace(/\/+$/, '');
|
||||
const defaultUrl = String(dc.endpoint_url || '').replace(/\/+$/, '');
|
||||
_deps.setPendingChat({
|
||||
url: dc.endpoint_url,
|
||||
@@ -139,17 +158,20 @@ async function _ensureDefaultPendingChat() {
|
||||
endpointId: dc.endpoint_id || '',
|
||||
source: 'default',
|
||||
});
|
||||
try { window.__odysseusDefaultChat = dc; } catch (_) {}
|
||||
if (!pending || pending.modelId !== dc.model || pendingUrl !== defaultUrl || pending.source !== 'default') {
|
||||
if (!latest || latest.modelId !== dc.model || pendingUrl !== defaultUrl || latest.source !== 'default') {
|
||||
updateModelPicker();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pending && pending.modelId) return;
|
||||
await _ensureModelCacheForFallback();
|
||||
// No configured default, or the configured default is gone/offline:
|
||||
// preserve the convenience fallback and keep the picker usable.
|
||||
const fallback = _firstAvailableModel();
|
||||
if (fallback) {
|
||||
if (seq !== _defaultPendingSeq) return;
|
||||
const latest = _deps.getPendingChat && _deps.getPendingChat();
|
||||
if (latest && latest.modelId && latest.source !== 'default' && latest.source !== 'fallback') return;
|
||||
_deps.setPendingChat({ ...fallback, source: 'fallback' });
|
||||
updateModelPicker();
|
||||
}
|
||||
@@ -181,6 +203,8 @@ function _initModelPickerDropdown() {
|
||||
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;
|
||||
if (wrap.dataset.modelPickerBound === '1') return;
|
||||
wrap.dataset.modelPickerBound = '1';
|
||||
|
||||
function _close() {
|
||||
if (menu.classList.contains('hidden')) return;
|
||||
@@ -227,10 +251,13 @@ function _initModelPickerDropdown() {
|
||||
|
||||
// Local endpoint health — only probed for LOCAL endpoints, since
|
||||
// cloud APIs are essentially always up. Cached briefly on the
|
||||
// server side too (8s TTL). Picker opens trigger a refresh.
|
||||
// server side too (8s TTL). Picker opens do not probe; the refresh button
|
||||
// is the explicit network/probe action.
|
||||
let _localProbe = {}; // {endpoint_id: {alive, latency_ms, error}}
|
||||
let _localProbeFetchedAt = 0;
|
||||
const _LOCAL_PROBE_TTL_MS = 5000;
|
||||
let _pickerLoading = false;
|
||||
let _pickerLoadSeq = 0;
|
||||
|
||||
async function _refreshLocalProbe() {
|
||||
try {
|
||||
@@ -263,18 +290,26 @@ function _initModelPickerDropdown() {
|
||||
// Mark local endpoints whose live probe failed.
|
||||
const probeResult = item.endpoint_id ? _localProbe[item.endpoint_id] : null;
|
||||
const isLocalDead = !!(probeResult && probeResult.alive === false);
|
||||
const isApiEndpoint = item.category && item.category !== 'local';
|
||||
allModels.forEach((mid, i) => {
|
||||
// Deduplicate by model ID — prefer ONLINE endpoint entries over
|
||||
// offline duplicates so the user gets a working endpoint first
|
||||
// when the same model is exposed by both.
|
||||
if (seen.has(mid)) return;
|
||||
seen.add(mid);
|
||||
// Local/self-hosted servers often expose the same model through several
|
||||
// stale endpoints, so keep deduping those by model id. Cloud/API
|
||||
// endpoints are user-selected provider routes; the same model id can be
|
||||
// intentionally enabled on OpenRouter and OpenAI, so key those by
|
||||
// endpoint too or the chat picker silently drops one.
|
||||
const seenKey = isApiEndpoint
|
||||
? `${item.endpoint_id || item.url || item.endpoint_name || 'api'}::${mid}`
|
||||
: mid;
|
||||
if (seen.has(seenKey)) return;
|
||||
seen.add(seenKey);
|
||||
result.push({
|
||||
key: seenKey,
|
||||
mid,
|
||||
display: (allDisplay[i] || mid).split('/').pop(),
|
||||
url: item.url,
|
||||
endpointId: item.endpoint_id,
|
||||
epName: item.endpoint_name || '',
|
||||
category: item.category || '',
|
||||
providerText: [
|
||||
item.endpoint_name || '',
|
||||
item.category || '',
|
||||
@@ -292,6 +327,48 @@ function _initModelPickerDropdown() {
|
||||
return sortModelObjects(result);
|
||||
}
|
||||
|
||||
function _hasModelCache() {
|
||||
try {
|
||||
return !!(window.modelsModule && window.modelsModule.getCachedItems && (window.modelsModule.getCachedItems() || []).length);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderLoading(text = 'Loading models…') {
|
||||
listEl.innerHTML = '';
|
||||
listEl.classList.remove('is-empty');
|
||||
listEl.classList.add('is-loading');
|
||||
menu.classList.remove('no-models');
|
||||
if (search) search.placeholder = text;
|
||||
let row = null;
|
||||
try {
|
||||
row = spinnerModule.createLoadingRow(text, 15);
|
||||
} catch (_) {
|
||||
row = document.createElement('div');
|
||||
row.className = 'model-switch-empty';
|
||||
row.textContent = text;
|
||||
}
|
||||
row.classList.add('model-picker-loading-row');
|
||||
listEl.appendChild(row);
|
||||
}
|
||||
|
||||
async function _refreshPickerModels({ force = false, showLoading = false } = {}) {
|
||||
if (!window.modelsModule || typeof window.modelsModule.refreshModels !== 'function') return;
|
||||
const seq = ++_pickerLoadSeq;
|
||||
_pickerLoading = true;
|
||||
if (showLoading) _renderLoading(force ? 'Refreshing models…' : 'Loading models…');
|
||||
try {
|
||||
await window.modelsModule.refreshModels(force);
|
||||
await _refreshLocalProbe();
|
||||
} finally {
|
||||
if (seq === _pickerLoadSeq) {
|
||||
_pickerLoading = false;
|
||||
listEl.classList.remove('is-loading');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Provider display names and grouping ──
|
||||
const _PROVIDER_NAMES = {
|
||||
'01-ai': 'Yi', 'abacusai': 'Abacus AI', 'adept': 'Adept',
|
||||
@@ -332,6 +409,16 @@ function _initModelPickerDropdown() {
|
||||
function _providerDisplayName(slug) {
|
||||
return _PROVIDER_NAMES[slug] || slug.charAt(0).toUpperCase() + slug.slice(1).replace(/-/g, ' ');
|
||||
}
|
||||
function _providerGroupKey(m) {
|
||||
if (m && m.category && m.category !== 'local' && m.epName) {
|
||||
return `~endpoint:${m.epName}`;
|
||||
}
|
||||
return _providerSlug((m && m.mid) || '');
|
||||
}
|
||||
function _providerGroupName(key) {
|
||||
if (String(key || '').startsWith('~endpoint:')) return String(key).slice('~endpoint:'.length);
|
||||
return _providerDisplayName(key);
|
||||
}
|
||||
function _providerSlug(mid) {
|
||||
const slash = mid.indexOf('/');
|
||||
let slug = slash > 0 ? mid.substring(0, slash) : 'other';
|
||||
@@ -342,6 +429,7 @@ function _initModelPickerDropdown() {
|
||||
|
||||
function _populate(filter) {
|
||||
listEl.innerHTML = '';
|
||||
listEl.classList.remove('is-loading');
|
||||
const all = _getAllModels();
|
||||
const q = (filter || '').trim().toLowerCase();
|
||||
const hasAnyModel = all.length > 0;
|
||||
@@ -359,7 +447,12 @@ function _initModelPickerDropdown() {
|
||||
// Unique lookup so Recent/Favorites (stored as bare model IDs) can be
|
||||
// resolved back to full model objects; drops anything no longer offered.
|
||||
const byId = new Map();
|
||||
all.forEach(m => { if (!byId.has(m.mid)) byId.set(m.mid, m); });
|
||||
const byKey = new Map();
|
||||
all.forEach(m => {
|
||||
const key = _pickerModelKey(m);
|
||||
if (key && !byKey.has(key)) byKey.set(key, m);
|
||||
if (!byId.has(m.mid)) byId.set(m.mid, m);
|
||||
});
|
||||
|
||||
const favs = _loadFavorites();
|
||||
|
||||
@@ -470,44 +563,44 @@ function _initModelPickerDropdown() {
|
||||
// list fits below as "All models" and a separate Recent
|
||||
// section just duplicates rows.
|
||||
const shown = new Set();
|
||||
const favModels = favs.map(id => byId.get(id)).filter(Boolean);
|
||||
const favModels = favs.map(id => byKey.get(id) || byId.get(id)).filter(Boolean);
|
||||
if (favModels.length) {
|
||||
_addSection('Favorites');
|
||||
favModels.forEach(m => { shown.add(m.mid); _addRow(m); });
|
||||
favModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m); });
|
||||
}
|
||||
// Recent: only render when the catalog is big enough that surfacing
|
||||
// a recency shortlist is actually useful, AND only models that
|
||||
// aren't already in Favorites (dedupe).
|
||||
if (all.length > BROWSE_ALL_LIMIT) {
|
||||
const recentModels = _loadRecent()
|
||||
.map(id => byId.get(id))
|
||||
.map(id => byKey.get(id) || byId.get(id))
|
||||
.filter(Boolean)
|
||||
.filter(m => !shown.has(m.mid))
|
||||
.filter(m => !shown.has(_pickerModelKey(m)))
|
||||
.slice(0, RECENT_MAX);
|
||||
if (recentModels.length) {
|
||||
_addSection('Recent');
|
||||
recentModels.forEach(m => { shown.add(m.mid); _addRow(m); });
|
||||
recentModels.forEach(m => { shown.add(_pickerModelKey(m)); _addRow(m); });
|
||||
}
|
||||
}
|
||||
|
||||
// Small catalogs: still list everything so users aren't forced to search.
|
||||
if (all.length <= BROWSE_ALL_LIMIT) {
|
||||
const rest = all.filter(m => !shown.has(m.mid));
|
||||
const rest = all.filter(m => !shown.has(_pickerModelKey(m)));
|
||||
if (rest.length) {
|
||||
if (shown.size) _addSection('All models');
|
||||
rest.forEach(_addRow);
|
||||
}
|
||||
} else {
|
||||
// Large catalog: show provider groups with collapsible sections.
|
||||
const rest = all.filter(m => !shown.has(m.mid));
|
||||
const rest = all.filter(m => !shown.has(_pickerModelKey(m)));
|
||||
const groups = new Map();
|
||||
rest.forEach(m => {
|
||||
const slug = _providerSlug(m.mid);
|
||||
const slug = _providerGroupKey(m);
|
||||
if (!groups.has(slug)) groups.set(slug, []);
|
||||
groups.get(slug).push(m);
|
||||
});
|
||||
const sorted = [...groups.keys()].sort((a, b) =>
|
||||
_providerDisplayName(a).localeCompare(_providerDisplayName(b)));
|
||||
_providerGroupName(a).localeCompare(_providerGroupName(b)));
|
||||
|
||||
sorted.forEach(provider => {
|
||||
const models = groups.get(provider);
|
||||
@@ -516,7 +609,7 @@ function _initModelPickerDropdown() {
|
||||
header.className = 'mp-provider-header';
|
||||
header.innerHTML =
|
||||
`<svg class="mp-provider-chevron${isCollapsed ? ' collapsed' : ''}" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>`
|
||||
+ `<span class="mp-provider-name">${_providerDisplayName(provider)}</span>`
|
||||
+ `<span class="mp-provider-name">${_providerGroupName(provider)}</span>`
|
||||
+ `<span class="mp-provider-count">${models.length}</span>`;
|
||||
header.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -548,13 +641,32 @@ function _initModelPickerDropdown() {
|
||||
}
|
||||
}
|
||||
|
||||
async function _pick(m) {
|
||||
async function _pick(m) {
|
||||
_defaultPendingSeq++;
|
||||
try {
|
||||
window.__odysseusLastPickedRoute = {
|
||||
model: m.mid || '',
|
||||
endpoint_url: m.url || '',
|
||||
endpoint_id: m.endpointId || '',
|
||||
display: m.display || m.mid || '',
|
||||
picked_at: Date.now(),
|
||||
};
|
||||
} catch (_) {}
|
||||
let switchDone = null;
|
||||
const switchPromise = new Promise(resolve => { switchDone = resolve; });
|
||||
try { window.__odysseusModelSwitchPromise = switchPromise; } catch (_) {}
|
||||
const finishSwitch = () => {
|
||||
try {
|
||||
if (switchDone) switchDone();
|
||||
if (window.__odysseusModelSwitchPromise === switchPromise) delete window.__odysseusModelSwitchPromise;
|
||||
} catch (_) {}
|
||||
};
|
||||
const currentSessionId = _deps.getCurrentSessionId();
|
||||
const _pendingChat = _deps.getPendingChat();
|
||||
|
||||
// Remember this pick so it surfaces under "Recent" next time the picker
|
||||
// opens — the whole point of quick-switch.
|
||||
if (m && m.mid) _pushRecent(m.mid);
|
||||
if (m && m.mid) _pushRecent(_pickerModelKey(m) || m.mid);
|
||||
|
||||
// Broadcast immediately so listeners (e.g. the tour) can advance without
|
||||
// waiting for the async session-create/PATCH that follows.
|
||||
@@ -574,12 +686,23 @@ function _initModelPickerDropdown() {
|
||||
// Header stays as session name — model switch only updates picker
|
||||
updateModelPicker();
|
||||
uiModule.showToast(`Using ${m.display}`);
|
||||
finishSwitch();
|
||||
return;
|
||||
} else if (!currentSessionId) {
|
||||
// No session yet — create one with this model
|
||||
await _deps.createDirectChat(m.url, m.mid, m.endpointId);
|
||||
try {
|
||||
await _deps.createDirectChat(m.url, m.mid, m.endpointId);
|
||||
} catch (e) {
|
||||
uiModule.showError('Failed to start chat: ' + e);
|
||||
finishSwitch();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Existing session with no model — PATCH it
|
||||
const sessions = _deps.getSessions();
|
||||
const s = sessions.find(x => x.id === currentSessionId);
|
||||
if (s) { s.model = m.mid; s.endpoint_url = m.url; s.endpoint_id = m.endpointId || s.endpoint_id || ''; }
|
||||
updateModelPicker();
|
||||
const fd = new FormData();
|
||||
fd.append('model', m.mid);
|
||||
fd.append('endpoint_url', m.url);
|
||||
@@ -588,20 +711,21 @@ function _initModelPickerDropdown() {
|
||||
const res = await fetch(`${API_BASE}/api/session/${currentSessionId}`, { method: 'PATCH', body: fd });
|
||||
if (!res.ok) {
|
||||
uiModule.showError('Failed to set model');
|
||||
finishSwitch();
|
||||
return;
|
||||
}
|
||||
const sessions = _deps.getSessions();
|
||||
const s = sessions.find(x => x.id === currentSessionId);
|
||||
if (s) { s.model = m.mid; s.endpoint_url = m.url; }
|
||||
// Header stays as session name — model info shown in picker only
|
||||
} catch (e) {
|
||||
uiModule.showError('Failed to set model: ' + e);
|
||||
finishSwitch();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Update picker visibility — model is now set
|
||||
updateModelPicker();
|
||||
if (window.refreshChatContextHeader) window.refreshChatContextHeader('model-pick');
|
||||
uiModule.showToast(`Using ${m.display}`);
|
||||
finishSwitch();
|
||||
}
|
||||
|
||||
document.addEventListener('odysseus:auto-select-model', async (e) => {
|
||||
@@ -650,14 +774,27 @@ function _initModelPickerDropdown() {
|
||||
if (match) await _pick(match);
|
||||
});
|
||||
|
||||
btn.addEventListener('pointerdown', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (menu.classList.contains('hidden') || menu.classList.contains('closing')) {
|
||||
// Force-clear any in-progress close animation
|
||||
menu.classList.remove('closing', 'hidden');
|
||||
_populate('');
|
||||
const hasCache = _hasModelCache();
|
||||
if (hasCache) {
|
||||
_populate('');
|
||||
} else {
|
||||
_renderLoading('Loading models…');
|
||||
}
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
window.modelsModule.refreshModels().then(() => {
|
||||
// Force the cheap /api/models cache refresh when the picker opens.
|
||||
// This does not wait on provider probes; the backend returns cached
|
||||
// inventory and starts refresh work separately. Without this, models
|
||||
// enabled in Added Models can be absent from the chatbox picker until
|
||||
// the tab's frontend cache ages out.
|
||||
_refreshPickerModels({ force: hasCache, showLoading: !hasCache }).then(() => {
|
||||
if (!menu.classList.contains('hidden')) _populate(search.value || '');
|
||||
updateModelPicker();
|
||||
}).catch(() => {});
|
||||
@@ -671,7 +808,10 @@ function _initModelPickerDropdown() {
|
||||
}
|
||||
});
|
||||
|
||||
search.addEventListener('input', () => _populate(search.value));
|
||||
search.addEventListener('input', () => {
|
||||
if (_pickerLoading) return;
|
||||
_populate(search.value);
|
||||
});
|
||||
search.addEventListener('click', (e) => e.stopPropagation());
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', async (e) => {
|
||||
@@ -679,10 +819,7 @@ function _initModelPickerDropdown() {
|
||||
refreshBtn.disabled = true;
|
||||
refreshBtn.classList.add('spinning');
|
||||
try {
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
await window.modelsModule.refreshModels(true);
|
||||
}
|
||||
await _refreshLocalProbe();
|
||||
await _refreshPickerModels({ force: true, showLoading: true });
|
||||
if (!menu.classList.contains('hidden')) _populate(search.value || '');
|
||||
updateModelPicker();
|
||||
} catch (_) {
|
||||
@@ -704,7 +841,7 @@ function _initModelPickerDropdown() {
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menu.classList.contains('hidden') && !menu.contains(e.target) && e.target !== btn) {
|
||||
if (!menu.classList.contains('hidden') && !wrap.contains(e.target)) {
|
||||
_close();
|
||||
}
|
||||
});
|
||||
@@ -738,16 +875,33 @@ export function updateModelPicker() {
|
||||
let modelId = null;
|
||||
if (s && s.model) {
|
||||
modelId = s.model;
|
||||
if (!_modelExists(modelId, s.endpoint_url || '')) {
|
||||
modelId = null;
|
||||
}
|
||||
} else if (_pendingChat && _pendingChat.modelId) {
|
||||
modelId = _pendingChat.modelId;
|
||||
if (!_modelExists(modelId, _pendingChat.url || '')) {
|
||||
if (_pendingChat.source === 'fallback' && !_modelExists(modelId, _pendingChat.url || '')) {
|
||||
_deps.setPendingChat(null);
|
||||
modelId = null;
|
||||
}
|
||||
}
|
||||
if (!modelId && !currentSessionId && !_pendingChat && _deps.setPendingChat) {
|
||||
let cachedDefault = null;
|
||||
try {
|
||||
cachedDefault = window.__odysseusDefaultChat || null;
|
||||
} catch (_) {}
|
||||
if (!cachedDefault || !cachedDefault.endpoint_url || !cachedDefault.model) {
|
||||
try {
|
||||
cachedDefault = JSON.parse(localStorage.getItem('odysseus-default-chat-cache') || 'null');
|
||||
} catch (_) {}
|
||||
}
|
||||
if (cachedDefault && cachedDefault.endpoint_url && cachedDefault.model) {
|
||||
modelId = cachedDefault.model;
|
||||
_deps.setPendingChat({
|
||||
url: cachedDefault.endpoint_url,
|
||||
modelId,
|
||||
endpointId: cachedDefault.endpoint_id || '',
|
||||
source: 'default',
|
||||
});
|
||||
}
|
||||
}
|
||||
// SECURITY: deliberately NOT auto-injecting `odysseus-model-favorites[0]`
|
||||
// here. localStorage favorites are per-browser, not per-user, so on a
|
||||
// shared browser the previous account's first favorited model would
|
||||
@@ -757,7 +911,14 @@ export function updateModelPicker() {
|
||||
//
|
||||
// Check if selected model is still available — fall back ONLY for pending chats with no user selection
|
||||
// Never override an existing session's model — the user explicitly chose it
|
||||
if (modelId && !currentSessionId && _pendingChat && window.modelsModule && window.modelsModule.getCachedItems) {
|
||||
if (
|
||||
modelId &&
|
||||
!currentSessionId &&
|
||||
_pendingChat &&
|
||||
_pendingChat.source !== 'manual' &&
|
||||
window.modelsModule &&
|
||||
window.modelsModule.getCachedItems
|
||||
) {
|
||||
const items = window.modelsModule.getCachedItems();
|
||||
const allAvailable = [];
|
||||
items.forEach(item => {
|
||||
|
||||
+17
-33
@@ -164,20 +164,30 @@ function _buildModelRow(mid, url, displayName, endpointId, offline, modelType) {
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function refreshModels(force = false) {
|
||||
export async function refreshModels(force = false, opts = {}) {
|
||||
const box = document.getElementById('models');
|
||||
const cacheOnly = !!(opts && opts.cacheOnly);
|
||||
const hasCache = _cachedItems.length > 0;
|
||||
|
||||
// Skip network fetch if cache is fresh and not forced — still re-render UI
|
||||
// Cache-only is used for cheap picker/settings opens, but it must not turn a
|
||||
// cold page load into an empty model list. If nothing has been fetched in this
|
||||
// tab yet, do one normal load.
|
||||
const now = Date.now();
|
||||
const needsFetch = force || _cachedItems.length === 0 || (now - _lastFetchTime) >= _FETCH_CACHE_TTL;
|
||||
const needsFetch = !(cacheOnly && hasCache) && (force || _cachedItems.length === 0 || (now - _lastFetchTime) >= _FETCH_CACHE_TTL);
|
||||
|
||||
if (box) box.innerHTML = '';
|
||||
const hadRenderedRows = !!(box && box.children && box.children.length);
|
||||
if (box && (!needsFetch || !hadRenderedRows)) box.innerHTML = '';
|
||||
if (needsFetch) {
|
||||
let _loadingSpinner = null;
|
||||
if (box) {
|
||||
_loadingSpinner = spinnerModule.create('', 'right', 'wave');
|
||||
box.appendChild(_loadingSpinner.createElement());
|
||||
_loadingSpinner.start();
|
||||
if (hadRenderedRows) {
|
||||
box.classList.add('models-refreshing');
|
||||
} else {
|
||||
_loadingSpinner = spinnerModule.create('', 'right', 'wave');
|
||||
box.appendChild(_loadingSpinner.createElement());
|
||||
_loadingSpinner.start();
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (force) _fetchInflight = null;
|
||||
@@ -208,6 +218,7 @@ export async function refreshModels(force = false) {
|
||||
return;
|
||||
} finally {
|
||||
try { _loadingSpinner && _loadingSpinner.stop && _loadingSpinner.stop(); } catch (_) {}
|
||||
if (box) box.classList.remove('models-refreshing');
|
||||
if (box) box.innerHTML = '';
|
||||
}
|
||||
}
|
||||
@@ -576,33 +587,6 @@ export async function refreshModels(force = false) {
|
||||
+ '<span class="muted-sm">Ask an admin to configure model endpoints</span>';
|
||||
}
|
||||
box.appendChild(noModels);
|
||||
// No endpoints yet: keep the welcome screen focused on first setup.
|
||||
const welcomeSub = document.getElementById('welcome-sub');
|
||||
if (welcomeSub) welcomeSub.innerHTML = 'Type <span class="setup-trigger-link" style="color:var(--accent,var(--red));font-weight:600;cursor:pointer;text-decoration:underline;" title="Click to launch setup">/setup</span> to get started.';
|
||||
const welcomeTip = document.getElementById('welcome-tip');
|
||||
if (welcomeTip) welcomeTip.textContent = 'Type /setup, then choose Local models or API.';
|
||||
} else {
|
||||
// Configured installs should feel ready, not stuck in onboarding.
|
||||
const welcomeSub = document.getElementById('welcome-sub');
|
||||
if (welcomeSub) welcomeSub.textContent = 'Yours for the voyage.';
|
||||
const welcomeTip = document.getElementById('welcome-tip');
|
||||
if (welcomeTip) {
|
||||
const tips = window.innerWidth <= 768
|
||||
? [
|
||||
'Tip: Long-press a session for rename, delete, and memory options.',
|
||||
'Tip: Tap the eye icon for Nobody mode - no history saved.',
|
||||
'Tip: Switch to Agent mode when you want tools.',
|
||||
'Tip: Attach images or files using the + button next to the input.',
|
||||
]
|
||||
: [
|
||||
'Tip: Press Ctrl+K to search across all your conversations.',
|
||||
'Tip: Press Ctrl+B to quickly toggle the sidebar.',
|
||||
'Tip: Shift-click the sidebar toggle to swap it to the other side.',
|
||||
'Tip: Drag and drop files onto the chat to attach them.',
|
||||
'Tip: Right-click a session for rename, delete, and memory options.',
|
||||
];
|
||||
welcomeTip.textContent = tips[Math.floor(Math.random() * tips.length)];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
+34
-42
@@ -392,6 +392,7 @@ let _loading = false;
|
||||
// Undo stack — most recent action is at the end. We cap it small because the
|
||||
// only entries that survive a panel reload are in-memory anyway.
|
||||
const _undoStack = [];
|
||||
const _NOTE_UNDO_ICON = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>';
|
||||
function _pushUndo(entry) {
|
||||
_undoStack.push(entry);
|
||||
if (_undoStack.length > 20) _undoStack.shift();
|
||||
@@ -416,6 +417,33 @@ function _undoArchive(note, prevIdx) {
|
||||
});
|
||||
}
|
||||
|
||||
function _archiveNoteById(id, { card = null, celebrate = false } = {}) {
|
||||
if (!id) return false;
|
||||
const idx = _notes.findIndex(n => n.id === id);
|
||||
if (idx < 0) return false;
|
||||
const note = _notes[idx];
|
||||
if (celebrate && card && note && _hasItems(note)) {
|
||||
const undone = (note.items || []).filter(i => !i.done);
|
||||
if (undone.length === 0) {
|
||||
const r = card.getBoundingClientRect();
|
||||
spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80);
|
||||
}
|
||||
}
|
||||
const removed = _notes.splice(idx, 1)[0];
|
||||
_editingId = null;
|
||||
_renderNotes();
|
||||
const undo = () => _undoArchive(removed, idx);
|
||||
_pushUndo({ label: 'archive', run: undo });
|
||||
_patchNote(id, { archived: true }).then(() => {
|
||||
uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _NOTE_UNDO_ICON, onAction: undo, actionHint: 'Ctrl+Z' });
|
||||
}).catch(() => {
|
||||
_notes.splice(idx, 0, removed);
|
||||
_renderNotes();
|
||||
uiModule.showError('Failed to archive');
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function _fetchNotes() {
|
||||
_loading = true;
|
||||
try {
|
||||
@@ -2396,34 +2424,12 @@ function _bindCardEvents(body) {
|
||||
e.stopPropagation();
|
||||
const id = btn.dataset.noteId;
|
||||
if (!id) return;
|
||||
const note = _notes.find(n => n.id === id);
|
||||
const card = btn.closest('.note-card');
|
||||
// Confetti when archiving a fully-completed checklist (todo or goal).
|
||||
if (note && _hasItems(note) && card) {
|
||||
const undone = (note.items || []).filter(i => !i.done);
|
||||
if (undone.length === 0) {
|
||||
const r = card.getBoundingClientRect();
|
||||
spawnConfetti(r.left + r.width / 2, r.top + r.height / 2, 80);
|
||||
}
|
||||
}
|
||||
let done = false;
|
||||
const finishRemove = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
const curIdx = _notes.findIndex(n => n.id === id);
|
||||
if (curIdx < 0) return;
|
||||
const removed = _notes.splice(curIdx, 1)[0];
|
||||
_renderNotes();
|
||||
const undo = () => _undoArchive(removed, curIdx);
|
||||
_pushUndo({ label: 'archive', run: undo });
|
||||
const _undoIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>';
|
||||
_patchNote(id, { archived: true }).then(() => {
|
||||
uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _undoIcon, onAction: undo, actionHint: 'Ctrl+Z' });
|
||||
}).catch(() => {
|
||||
_notes.splice(curIdx, 0, removed);
|
||||
_renderNotes();
|
||||
uiModule.showError('Failed to archive');
|
||||
});
|
||||
_archiveNoteById(id, { card, celebrate: true });
|
||||
};
|
||||
if (card) {
|
||||
card.classList.add('note-card-sliding-out');
|
||||
@@ -3615,10 +3621,11 @@ function _buildForm(note = null) {
|
||||
if (_saveBtn._saving) return;
|
||||
// Mobile: when an existing note is opened and closed without edits, the
|
||||
// Update (✓) button morphs into Archive (set up below). Route the click
|
||||
// to the hidden archive button so the existing archive flow + undo toast
|
||||
// run unchanged.
|
||||
// directly through the archive flow. Using a DOM .click() proxy here was
|
||||
// fragile on mobile because the real archive button can move from the
|
||||
// footer to the fullscreen header.
|
||||
if (_saveBtn.classList.contains('archive-mode')) {
|
||||
form.querySelector('.note-form-archive-btn')?.click();
|
||||
_archiveNoteById(note?.id);
|
||||
return;
|
||||
}
|
||||
_saveBtn._saving = true; _saveBtn.disabled = true; _saveBtn.style.opacity = '0.5';
|
||||
@@ -3743,22 +3750,7 @@ function _buildForm(note = null) {
|
||||
// Archive / Delete — edit-mode-only buttons, mirror the (now-hidden) card actions.
|
||||
form.querySelector('.note-form-archive-btn')?.addEventListener('click', () => {
|
||||
if (!isEdit) return;
|
||||
const id = note.id;
|
||||
const idx = _notes.findIndex(n => n.id === id);
|
||||
if (idx < 0) return;
|
||||
const removed = _notes.splice(idx, 1)[0];
|
||||
_editingId = null;
|
||||
_renderNotes();
|
||||
const undo = () => _undoArchive(removed, idx);
|
||||
_pushUndo({ label: 'archive', run: undo });
|
||||
const _undoIcon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;"><polyline points="9 14 4 9 9 4"/><path d="M4 9h11a5 5 0 0 1 5 5v0a5 5 0 0 1-5 5H9"/></svg>';
|
||||
_patchNote(id, { archived: true }).then(() => {
|
||||
uiModule.showToast('Archived', { duration: 6000, action: 'Undo', actionIcon: _undoIcon, onAction: undo, actionHint: 'Ctrl+Z' });
|
||||
}).catch(() => {
|
||||
_notes.splice(idx, 0, removed);
|
||||
_renderNotes();
|
||||
uiModule.showError('Failed to archive');
|
||||
});
|
||||
_archiveNoteById(note.id);
|
||||
});
|
||||
form.querySelector('.note-form-delete-btn')?.addEventListener('click', async () => {
|
||||
if (!isEdit) return;
|
||||
|
||||
@@ -10,7 +10,29 @@ let results = [];
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
|
||||
function hideMobileSidebarForSearch() {
|
||||
if (window.innerWidth >= 768) return;
|
||||
const sidebar = el('sidebar');
|
||||
const rail = el('icon-rail');
|
||||
const backdrop = el('sidebar-backdrop');
|
||||
let changed = false;
|
||||
if (sidebar && !sidebar.classList.contains('hidden')) {
|
||||
sidebar.classList.add('hidden');
|
||||
changed = true;
|
||||
}
|
||||
if (rail && rail.classList.contains('mobile-mini')) {
|
||||
rail.classList.remove('mobile-mini');
|
||||
rail.style.cssText = '';
|
||||
changed = true;
|
||||
}
|
||||
if (backdrop) backdrop.classList.remove('visible');
|
||||
if (changed && typeof window.syncRailSide === 'function') {
|
||||
try { window.syncRailSide(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
export function openSearch() {
|
||||
hideMobileSidebarForSearch();
|
||||
const overlay = el('search-overlay');
|
||||
if (!overlay) return;
|
||||
overlay.classList.remove('hidden');
|
||||
|
||||
+290
-108
@@ -3,9 +3,9 @@
|
||||
|
||||
import Storage from './storage.js';
|
||||
import uiModule, { autoResize, styledPrompt } from './ui.js';
|
||||
import chatRenderer from './chatRenderer.js';
|
||||
import chatRenderer from './chatRenderer.js?v=20260722ctxheader1';
|
||||
import { providerLogo } from './providers.js';
|
||||
import { initModelPicker, updateModelPicker } from './modelPicker.js';
|
||||
import { initModelPicker, updateModelPicker } from './modelPicker.js?v=20260722ctxheader1';
|
||||
import themeModule from './theme.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
|
||||
@@ -16,6 +16,7 @@ let currentSessionId = null;
|
||||
let _sessionNavToken = 0;
|
||||
let _skipAutoSelect = false;
|
||||
let _suppressNextSessionLoading = false;
|
||||
let _rootFreshChatApplied = false;
|
||||
const HISTORY_DISPLAY_CHAR_LIMIT = 160000;
|
||||
const HISTORY_DISPLAY_TAIL_CHARS = 20000;
|
||||
const HISTORY_PAGE_LIMIT_MOBILE = 8;
|
||||
@@ -26,12 +27,29 @@ const FOLDER_MAX_VISIBLE = 5;
|
||||
let _showAllSessions = false;
|
||||
let _expandedFolders = {}; // folderName -> true if "show more" clicked
|
||||
let _sortMode = Storage.get('odysseus-session-sort') || 'active'; // default to last active
|
||||
const DATE_SECTION_COLLAPSE_KEY = 'ody-session-date-section-collapsed';
|
||||
let _autoCreateInProgress = false; // guard against recursive auto-create
|
||||
const _INCOGNITO_SESSIONS_KEY = 'ody-incognito-sessions'; // sessionStorage key for incognito session IDs
|
||||
const _isMac = /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
const _mod = _isMac ? '⌘' : 'Ctrl';
|
||||
let _historyPager = null;
|
||||
|
||||
function _shouldPreserveStartupComposer(msgInput) {
|
||||
if (!msgInput || !msgInput.value) return false;
|
||||
if (window.__odysseusComposerUserEdited) return true;
|
||||
return !!document.getElementById('app-loader') && document.activeElement === msgInput;
|
||||
}
|
||||
|
||||
function _clearComposerUnlessStartupTyped(msgInput) {
|
||||
if (!msgInput) return;
|
||||
if (_shouldPreserveStartupComposer(msgInput)) {
|
||||
msgInput.disabled = false;
|
||||
autoResize(msgInput);
|
||||
return;
|
||||
}
|
||||
msgInput.value = '';
|
||||
}
|
||||
|
||||
function _paintSessionLoading(chatHistory, label = 'Loading chat') {
|
||||
if (!chatHistory) return;
|
||||
if (chatRenderer.hideWelcomeScreen) chatRenderer.hideWelcomeScreen();
|
||||
@@ -973,21 +991,68 @@ function _sessionBucketDate(s) {
|
||||
return s.last_message_at || s.updated_at || s.created_at || '';
|
||||
}
|
||||
|
||||
function _loadDateSectionCollapseState() {
|
||||
const raw = Storage.getJSON(DATE_SECTION_COLLAPSE_KEY, {});
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
|
||||
return raw;
|
||||
}
|
||||
|
||||
function _saveDateSectionCollapseState(state) {
|
||||
Storage.setJSON(DATE_SECTION_COLLAPSE_KEY, state && typeof state === 'object' ? state : {});
|
||||
}
|
||||
|
||||
function _dateSectionKey(kind, label) {
|
||||
return `${kind || 'session'}:${label || 'Older'}`;
|
||||
}
|
||||
|
||||
function _isDateSectionCollapsed(kind, label) {
|
||||
return _loadDateSectionCollapseState()[_dateSectionKey(kind, label)] === true;
|
||||
}
|
||||
|
||||
function _toggleDateSection(kind, label) {
|
||||
const state = _loadDateSectionCollapseState();
|
||||
const key = _dateSectionKey(kind, label);
|
||||
state[key] = state[key] !== true;
|
||||
_saveDateSectionCollapseState(state);
|
||||
renderSessionList();
|
||||
}
|
||||
|
||||
function _createDateSectionHeader(label, kind = 'session') {
|
||||
const el = document.createElement('div');
|
||||
el.className = `date-section-header ${kind}-date-section-header`;
|
||||
el.textContent = label;
|
||||
const collapsed = _isDateSectionCollapsed(kind, label);
|
||||
if (collapsed) el.classList.add('collapsed');
|
||||
el.dataset.dateSectionKind = kind;
|
||||
el.dataset.dateSectionLabel = label;
|
||||
el.tabIndex = 0;
|
||||
el.setAttribute('role', 'button');
|
||||
el.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
el.title = collapsed ? `Show ${label}` : `Hide ${label}`;
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
_toggleDateSection(kind, label);
|
||||
});
|
||||
el.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
_toggleDateSection(kind, label);
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function _appendSessionItemsWithDateHeaders(frag, items) {
|
||||
let lastLabel = null;
|
||||
let collapsed = false;
|
||||
for (const s of items) {
|
||||
const label = _dateBucketLabel(_sessionBucketDate(s));
|
||||
if (label !== lastLabel) {
|
||||
frag.appendChild(_createDateSectionHeader(label, 'session'));
|
||||
collapsed = _isDateSectionCollapsed('session', label);
|
||||
lastLabel = label;
|
||||
}
|
||||
if (collapsed) continue;
|
||||
frag.appendChild(createSessionItem(s));
|
||||
}
|
||||
}
|
||||
@@ -995,6 +1060,7 @@ function _appendSessionItemsWithDateHeaders(frag, items) {
|
||||
function _appendFavoriteSessionItems(frag, items) {
|
||||
if (!items.length) return;
|
||||
frag.appendChild(_createDateSectionHeader('Favorites', 'session'));
|
||||
if (_isDateSectionCollapsed('session', 'Favorites')) return;
|
||||
for (const s of items) {
|
||||
frag.appendChild(createSessionItem(s));
|
||||
}
|
||||
@@ -1226,9 +1292,7 @@ function _renderSessionListImpl() {
|
||||
visibleFolder.push(folderSessions[activeInFolder]);
|
||||
}
|
||||
|
||||
visibleFolder.forEach(s => {
|
||||
content.appendChild(createSessionItem(s));
|
||||
});
|
||||
_appendSessionItemsWithDateHeaders(content, visibleFolder);
|
||||
|
||||
if (folderSessions.length > FOLDER_MAX_VISIBLE) {
|
||||
const rem = folderSessions.length - FOLDER_MAX_VISIBLE;
|
||||
@@ -1328,9 +1392,7 @@ function _renderSessionListImpl() {
|
||||
}
|
||||
|
||||
if (unfiledTarget) {
|
||||
visibleUnfiled.forEach(s => {
|
||||
unfiledTarget.appendChild(createSessionItem(s));
|
||||
});
|
||||
_appendSessionItemsWithDateHeaders(unfiledTarget, visibleUnfiled);
|
||||
}
|
||||
|
||||
// "Show more" / "Show less" toggle
|
||||
@@ -1616,7 +1678,11 @@ export async function loadSessions() {
|
||||
sessionStorage.removeItem('ody-prefetch-sessions');
|
||||
fetched = JSON.parse(prefetched);
|
||||
} else {
|
||||
const res = await fetch(`${API_BASE}/api/sessions`);
|
||||
let url = `${API_BASE}/api/sessions`;
|
||||
if (currentSessionId && _isIncognitoSession(currentSessionId)) {
|
||||
url += `?active_incognito_id=${encodeURIComponent(currentSessionId)}`;
|
||||
}
|
||||
const res = await fetch(url);
|
||||
fetched = await res.json();
|
||||
}
|
||||
sessions = _normalizeSessionsList(fetched);
|
||||
@@ -1640,7 +1706,13 @@ export async function loadSessions() {
|
||||
if (/^(document|note|image|email|event|task|skill|research)-/.test(hashId) || /^open=notes¬e=/.test(hashId)) {
|
||||
hashId = '';
|
||||
}
|
||||
let savedId = Storage.get('lastSessionId');
|
||||
const _isFirstLoad = !sessionStorage.getItem('ody-session-active');
|
||||
const _freshRootLoad = !_rootFreshChatApplied && !hashId && !currentSessionId && !_pendingChat;
|
||||
if (_freshRootLoad) {
|
||||
_rootFreshChatApplied = true;
|
||||
Storage.remove('lastSessionId');
|
||||
}
|
||||
let savedId = _freshRootLoad ? null : Storage.get('lastSessionId');
|
||||
// If the persisted lastSessionId points to a transient session (legacy
|
||||
// state from before the persistence-guard was added), drop it.
|
||||
if (savedId) {
|
||||
@@ -1665,13 +1737,13 @@ export async function loadSessions() {
|
||||
} else if (currentSessionId) {
|
||||
// Session was just created but may not be in the list yet — keep it
|
||||
targetId = currentSessionId;
|
||||
} else if (savedId && activeSessions.some(s => s.id === savedId)) {
|
||||
} else if (!_freshRootLoad && savedId && activeSessions.some(s => s.id === savedId)) {
|
||||
targetId = savedId;
|
||||
} else if (!_skipAutoSelect && _realSessions.length > 0) {
|
||||
} else if (!_freshRootLoad && !_skipAutoSelect && _realSessions.length > 0) {
|
||||
// Most-recent NON-transient session — skip Assistant / Tasks so the
|
||||
// auto-firing assistant doesn't become the apparent default chat.
|
||||
targetId = _realSessions[0].id;
|
||||
} else if (!_skipAutoSelect && activeSessions.length > 0) {
|
||||
} else if (!_freshRootLoad && !_skipAutoSelect && activeSessions.length > 0) {
|
||||
// Only transient sessions exist (brand-new account) — fall through to
|
||||
// the original behaviour so we don't leave the user with nothing.
|
||||
targetId = activeSessions[0].id;
|
||||
@@ -1687,31 +1759,15 @@ export async function loadSessions() {
|
||||
// picker would still show the old model's name from cached state). See
|
||||
// the targetId resolution above (hash → currentSession → lastSessionId →
|
||||
// most-recent).
|
||||
const _isFirstLoad = !sessionStorage.getItem('ody-session-active');
|
||||
if (_isFirstLoad) {
|
||||
sessionStorage.setItem('ody-session-active', '1');
|
||||
if (!targetId) {
|
||||
try {
|
||||
const dcRes = await fetch(`${API_BASE}/api/default-chat`);
|
||||
const dc = await dcRes.json();
|
||||
if (dc.endpoint_url && dc.model) {
|
||||
// Check if there's already an empty session with this model we can reuse
|
||||
const emptyDefault = activeSessions.find(s =>
|
||||
s.model === dc.model && s.message_count === 0
|
||||
);
|
||||
if (emptyDefault) {
|
||||
targetId = emptyDefault.id;
|
||||
} else {
|
||||
await createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id);
|
||||
// On mobile, hide sidebar so user lands directly in chat
|
||||
if (window.innerWidth < 768) {
|
||||
const sb = document.getElementById('sidebar');
|
||||
if (sb) sb.classList.add('hidden');
|
||||
}
|
||||
return; // createDirectChat handles selectSession internally
|
||||
}
|
||||
}
|
||||
} catch (_) { /* no default model configured */ }
|
||||
if (_isFirstLoad) sessionStorage.setItem('ody-session-active', '1');
|
||||
if ((_isFirstLoad || _freshRootLoad) && !targetId) {
|
||||
// Land on a visually fresh chat without creating hidden pending session
|
||||
// state. The send path can create the default-backed session when the
|
||||
// user actually submits. Pre-creating here races with opening an existing
|
||||
// chat and was causing sends to jump into brand-new chats.
|
||||
if (window.innerWidth < 768) {
|
||||
const sb = document.getElementById('sidebar');
|
||||
if (sb) sb.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1743,10 +1799,9 @@ export async function loadSessions() {
|
||||
if (activeSessions.length === 0 && !_autoCreateInProgress) {
|
||||
_autoCreateInProgress = true;
|
||||
try {
|
||||
const dcRes = await fetch(`${API_BASE}/api/default-chat`);
|
||||
const dc = await dcRes.json();
|
||||
if (dc.endpoint_url && dc.model) {
|
||||
await createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id);
|
||||
const dc = await _getPreferredDefaultChat();
|
||||
if (dc && dc.endpoint_url && dc.model) {
|
||||
await createDirectChat(dc.endpoint_url, dc.model, dc.endpoint_id, { source: 'default' });
|
||||
}
|
||||
} catch (_) { /* no default model — that's fine, user can /setup */ }
|
||||
_autoCreateInProgress = false;
|
||||
@@ -1767,6 +1822,13 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
||||
try {
|
||||
const navToken = ++_sessionNavToken;
|
||||
const prevSessionId = currentSessionId;
|
||||
// Selecting a real persisted chat cancels any deferred "New Chat" model
|
||||
// pick. Otherwise the next send can materialize that pending chat instead
|
||||
// of posting into the session the user just opened.
|
||||
if (_pendingChat) {
|
||||
_pendingChat = null;
|
||||
_pendingMaterializePromise = null;
|
||||
}
|
||||
_clearHistoryPager();
|
||||
// Re-archive peeked session when navigating away
|
||||
_checkPeekCleanup(id);
|
||||
@@ -1775,6 +1837,7 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
||||
try { window.documentModule.clearSelection(); } catch {}
|
||||
}
|
||||
currentSessionId = id;
|
||||
try { window.__odysseusLastSelectedSessionId = id; } catch (_) {}
|
||||
// Identify Assistant / task-output sessions so we don't "trap" the user
|
||||
// there on return. Skipped from both `lastSessionId` persistence and the
|
||||
// URL hash — the user complained that coming back to Odysseus kept
|
||||
@@ -1784,10 +1847,6 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
||||
const _isTransientChat = !!_meta && (_meta.folder === 'Assistant' || _meta.folder === 'Tasks');
|
||||
if (!_isTransientChat) {
|
||||
Storage.set('lastSessionId', id);
|
||||
// Update URL hash without triggering hashchange handler
|
||||
if (window.location.hash !== '#' + id) {
|
||||
history.replaceState(null, '', '#' + id);
|
||||
}
|
||||
}
|
||||
// Restore character preset for persistent chats
|
||||
try {
|
||||
@@ -1827,7 +1886,7 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
||||
const msgInput = document.getElementById('message');
|
||||
if (msgInput) {
|
||||
msgInput.disabled = false;
|
||||
msgInput.value = '';
|
||||
_clearComposerUnlessStartupTyped(msgInput);
|
||||
msgInput.style.height = '';
|
||||
msgInput.style.overflow = '';
|
||||
autoResize(msgInput);
|
||||
@@ -1859,6 +1918,7 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
||||
}
|
||||
// Update model picker visibility
|
||||
updateModelPicker();
|
||||
if (window.refreshChatContextHeader) window.refreshChatContextHeader('select-session');
|
||||
|
||||
// Refresh session cost badge for the newly selected session
|
||||
if (chatRenderer.updateSessionCostUI) chatRenderer.updateSessionCostUI();
|
||||
@@ -2086,8 +2146,44 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
||||
|
||||
// Pending session — stored locally until the first message is sent
|
||||
let _pendingChat = null; // { url, modelId, endpointId }
|
||||
let _pendingMaterializePromise = null;
|
||||
|
||||
export function createDirectChat(url, modelId, endpointId) {
|
||||
async function _getPreferredDefaultChat() {
|
||||
let dc = null;
|
||||
try {
|
||||
dc = window.__odysseusDefaultChat || null;
|
||||
} catch (_) {}
|
||||
if (!dc || !dc.endpoint_url || !dc.model) {
|
||||
try {
|
||||
dc = JSON.parse(localStorage.getItem('odysseus-default-chat-cache') || 'null');
|
||||
} catch (_) {}
|
||||
}
|
||||
if (dc && dc.endpoint_url && dc.model) return dc;
|
||||
try {
|
||||
const dcRes = await fetch(`${API_BASE}/api/default-chat`);
|
||||
dc = await dcRes.json();
|
||||
if (dc && dc.endpoint_url && dc.model) {
|
||||
try {
|
||||
window.__odysseusDefaultChat = dc;
|
||||
localStorage.setItem('odysseus-default-chat-cache', JSON.stringify(dc));
|
||||
} catch (_) {}
|
||||
return dc;
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createDirectChat(url, modelId, endpointId, opts = {}) {
|
||||
const incomingSource = opts.source || 'manual';
|
||||
if (
|
||||
_pendingChat &&
|
||||
_pendingChat.modelId &&
|
||||
_pendingChat.source === 'manual' &&
|
||||
incomingSource !== 'manual'
|
||||
) {
|
||||
updateModelPicker();
|
||||
return;
|
||||
}
|
||||
_sessionNavToken++;
|
||||
// Detach any active stream so it doesn't interfere with the new chat
|
||||
if (window.chatModule && window.chatModule.detachCurrentStream) {
|
||||
@@ -2101,10 +2197,12 @@ export function createDirectChat(url, modelId, endpointId) {
|
||||
}
|
||||
|
||||
// Don't hit the API — just store the model info and prepare the UI
|
||||
_pendingChat = { url, modelId, endpointId };
|
||||
_pendingChat = { url, modelId, endpointId, source: incomingSource };
|
||||
_pendingMaterializePromise = null;
|
||||
_skipAutoSelect = true;
|
||||
_suppressNextSessionLoading = true;
|
||||
currentSessionId = null;
|
||||
try { window.__odysseusLastSelectedSessionId = ''; } catch (_) {}
|
||||
Storage.remove('lastSessionId');
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
document.querySelectorAll('.list-item.active-session, .session-item.active').forEach(el => {
|
||||
@@ -2132,6 +2230,7 @@ export function createDirectChat(url, modelId, endpointId) {
|
||||
|
||||
// Update model picker to show the pending model
|
||||
updateModelPicker();
|
||||
if (window.refreshChatContextHeader) window.refreshChatContextHeader('new-chat');
|
||||
|
||||
// Update current-meta header
|
||||
const metaEl = document.getElementById('current-meta');
|
||||
@@ -2141,69 +2240,111 @@ export function createDirectChat(url, modelId, endpointId) {
|
||||
|
||||
// Enable input
|
||||
const msgInput = document.getElementById('message');
|
||||
if (msgInput) { msgInput.disabled = false; msgInput.value = ''; msgInput.focus(); }
|
||||
if (msgInput) {
|
||||
msgInput.disabled = false;
|
||||
_clearComposerUnlessStartupTyped(msgInput);
|
||||
msgInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/** Actually create the session in the DB. Called on first message send. */
|
||||
export async function materializePendingSession() {
|
||||
if (_pendingMaterializePromise) return _pendingMaterializePromise;
|
||||
const pending = _pendingChat;
|
||||
if (!pending) return false;
|
||||
_pendingChat = null;
|
||||
|
||||
const materializePromise = (async () => {
|
||||
|
||||
const incognitoChk = document.getElementById('incognito-toggle');
|
||||
const isIncognito = incognitoChk && incognitoChk.checked;
|
||||
const base = (pending.modelId || 'model').split('/').pop();
|
||||
const name = isIncognito ? 'Nobody' : `${base} ${new Date().toLocaleTimeString()}`;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('name', name);
|
||||
fd.append('endpoint_url', pending.url || '');
|
||||
fd.append('model', pending.modelId || '');
|
||||
if (pending.url && pending.modelId) {
|
||||
fd.append('skip_validation', 'true');
|
||||
}
|
||||
if (pending.endpointId) {
|
||||
fd.append('endpoint_id', pending.endpointId);
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: fd });
|
||||
} catch (e) {
|
||||
uiModule.showError('Failed to reach backend: ' + e);
|
||||
return false;
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = await res.json();
|
||||
} catch {
|
||||
payload = { detail: await res.text() };
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
uiModule.showError(`Session create failed (${res.status}) ${payload.detail || JSON.stringify(payload)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// The user may have opened an existing chat while this deferred default
|
||||
// session was being created. Do not let a stale response steal
|
||||
// currentSessionId and make the next send land in a brand-new chat.
|
||||
if (_pendingChat !== pending) {
|
||||
if (payload.id) {
|
||||
fetch(`${API_BASE}/api/session/${encodeURIComponent(payload.id)}`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isIncognito && payload.id) {
|
||||
_markIncognito(payload.id);
|
||||
}
|
||||
|
||||
// Clear any leftover document text selection from the previous session
|
||||
if (window.documentModule?.clearSelection) {
|
||||
try { window.documentModule.clearSelection(); } catch {}
|
||||
}
|
||||
_pendingChat = null;
|
||||
currentSessionId = payload.id;
|
||||
if (!isIncognito) {
|
||||
Storage.set('lastSessionId', payload.id);
|
||||
}
|
||||
|
||||
// Reload the sidebar in the background. Awaiting this used to block the first
|
||||
// prompt in a new/pending chat behind startup fetches and slow /api/sessions
|
||||
// calls, so the user's message could sit for 20s+ before streaming began.
|
||||
_suppressNextSessionLoading = true;
|
||||
if (window.refreshChatContextHeader) window.refreshChatContextHeader('materialize-session');
|
||||
loadSessions().catch(() => {});
|
||||
return true;
|
||||
})();
|
||||
_pendingMaterializePromise = materializePromise;
|
||||
|
||||
try {
|
||||
return await materializePromise;
|
||||
} finally {
|
||||
if (_pendingMaterializePromise === materializePromise) {
|
||||
_pendingMaterializePromise = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function preMaterializePendingSession() {
|
||||
if (!_pendingChat || _pendingMaterializePromise) return;
|
||||
const incognitoChk = document.getElementById('incognito-toggle');
|
||||
const isIncognito = incognitoChk && incognitoChk.checked;
|
||||
const base = (pending.modelId || 'model').split('/').pop();
|
||||
const name = isIncognito ? 'Nobody' : `${base} ${new Date().toLocaleTimeString()}`;
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('name', name);
|
||||
fd.append('endpoint_url', pending.url || '');
|
||||
fd.append('model', pending.modelId || '');
|
||||
if (pending.url && pending.modelId) {
|
||||
fd.append('skip_validation', 'true');
|
||||
}
|
||||
if (pending.endpointId) {
|
||||
fd.append('endpoint_id', pending.endpointId);
|
||||
}
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${API_BASE}/api/session`, { method: 'POST', body: fd });
|
||||
} catch (e) {
|
||||
uiModule.showError('Failed to reach backend: ' + e);
|
||||
return false;
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = await res.json();
|
||||
} catch {
|
||||
payload = { detail: await res.text() };
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
uiModule.showError(`Session create failed (${res.status}) ${payload.detail || JSON.stringify(payload)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isIncognito && payload.id) {
|
||||
_markIncognito(payload.id);
|
||||
}
|
||||
|
||||
// Clear any leftover document text selection from the previous session
|
||||
if (window.documentModule?.clearSelection) {
|
||||
try { window.documentModule.clearSelection(); } catch {}
|
||||
}
|
||||
currentSessionId = payload.id;
|
||||
Storage.set('lastSessionId', payload.id);
|
||||
history.replaceState(null, '', '#' + payload.id);
|
||||
|
||||
// Reload the sidebar in the background. Awaiting this used to block the first
|
||||
// prompt in a new/pending chat behind startup fetches and slow /api/sessions
|
||||
// calls, so the user's message could sit for 20s+ before streaming began.
|
||||
_suppressNextSessionLoading = true;
|
||||
loadSessions().catch(() => {});
|
||||
return true;
|
||||
if (incognitoChk && incognitoChk.checked) return;
|
||||
setTimeout(() => {
|
||||
const chk = document.getElementById('incognito-toggle');
|
||||
if (chk && chk.checked) return;
|
||||
if (_pendingChat && !_pendingMaterializePromise) {
|
||||
materializePendingSession().catch(() => {});
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
export function hasPendingChat() { return !!_pendingChat; }
|
||||
@@ -2213,6 +2354,10 @@ export function getCurrentSessionId() {
|
||||
return currentSessionId;
|
||||
}
|
||||
|
||||
export function isCurrentSessionIncognito() {
|
||||
return !!(currentSessionId && _isIncognitoSession(currentSessionId));
|
||||
}
|
||||
|
||||
export function getSessions() {
|
||||
return sessions;
|
||||
}
|
||||
@@ -2220,9 +2365,8 @@ export function getSessions() {
|
||||
export function getCurrentModel() {
|
||||
const sess = sessions.find(x => x.id === currentSessionId);
|
||||
if (sess && sess.model) return sess.model;
|
||||
// Pending session not yet materialized — read from model picker label
|
||||
const label = document.getElementById('model-picker-label');
|
||||
return label ? label.textContent.trim() : null;
|
||||
if (_pendingChat && _pendingChat.modelId) return _pendingChat.modelId;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Endpoint URL serving the current (or pending) session's model. Used to
|
||||
@@ -2237,6 +2381,7 @@ export function getCurrentEndpointUrl() {
|
||||
export function setCurrentSessionId(id) {
|
||||
_sessionNavToken++;
|
||||
currentSessionId = id;
|
||||
try { window.__odysseusLastSelectedSessionId = id || ''; } catch (_) {}
|
||||
if (!id) {
|
||||
_suppressNextSessionLoading = true;
|
||||
Storage.remove('lastSessionId');
|
||||
@@ -2247,6 +2392,41 @@ export function setCurrentSessionId(id) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCurrentSessionFromTopMenu() {
|
||||
const sid = currentSessionId;
|
||||
if (!sid) {
|
||||
uiModule.showToast('No chat to delete');
|
||||
return false;
|
||||
}
|
||||
const session = sessions.find(s => String(s.id) === String(sid));
|
||||
if (session?.is_important) {
|
||||
uiModule.showToast('Unfavorite before deleting');
|
||||
return false;
|
||||
}
|
||||
if (!await uiModule.styledConfirm('Delete this session?', { confirmText: 'Delete', danger: true })) {
|
||||
return false;
|
||||
}
|
||||
if (window.chatModule && window.chatModule.abortCurrentRequest) {
|
||||
window.chatModule.abortCurrentRequest();
|
||||
}
|
||||
_deselectCurrentSession(sid);
|
||||
_removeSessionFromLocalState(sid);
|
||||
_skipAutoSelect = true;
|
||||
try {
|
||||
const pm = await import('./presets.js');
|
||||
if (pm.removePersistentChat) pm.removePersistentChat(sid);
|
||||
} catch (e) {}
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/session/${sid}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('Failed');
|
||||
uiModule.showToast('Session deleted');
|
||||
} catch (e) {
|
||||
uiModule.showError('Failed to delete session');
|
||||
}
|
||||
await loadSessions();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Session list keyboard navigation: arrows to move, Delete to delete
|
||||
async function _onSessionListKeydown(e) {
|
||||
const item = e.target.closest('.list-item[data-session-id]');
|
||||
@@ -3454,6 +3634,7 @@ const sessionModule = {
|
||||
selectSession,
|
||||
createDirectChat,
|
||||
materializePendingSession,
|
||||
preMaterializePendingSession,
|
||||
hasPendingChat,
|
||||
getPendingChat,
|
||||
getCurrentSessionId,
|
||||
@@ -3475,7 +3656,8 @@ const sessionModule = {
|
||||
closeArchive,
|
||||
setSessionHasDocs,
|
||||
getSortMode,
|
||||
setSortMode
|
||||
setSortMode,
|
||||
deleteCurrentSessionFromTopMenu
|
||||
};
|
||||
|
||||
export { updateModelPicker };
|
||||
|
||||
+30
-11
@@ -22,7 +22,7 @@ function safeRasterDataUrl(raw) {
|
||||
}
|
||||
|
||||
/* ── Tab switching ── */
|
||||
const ADMIN_TABS = new Set(['services', 'integrations', 'tools', 'users', 'system']);
|
||||
const ADMIN_TABS = new Set(['services', 'added-models', 'integrations', 'tools', 'users', 'system']);
|
||||
|
||||
function initTabs() {
|
||||
modalEl.querySelectorAll('[data-settings-tab]').forEach(btn => {
|
||||
@@ -792,8 +792,9 @@ async function initImageSettings() {
|
||||
|
||||
async function saveSettings() {
|
||||
try {
|
||||
await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
||||
const res = await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image_gen_enabled: enabledToggle ? enabledToggle.checked : false, image_model: modelSel.value, image_quality: qualSel.value }) });
|
||||
if (!res.ok) throw new Error(await res.text().catch(() => `HTTP ${res.status}`));
|
||||
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
|
||||
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
|
||||
}
|
||||
@@ -1860,7 +1861,7 @@ const SHORTCUT_ICONS = {
|
||||
settings: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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-2.83 2.83l-.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-4 0v-.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-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.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 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.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 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
|
||||
focus_input: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
|
||||
open_calendar: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>',
|
||||
open_compare: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="8" height="18" rx="1"/><rect x="14" y="3" width="8" height="18" rx="1"/></svg>',
|
||||
open_compare: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="16" rx="1.5"/><rect x="14" y="4" width="7" height="16" rx="1.5"/><path d="M10 8h4"/><path d="M10 16h4"/></svg>',
|
||||
open_cookbook: '<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="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>',
|
||||
open_research: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>',
|
||||
open_gallery: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>',
|
||||
@@ -2821,6 +2822,17 @@ async function initReminderSettings() {
|
||||
async function initEmailAccountsSettings() {
|
||||
const root = el('settings-modal');
|
||||
if (!root || !root.querySelector('[data-settings-panel="email"]')) return;
|
||||
|
||||
el('set-email-open-library-settings')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const mod = await import('./emailLibrary.js?v=20260722emailfastindex1');
|
||||
if (typeof mod.openEmailLibrarySettings === 'function') {
|
||||
await mod.openEmailLibrarySettings();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to open Email settings page', e);
|
||||
}
|
||||
});
|
||||
const manageBtn = el('set-email-open-integrations');
|
||||
if (manageBtn && manageBtn.dataset.bound !== '1') {
|
||||
manageBtn.dataset.bound = '1';
|
||||
@@ -3111,24 +3123,31 @@ async function initEmailSettings() {
|
||||
const root = el('settings-modal');
|
||||
if (!root || !root.querySelector('[data-settings-panel="email"]')) return;
|
||||
|
||||
const styleKey = 'odysseus-email-writing-style';
|
||||
const styleKey = () => {
|
||||
const account = String(window.__odysseusActiveEmailAccount || '').trim();
|
||||
return account ? `odysseus-email-writing-style:${account}` : 'odysseus-email-writing-style';
|
||||
};
|
||||
const styleEl = el('set-email-style');
|
||||
const emailAccountSuffix = () => {
|
||||
const account = String(window.__odysseusActiveEmailAccount || '').trim();
|
||||
return account ? `?account_id=${encodeURIComponent(account)}` : '';
|
||||
};
|
||||
|
||||
// The account/CardDAV config endpoints can be slow when remote mail servers
|
||||
// are cold. Populate the Writing Style box independently so saved prose does
|
||||
// not appear seconds after the panel opens.
|
||||
try {
|
||||
const cachedStyle = localStorage.getItem(styleKey);
|
||||
const cachedStyle = localStorage.getItem(styleKey());
|
||||
if (styleEl && cachedStyle !== null && !styleEl.value) styleEl.value = cachedStyle;
|
||||
} catch (_) {}
|
||||
|
||||
const loadWritingStyle = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/email/style');
|
||||
const res = await fetch(`/api/email/style${emailAccountSuffix()}`);
|
||||
const data = await res.json();
|
||||
const style = data.style || '';
|
||||
if (styleEl) styleEl.value = style;
|
||||
try { localStorage.setItem(styleKey, style); } catch (_) {}
|
||||
try { localStorage.setItem(styleKey(), style); } catch (_) {}
|
||||
} catch (_) {}
|
||||
};
|
||||
loadWritingStyle();
|
||||
@@ -3240,7 +3259,7 @@ async function initEmailSettings() {
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/email/extract-style', {
|
||||
const res = await fetch(`/api/email/extract-style${emailAccountSuffix()}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sample_count: 15 }),
|
||||
@@ -3248,7 +3267,7 @@ async function initEmailSettings() {
|
||||
const data = await res.json();
|
||||
if (data.success && data.style) {
|
||||
if (styleEl) styleEl.value = data.style;
|
||||
try { localStorage.setItem(styleKey, data.style); } catch (_) {}
|
||||
try { localStorage.setItem(styleKey(), data.style); } catch (_) {}
|
||||
if (msg) msg.textContent = '✓ Style extracted';
|
||||
} else {
|
||||
if (msg) msg.textContent = data.error || 'Failed';
|
||||
@@ -3268,14 +3287,14 @@ async function initEmailSettings() {
|
||||
if (msg) msg.textContent = 'Saving...';
|
||||
try {
|
||||
const style = styleEl ? styleEl.value : '';
|
||||
const res = await fetch('/api/email/style', {
|
||||
const res = await fetch(`/api/email/style${emailAccountSuffix()}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ style }),
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
try { localStorage.setItem(styleKey, style); } catch (_) {}
|
||||
try { localStorage.setItem(styleKey(), style); } catch (_) {}
|
||||
}
|
||||
if (msg) msg.textContent = result.success ? '✓ Saved' : 'Failed';
|
||||
setTimeout(() => { if (msg) msg.textContent = ''; }, 3000);
|
||||
|
||||
@@ -34,6 +34,46 @@ export function initSidebarLayout(Storage, opts) {
|
||||
// ── Icon rail + sidebar toggle ──
|
||||
const iconRail = document.getElementById('icon-rail');
|
||||
const hamburgerBtn = document.getElementById('hamburger-btn');
|
||||
const SIDEBAR_MODE_KEY = 'odysseus-sidebar-mode';
|
||||
|
||||
function _setSidebarModeClasses(mode) {
|
||||
document.documentElement.classList.remove('ody-mobile-startup-sidebar-hidden');
|
||||
document.documentElement.classList.toggle('ody-sidebar-mini', mode === 'mini');
|
||||
document.documentElement.classList.toggle('ody-sidebar-off', mode === 'off');
|
||||
}
|
||||
|
||||
function _saveSidebarMode(mode) {
|
||||
try { localStorage.setItem(SIDEBAR_MODE_KEY, mode); } catch (_) {}
|
||||
_setSidebarModeClasses(mode);
|
||||
}
|
||||
|
||||
function _applyStoredSidebarMode() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (!sidebar) return;
|
||||
if (window.innerWidth < 768 && document.getElementById('app-loader')) {
|
||||
sidebar.classList.add('hidden');
|
||||
if (iconRail) {
|
||||
iconRail.classList.add('rail-hidden');
|
||||
iconRail.classList.remove('mobile-mini');
|
||||
iconRail.style.cssText = '';
|
||||
}
|
||||
_setSidebarModeClasses('off');
|
||||
return;
|
||||
}
|
||||
let mode = 'full';
|
||||
try { mode = localStorage.getItem(SIDEBAR_MODE_KEY) || 'full'; } catch (_) {}
|
||||
if (mode === 'mini') {
|
||||
sidebar.classList.add('hidden');
|
||||
if (iconRail) iconRail.classList.remove('rail-hidden');
|
||||
} else if (mode === 'off') {
|
||||
sidebar.classList.add('hidden');
|
||||
if (iconRail) iconRail.classList.add('rail-hidden');
|
||||
} else {
|
||||
sidebar.classList.remove('hidden');
|
||||
if (iconRail) iconRail.classList.remove('rail-hidden');
|
||||
}
|
||||
_setSidebarModeClasses(mode);
|
||||
}
|
||||
|
||||
function _syncRailSideCore() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
@@ -62,6 +102,7 @@ export function initSidebarLayout(Storage, opts) {
|
||||
document.body.classList.toggle('hamburger-left', !isRight);
|
||||
document.body.classList.toggle('hamburger-only', sidebarHidden && railHidden);
|
||||
document.body.classList.toggle('sidebar-collapsed', sidebarHidden);
|
||||
_setSidebarModeClasses(!sidebarHidden ? 'full' : (railHidden ? 'off' : 'mini'));
|
||||
}
|
||||
// Keep incognito button clear of hamburger
|
||||
const incogBtn = document.getElementById('incognito-btn');
|
||||
@@ -82,6 +123,7 @@ export function initSidebarLayout(Storage, opts) {
|
||||
if (Storage.get(Storage.KEYS.SIDEBAR_SIDE) === 'right') {
|
||||
document.getElementById('sidebar').classList.add('right-side');
|
||||
}
|
||||
_applyStoredSidebarMode();
|
||||
syncRailSide();
|
||||
|
||||
// In-sidebar toggle button — same behavior as hamburger
|
||||
@@ -154,6 +196,7 @@ export function initSidebarLayout(Storage, opts) {
|
||||
if (isSidebarVisible) {
|
||||
// Closing sidebar
|
||||
sidebar.classList.add('hidden');
|
||||
_saveSidebarMode('off');
|
||||
if (backdrop) backdrop.classList.remove('visible');
|
||||
} else {
|
||||
// Mobile: the hamburger always opens the sidebar from the RIGHT.
|
||||
@@ -169,11 +212,13 @@ export function initSidebarLayout(Storage, opts) {
|
||||
// Wait for keyboard dismiss to settle, then open
|
||||
setTimeout(() => {
|
||||
sidebar.classList.remove('hidden');
|
||||
_saveSidebarMode('full');
|
||||
if (backdrop) backdrop.classList.add('visible');
|
||||
syncRailSide();
|
||||
}, 250);
|
||||
} else {
|
||||
sidebar.classList.remove('hidden');
|
||||
_saveSidebarMode('full');
|
||||
if (backdrop) backdrop.classList.add('visible');
|
||||
}
|
||||
}
|
||||
@@ -184,10 +229,13 @@ export function initSidebarLayout(Storage, opts) {
|
||||
// Desktop: full sidebar ↔ mini (icon rail) — simple toggle
|
||||
if (isSidebarVisible) {
|
||||
sidebar.classList.add('hidden');
|
||||
if (iconRail) iconRail.classList.remove('rail-hidden');
|
||||
_saveSidebarMode('mini');
|
||||
} else {
|
||||
_wasAutoCollapsed = false;
|
||||
iconRail.classList.remove('rail-hidden');
|
||||
sidebar.classList.remove('hidden');
|
||||
_saveSidebarMode('full');
|
||||
}
|
||||
syncRailSide();
|
||||
});
|
||||
@@ -484,7 +532,7 @@ function _initChatSwipeToOpenSidebar() {
|
||||
|
||||
// Areas where a horizontal drag means something else (their own scroll/drag).
|
||||
const EXCLUDE = [
|
||||
'#sidebar', '#icon-rail', '.modal', '.input-bar', '#message',
|
||||
'#sidebar', '#icon-rail', '.modal', '.input-bar', '.chat-input-bar', '#message',
|
||||
'#minimized-dock', '.minimized-dock-chip', '#dock-trash-zone',
|
||||
'pre', 'table', '.agent-tool-output', '.agent-thread-cmd',
|
||||
'input', 'textarea', 'select',
|
||||
|
||||
@@ -16,7 +16,7 @@ import modelsModule from './models.js';
|
||||
import chatRenderer from './chatRenderer.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import themeModule from './theme.js';
|
||||
import documentModule from './document.js';
|
||||
import documentModule from './document.js?v=20260722emailfastindex1';
|
||||
import workspaceModule from './workspace.js';
|
||||
import settingsModule from './settings.js';
|
||||
import cookbookModule from './cookbook.js';
|
||||
@@ -287,8 +287,14 @@ function _setupProviderPrompt() {
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Persist a message to the current session (fire-and-forget) */
|
||||
function _persistMsg(role, content, metadata) {
|
||||
const sid = sessionModule.getCurrentSessionId();
|
||||
async function _persistMsg(role, content, metadata) {
|
||||
let sid = sessionModule.getCurrentSessionId();
|
||||
if (!sid && sessionModule.hasPendingChat?.()) {
|
||||
try {
|
||||
await sessionModule.materializePendingSession?.();
|
||||
sid = sessionModule.getCurrentSessionId();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!sid || !content) return;
|
||||
const payload = { role, content };
|
||||
if (metadata) payload.metadata = metadata;
|
||||
@@ -300,6 +306,7 @@ function _persistMsg(role, content, metadata) {
|
||||
}
|
||||
|
||||
function slashReply(text) {
|
||||
_hideWelcomeScreen();
|
||||
const chatBox = document.getElementById('chat-history');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg msg-ai';
|
||||
@@ -1262,7 +1269,7 @@ async function _cmdWorkspace(args, ctx) {
|
||||
// folder, sensitive dir, filesystem root).
|
||||
workspaceModule.vetAndSetWorkspace(rest).then(({ ok, path }) => {
|
||||
if (ok) slashReply(`Workspace set: <code>${uiModule.esc(path)}</code>`);
|
||||
else slashReply(`Not a usable workspace folder: <code>${uiModule.esc(rest)}</code>. It must be an existing directory, not a filesystem root or sensitive path.`);
|
||||
else slashReply(`Not a usable workspace folder on the Odysseus backend: <code>${uiModule.esc(rest)}</code>. If Odysseus is running in Docker, use the container path, usually <code>/app</code>, or use <code>/workspace pick</code>.`);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
+24
-40
@@ -152,7 +152,7 @@ class Spinner {
|
||||
|
||||
this._wpCanvas = canvas;
|
||||
this._wpCtx = canvas.getContext('2d');
|
||||
this._wpFrame = 60;
|
||||
this._wpStartedAt = null;
|
||||
this.element = wrapper;
|
||||
return wrapper;
|
||||
}
|
||||
@@ -164,12 +164,12 @@ class Spinner {
|
||||
const cx = W / 2, cy = H / 2;
|
||||
const maxR = Math.min(W, H) / 2 - 1;
|
||||
const lw = W > 30 ? 3 : W > 20 ? 2 : 1.5;
|
||||
const TOTAL_TURNS = 4;
|
||||
const TAIL_LEN = 0.45;
|
||||
const SPIN_SPEED = 0.08;
|
||||
const LAYERS = 12;
|
||||
const STEPS = 50;
|
||||
const t = this._wpFrame;
|
||||
const TOTAL_TURNS = 2.7;
|
||||
const STEPS = 84;
|
||||
const LOOP_MS = 1100;
|
||||
if (!this._wpStartedAt) this._wpStartedAt = performance.now();
|
||||
const loop = ((performance.now() - this._wpStartedAt) % LOOP_MS) / LOOP_MS;
|
||||
const rot = loop * Math.PI * 2;
|
||||
|
||||
// Colors from CSS vars — read ONCE and cache. Calling getComputedStyle every
|
||||
// frame forces a full style recalc per frame, which janks/freezes the canvas
|
||||
@@ -185,8 +185,9 @@ class Spinner {
|
||||
const fg = this._wpColors.fg;
|
||||
const track = this._wpColors.track;
|
||||
|
||||
function spiralPoint(frac, rot) {
|
||||
const r = maxR * (1 - frac);
|
||||
function spiralPoint(frac) {
|
||||
const eased = Math.pow(frac, 0.82);
|
||||
const r = maxR * eased;
|
||||
const angle = frac * TOTAL_TURNS * Math.PI * 2 + rot;
|
||||
return { x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r };
|
||||
}
|
||||
@@ -202,49 +203,32 @@ class Spinner {
|
||||
ctx.stroke();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
const headPos = (t * 0.008) % 1;
|
||||
|
||||
// overlapping sub-paths for smooth fade
|
||||
// Rotating a single continuous spiral keeps the loop seamless: the start
|
||||
// and end frames are the same shape, just one full turn apart.
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
for (let layer = LAYERS - 1; layer >= 0; layer--) {
|
||||
const endFrac = (layer + 1) / LAYERS;
|
||||
const stepsForLayer = Math.ceil(STEPS * endFrac);
|
||||
const alpha = Math.pow(1 - endFrac, 2) * 0.7;
|
||||
|
||||
for (let i = 1; i <= STEPS; i++) {
|
||||
const a = (i - 1) / STEPS;
|
||||
const b = i / STEPS;
|
||||
const p0 = spiralPoint(a);
|
||||
const p1 = spiralPoint(b);
|
||||
ctx.beginPath();
|
||||
let started = false;
|
||||
let prevPos = -1;
|
||||
for (let i = 0; i <= stepsForLayer; i++) {
|
||||
const frac = i / STEPS;
|
||||
let pos = headPos - frac * TAIL_LEN;
|
||||
if (pos < 0) pos += 1;
|
||||
if (started && prevPos < 0.3 && pos > 0.7) {
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
started = false;
|
||||
}
|
||||
const pt = spiralPoint(pos, t * SPIN_SPEED);
|
||||
if (!started) { ctx.moveTo(pt.x, pt.y); started = true; }
|
||||
else ctx.lineTo(pt.x, pt.y);
|
||||
prevPos = pos;
|
||||
}
|
||||
ctx.moveTo(p0.x, p0.y);
|
||||
ctx.lineTo(p1.x, p1.y);
|
||||
ctx.strokeStyle = fg;
|
||||
ctx.lineWidth = lw * 0.8;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.lineWidth = lw * (0.52 + b * 0.32);
|
||||
ctx.globalAlpha = 0.12 + Math.pow(b, 1.8) * 0.72;
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// bright dot at head
|
||||
const head = spiralPoint(headPos, t * SPIN_SPEED);
|
||||
const head = spiralPoint(1);
|
||||
ctx.beginPath();
|
||||
ctx.arc(head.x, head.y, Math.max(1, lw * 0.45), 0, Math.PI * 2);
|
||||
ctx.arc(head.x, head.y, Math.max(1.05, lw * 0.48), 0, Math.PI * 2);
|
||||
ctx.fillStyle = fg;
|
||||
ctx.globalAlpha = 0.9;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
this._wpFrame++;
|
||||
if (!this.isRunning) return;
|
||||
// Leak-safe self-terminate: stop once our element WAS in the DOM and then
|
||||
// got removed (e.g. a loading row replaced by results). But keep spinning
|
||||
@@ -293,7 +277,7 @@ class Spinner {
|
||||
}
|
||||
|
||||
if (this.animation === 'whirlpool') {
|
||||
this._wpFrame = 60;
|
||||
this._wpStartedAt = performance.now();
|
||||
this._drawWhirlpool();
|
||||
return;
|
||||
}
|
||||
|
||||
+274
-36
@@ -20,6 +20,8 @@ let _escHandler = null;
|
||||
let _viewingRuns = null; // task id when viewing run history
|
||||
let _clockInterval = null;
|
||||
let _taskFailurePending = false;
|
||||
let _taskCompletionPending = false;
|
||||
let _taskBulkDeleting = false;
|
||||
|
||||
const DAYS_OF_WEEK = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
|
||||
@@ -29,6 +31,12 @@ function _setTaskFailurePending(active) {
|
||||
document.getElementById('rail-tasks')?.classList.toggle('task-failure-pending', _taskFailurePending);
|
||||
}
|
||||
|
||||
function _setTaskCompletionPending(active) {
|
||||
_taskCompletionPending = !!active;
|
||||
document.getElementById('tool-tasks-btn')?.classList.toggle('task-completion-pending', _taskCompletionPending);
|
||||
document.getElementById('rail-tasks')?.classList.toggle('task-completion-pending', _taskCompletionPending);
|
||||
}
|
||||
|
||||
// ---- API ----
|
||||
|
||||
async function _fetchTasks() {
|
||||
@@ -105,6 +113,25 @@ function _animateTaskRemoval(ids) {
|
||||
return new Promise(resolve => setTimeout(resolve, 520));
|
||||
}
|
||||
|
||||
function _setTaskCardsDeleting(ids, active) {
|
||||
for (const id of ids) {
|
||||
const card = _taskCardById(id);
|
||||
if (!card) continue;
|
||||
card.classList.toggle('task-card-deleting', !!active);
|
||||
const existing = card.querySelector('.task-card-delete-busy');
|
||||
if (!active) {
|
||||
existing?.remove();
|
||||
continue;
|
||||
}
|
||||
if (!existing) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'task-card-delete-busy';
|
||||
badge.innerHTML = '<span class="task-card-delete-busy-label">Deleting</span><span class="task-card-delete-busy-spin" aria-hidden="true"></span>';
|
||||
card.appendChild(badge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function _pauseTask(id) {
|
||||
const res = await fetch(`${API_BASE}/api/tasks/${id}/pause`, {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
@@ -669,17 +696,65 @@ function _taskUpdateBulkCount() {
|
||||
}
|
||||
async function _taskBulkDelete() {
|
||||
const ids = [..._taskSelected];
|
||||
if (!ids.length) return;
|
||||
if (!ids.length || _taskBulkDeleting) return;
|
||||
const ok = uiModule?.styledConfirm
|
||||
? await uiModule.styledConfirm(`Delete ${ids.length} task${ids.length > 1 ? 's' : ''}? This cannot be undone.`, { confirmText: 'Delete', danger: true })
|
||||
: confirm(`Delete ${ids.length} task(s)?`);
|
||||
if (!ok) return;
|
||||
const results = await Promise.allSettled(ids.map(id => _deleteTask(id)));
|
||||
const deletedIds = ids.filter((_, i) => results[i].status === 'fulfilled');
|
||||
await _animateTaskRemoval(deletedIds);
|
||||
if (uiModule) uiModule.showToast(`Deleted ${deletedIds.length} task${deletedIds.length > 1 ? 's' : ''}`);
|
||||
await _fetchTasks();
|
||||
_taskExitSelect(); // clears selection + re-renders the fresh list
|
||||
_taskBulkDeleting = true;
|
||||
const countEl = document.getElementById('tasks-selected-count');
|
||||
const deleteBtn = document.getElementById('tasks-bulk-delete');
|
||||
const cancelBtn = document.getElementById('tasks-bulk-cancel');
|
||||
const selectAll = document.getElementById('tasks-select-all');
|
||||
const originalDeleteHtml = deleteBtn?.innerHTML || '';
|
||||
let busySpinner = null;
|
||||
if (deleteBtn) {
|
||||
deleteBtn.disabled = true;
|
||||
deleteBtn.classList.add('tasks-bulk-loading');
|
||||
deleteBtn.innerHTML = '<span class="tasks-bulk-loading-label">Deleting</span>';
|
||||
busySpinner = spinnerModule.create('', 'clean', 'whirlpool');
|
||||
const spEl = busySpinner.createElement();
|
||||
spEl.classList.add('tasks-bulk-whirlpool');
|
||||
deleteBtn.appendChild(spEl);
|
||||
busySpinner.start();
|
||||
}
|
||||
if (cancelBtn) cancelBtn.disabled = true;
|
||||
if (selectAll) selectAll.disabled = true;
|
||||
if (countEl) countEl.textContent = `Deleting 0/${ids.length}…`;
|
||||
_setTaskCardsDeleting(ids, true);
|
||||
const deletedIds = [];
|
||||
let finished = 0;
|
||||
try {
|
||||
const results = await Promise.allSettled(ids.map(async (id) => {
|
||||
try {
|
||||
await _deleteTask(id);
|
||||
deletedIds.push(id);
|
||||
} finally {
|
||||
finished += 1;
|
||||
if (countEl) countEl.textContent = `Deleting ${finished}/${ids.length}…`;
|
||||
}
|
||||
}));
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
await _animateTaskRemoval(deletedIds);
|
||||
if (uiModule) {
|
||||
const msg = failed
|
||||
? `Deleted ${deletedIds.length}, failed ${failed}`
|
||||
: `Deleted ${deletedIds.length} task${deletedIds.length > 1 ? 's' : ''}`;
|
||||
uiModule.showToast(msg);
|
||||
}
|
||||
await _fetchTasks();
|
||||
} finally {
|
||||
_setTaskCardsDeleting(ids, false);
|
||||
if (busySpinner) busySpinner.destroy();
|
||||
if (deleteBtn) {
|
||||
deleteBtn.classList.remove('tasks-bulk-loading');
|
||||
deleteBtn.innerHTML = originalDeleteHtml || deleteBtn.innerHTML;
|
||||
}
|
||||
if (cancelBtn) cancelBtn.disabled = false;
|
||||
if (selectAll) selectAll.disabled = false;
|
||||
_taskBulkDeleting = false;
|
||||
_taskExitSelect(); // clears selection + re-renders the fresh list
|
||||
}
|
||||
}
|
||||
|
||||
// Category filter chips (library-style tags) — solo-select: click one to
|
||||
@@ -1937,10 +2012,93 @@ function _switchTab(tab) {
|
||||
b.classList.toggle('active', on);
|
||||
});
|
||||
if (tab === 'tasks') _renderMainView();
|
||||
else if (tab === 'completed') _renderCompletedView();
|
||||
else if (tab === 'activity') _renderActivityView();
|
||||
else if (tab === 'new') _showPresetPicker();
|
||||
}
|
||||
|
||||
function _runToActivityEntry(r) {
|
||||
let resultText = r.result || r.error || '';
|
||||
if (!resultText) {
|
||||
if (r.status === 'queued') resultText = '_Queued — waiting for a free slot…_';
|
||||
if (r.status === 'running') resultText = '_Running…_';
|
||||
}
|
||||
return {
|
||||
kind: r.task_type || 'llm',
|
||||
taskName: r.task_name || (r.task_type === 'action' ? (r.action || 'Action') : 'Task'),
|
||||
taskId: r.task_id,
|
||||
action: r.action || '',
|
||||
result: resultText,
|
||||
prompt: '',
|
||||
ts: r.finished_at || r.started_at,
|
||||
status: r.status,
|
||||
model: r.model || '',
|
||||
endpointUrl: r.endpoint_url || '',
|
||||
sessionId: r.session_id || '',
|
||||
researchId: r.research_id || '',
|
||||
output_target: r.output_target || 'session',
|
||||
};
|
||||
}
|
||||
|
||||
function _isFinishedRun(entry) {
|
||||
return !['queued', 'running', 'skipped'].includes(entry.status || '');
|
||||
}
|
||||
|
||||
function _isChatResultRun(entry) {
|
||||
return _isFinishedRun(entry)
|
||||
&& (entry.kind === 'llm' || entry.kind === 'research')
|
||||
&& !!(entry.result || '').trim();
|
||||
}
|
||||
|
||||
async function _renderCompletedView() {
|
||||
_setTaskCompletionPending(false);
|
||||
const modal = document.getElementById('tasks-modal');
|
||||
const body = modal?.querySelector('.modal-body');
|
||||
if (!body) return;
|
||||
body.innerHTML = `
|
||||
<div class="admin-card tasks-activity-card tasks-completed-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;min-height:0;">
|
||||
<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;">
|
||||
<h2 style="margin:0;padding:0;line-height:1;">Completed</h2>
|
||||
<button class="memory-toolbar-btn" id="tasks-completed-refresh" title="Refresh" style="margin-left:auto;"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;"><path d="M1 4v6h6"/><path d="M23 20v-6h-6"/><path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/></svg></button>
|
||||
</div>
|
||||
<p class="memory-desc">Completed assistant/research outputs you can open in chat.</p>
|
||||
<div id="tasks-completed-list" class="memory-list tasks-activity-list tasks-completed-list" style="flex:1;overflow:auto;font-size:13px;min-height:0;"></div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('tasks-completed-refresh')?.addEventListener('click', _renderCompletedView);
|
||||
const list = document.getElementById('tasks-completed-list');
|
||||
list?.appendChild(spinnerModule.createLoadingRow('Loading…'));
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/tasks/runs/recent?limit=${_completedLimit}&max_result_chars=10000`, { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
const finished = (data.runs || []).map(_runToActivityEntry).filter(_isChatResultRun);
|
||||
_completedHasMore = !!data.has_more && _completedLimit < 200;
|
||||
_activityEntries = finished;
|
||||
_syncCompletedTabCount(finished.length);
|
||||
if (!list) return;
|
||||
if (finished.length === 0) {
|
||||
list.innerHTML = '<div class="doclib-empty task-completed-empty">No completed task outputs yet.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = finished.map(_renderCompletedPreviewEntry).join('');
|
||||
if (_completedHasMore) {
|
||||
list.insertAdjacentHTML('beforeend', `
|
||||
<button type="button" class="memory-toolbar-btn tasks-activity-load-more" id="tasks-completed-load-more" style="width:100%;justify-content:center;margin-top:6px;">
|
||||
Load more
|
||||
</button>
|
||||
`);
|
||||
list.querySelector('#tasks-completed-load-more')?.addEventListener('click', () => {
|
||||
_completedLimit = Math.min(200, _completedLimit + 40);
|
||||
_renderCompletedView();
|
||||
});
|
||||
}
|
||||
_wireCompletedPreviewRows(list);
|
||||
} catch (e) {
|
||||
if (list) list.innerHTML = `<div style="opacity:0.5;padding:12px;">Failed to load completed tasks: ${_escHtml(e.message || String(e))}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Activity view (assistant session log) ----
|
||||
|
||||
async function _renderActivityView() {
|
||||
@@ -2082,32 +2240,8 @@ async function _renderActivityView() {
|
||||
list.innerHTML = '<div style="opacity:0.5;padding:12px;">No activity yet. Scheduled tasks will log here once they run.</div>';
|
||||
return;
|
||||
}
|
||||
_activityEntries = runs.map(r => {
|
||||
let resultText = r.result || r.error || '';
|
||||
if (!resultText) {
|
||||
if (r.status === 'queued') resultText = '_Queued — waiting for a free slot…_';
|
||||
if (r.status === 'running') resultText = '_Running…_';
|
||||
}
|
||||
return {
|
||||
// Surface the actual task_type ('llm' | 'research' | 'action') so the
|
||||
// chat-worthy check in _renderActivityEntry can decide between "Open
|
||||
// in chat" (llm/research) and "Copy log" (action). Was hardcoded
|
||||
// 'task', which never matched and made Open-in-chat dead code.
|
||||
kind: r.task_type || 'llm',
|
||||
taskName: r.task_name || (r.task_type === 'action' ? (r.action || 'Action') : 'Task'),
|
||||
taskId: r.task_id,
|
||||
action: r.action || '',
|
||||
result: resultText,
|
||||
prompt: '',
|
||||
ts: r.finished_at || r.started_at,
|
||||
status: r.status,
|
||||
model: r.model || '',
|
||||
endpointUrl: r.endpoint_url || '',
|
||||
sessionId: r.session_id || '',
|
||||
researchId: r.research_id || '',
|
||||
output_target: r.output_target || 'session',
|
||||
};
|
||||
});
|
||||
_activityEntries = runs.map(_runToActivityEntry);
|
||||
_syncCompletedTabCount(_activityEntries.filter(_isChatResultRun).length);
|
||||
_buildChips();
|
||||
_applyFilter();
|
||||
} catch (e) {
|
||||
@@ -2119,6 +2253,13 @@ async function _renderActivityView() {
|
||||
let _activityEntries = [];
|
||||
let _activityLimit = 40;
|
||||
let _activityHasMore = false;
|
||||
let _completedLimit = 40;
|
||||
let _completedHasMore = false;
|
||||
|
||||
function _syncCompletedTabCount(count) {
|
||||
const el = document.getElementById('tasks-completed-tab-count');
|
||||
if (el) el.textContent = String(count || 0);
|
||||
}
|
||||
|
||||
function _stackActivityEntries(entries) {
|
||||
const out = [];
|
||||
@@ -2291,6 +2432,86 @@ function _wireActivityRows(list) {
|
||||
});
|
||||
}
|
||||
|
||||
function _renderCompletedPreviewEntry(entry) {
|
||||
const entryIdx = _activityEntries.indexOf(entry);
|
||||
const tsLabel = _relativeTime(entry.ts);
|
||||
const tsAbs = entry.ts ? new Date(entry.ts).toLocaleString() : '';
|
||||
const modelTag = entry.model
|
||||
? `<span class="doclib-chat-msg-model">${_escHtml(entry.model.split('/').pop())}</span>`
|
||||
: '';
|
||||
const raw = (entry.result || '').trim();
|
||||
const truncated = raw.length > 1400 ? raw.slice(0, 1400) + '…' : raw;
|
||||
const cleaned = truncated
|
||||
.replace(/<think>[\s\S]*?<\/think>/g, '')
|
||||
.replace(/<think>[\s\S]*$/, '')
|
||||
.trim();
|
||||
let body;
|
||||
try {
|
||||
body = markdownModule.mdToHtml(cleaned);
|
||||
} catch {
|
||||
body = _escHtml(cleaned);
|
||||
}
|
||||
const title = _escHtml(entry.taskName || 'Task');
|
||||
const time = `<span class="task-log-time" title="${_escHtml(tsAbs)}">${_escHtml(tsLabel)}</span>`;
|
||||
return `
|
||||
<div class="memory-item doclib-chat-row task-completed-preview-row" data-entry-idx="${entryIdx}">
|
||||
<div class="doclib-chat-header task-completed-preview-head">
|
||||
<span class="task-log-task-icon">${_taskIcon({ action: entry.action, task_type: entry.kind })}</span>
|
||||
<span class="task-log-name">${title}</span>${_taskAiMark(entry)}
|
||||
<span style="flex:1"></span>
|
||||
${time}
|
||||
</div>
|
||||
<div class="doclib-chat-preview task-completed-chat-preview" style="display:block;">
|
||||
<div class="doclib-chat-preview-messages">
|
||||
<div class="doclib-chat-bubble-row assistant">
|
||||
<div class="doclib-chat-bubble">
|
||||
${modelTag}
|
||||
<div class="doclib-chat-bubble-body">${body}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doclib-chat-preview-actions">
|
||||
<button class="doclib-chat-copy-btn task-completed-copy-btn" type="button">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Copy
|
||||
</button>
|
||||
<button class="doclib-chat-open-btn task-completed-open-chat" type="button">
|
||||
<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="M5 12h14M13 5l7 7-7 7"/></svg>
|
||||
Open chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _wireCompletedPreviewRows(list) {
|
||||
list.querySelectorAll('.task-completed-open-chat').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const row = btn.closest('.task-completed-preview-row');
|
||||
const idx = parseInt(row?.dataset.entryIdx || '-1', 10);
|
||||
const entry = _activityEntries[idx];
|
||||
if (entry) _openResultInChat(entry);
|
||||
});
|
||||
});
|
||||
list.querySelectorAll('.task-completed-copy-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const row = btn.closest('.task-completed-preview-row');
|
||||
const idx = parseInt(row?.dataset.entryIdx || '-1', 10);
|
||||
const entry = _activityEntries[idx];
|
||||
if (!entry) return;
|
||||
try {
|
||||
uiModule.copyToClipboard((entry.result || '').trim());
|
||||
uiModule.showToast('Output copied');
|
||||
} catch (_) {
|
||||
uiModule.showError('Copy failed');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Open a task run's result in a fresh chat session so it's comfortable
|
||||
// to read full-width and the user can ask follow-ups.
|
||||
async function _openResultInChat(entry) {
|
||||
@@ -2422,7 +2643,7 @@ function _categoryLabel(taskName) {
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function _renderActivityEntry(entry) {
|
||||
function _renderActivityEntry(entry, opts = {}) {
|
||||
// Canonical index into _activityEntries (map() passes the FILTERED
|
||||
// index, which would be wrong) — used by the Open-in-chat handler.
|
||||
const entryIdx = Number.isInteger(entry.sourceIdx) ? entry.sourceIdx : _activityEntries.indexOf(entry);
|
||||
@@ -2568,7 +2789,7 @@ function _renderActivityEntry(entry) {
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<div class="task-log-row${rowStatusClass}${long ? ' is-long' : ''}${_isRunning ? ' is-running' : ''}" data-kind="${_escHtml(entry.kind)}" data-entry-idx="${entryIdx}" style="${styleVars}">
|
||||
<div class="task-log-row${rowStatusClass}${long ? ' is-long' : ''}${_isRunning ? ' is-running' : ''}${opts.expanded ? ' expanded' : ''}" data-kind="${_escHtml(entry.kind)}" data-entry-idx="${entryIdx}" style="${styleVars}">
|
||||
<div class="task-log-row-head">
|
||||
${statusDot}
|
||||
<span class="task-log-task-icon">${_taskIcon({ action: entry.action, task_type: entry.kind })}</span>
|
||||
@@ -2715,10 +2936,13 @@ export function openTasks(focusId, opts) {
|
||||
startNotificationPolling();
|
||||
const o = opts || {};
|
||||
const openActivityForFailure = _taskFailurePending && !focusId && o.filter === undefined;
|
||||
const openCompletedForNotification = _taskCompletionPending && !focusId && o.filter === undefined;
|
||||
_setTaskFailurePending(false);
|
||||
_setTaskCompletionPending(false);
|
||||
if (_open) {
|
||||
// Already open — just focus the requested task / apply filter.
|
||||
if (openActivityForFailure) _switchTab('activity');
|
||||
else if (openCompletedForNotification) _switchTab('completed');
|
||||
if (o.filter !== undefined) { _taskFilter = o.filter; _renderList(); }
|
||||
if (focusId) _focusTask(focusId);
|
||||
return;
|
||||
@@ -2751,6 +2975,10 @@ export function openTasks(focusId, opts) {
|
||||
<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:5px"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||
Activity
|
||||
</button>
|
||||
<button class="memory-tab tasks-tab" data-tab="completed" role="tab" aria-selected="false">
|
||||
<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:5px"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
Completed <span id="tasks-completed-tab-count" class="memory-count" style="font-size:0.8em;opacity:0.6;font-weight:normal;margin-left:4px">0</span>
|
||||
</button>
|
||||
<button class="memory-tab tasks-tab" data-tab="new" role="tab" aria-selected="false">
|
||||
<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:5px"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
|
||||
Add
|
||||
@@ -2825,7 +3053,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(openActivityForFailure ? 'activity' : 'tasks');
|
||||
_switchTab(openActivityForFailure ? 'activity' : openCompletedForNotification ? 'completed' : 'tasks');
|
||||
_fetchTasks().then(() => {
|
||||
// Re-render so the list swaps the Loading row for real cards.
|
||||
_renderList();
|
||||
@@ -2901,6 +3129,15 @@ async function _pollTaskNotifications() {
|
||||
const notes = data.notifications || [];
|
||||
for (const n of notes) {
|
||||
const ok = n.status === 'success';
|
||||
if (ok) {
|
||||
const completedOpen = _open && document.querySelector('.tasks-tab.active[data-tab="completed"]');
|
||||
if (completedOpen) {
|
||||
_setTaskCompletionPending(false);
|
||||
_renderCompletedView();
|
||||
} else {
|
||||
_setTaskCompletionPending(true);
|
||||
}
|
||||
}
|
||||
// Tasks with output_target='notification' carry the result text in `body`
|
||||
// — show it as a real browser Notification (richer than a toast). Falls
|
||||
// back to a toast when permission is denied or unavailable.
|
||||
@@ -2934,6 +3171,7 @@ async function _pollTaskNotifications() {
|
||||
|
||||
function startNotificationPolling() {
|
||||
if (_notifInterval) return;
|
||||
setTimeout(_pollTaskNotifications, 1500);
|
||||
_notifInterval = setInterval(_pollTaskNotifications, 30000);
|
||||
}
|
||||
|
||||
|
||||
+24
-6
@@ -577,9 +577,11 @@ export function el(id) {
|
||||
|
||||
/**
|
||||
* Styled confirm dialog — replaces native browser confirm().
|
||||
* Returns a Promise<boolean>.
|
||||
* Returns a Promise<boolean|'alternate'>. Existing two-button callers only
|
||||
* receive true/false; callers that pass alternateText can detect the third
|
||||
* action via the string 'alternate'.
|
||||
*/
|
||||
export function styledConfirm(message, { confirmText = 'Confirm', cancelText = 'Cancel', danger = false } = {}) {
|
||||
export function styledConfirm(message, { confirmText = 'Confirm', cancelText = 'Cancel', alternateText = '', title = 'Confirm', danger = false } = {}) {
|
||||
return new Promise(resolve => {
|
||||
// Reuse or create the modal
|
||||
let overlay = document.getElementById('styled-confirm-overlay');
|
||||
@@ -593,6 +595,7 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
||||
'<div class="modal-body"><p id="styled-confirm-msg"></p></div>' +
|
||||
'<div class="modal-footer">' +
|
||||
'<button id="styled-confirm-cancel"></button>' +
|
||||
'<button id="styled-confirm-alt" style="display:none;"></button>' +
|
||||
'<button id="styled-confirm-ok"></button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
@@ -600,14 +603,25 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
||||
}
|
||||
|
||||
const msgEl = document.getElementById('styled-confirm-msg');
|
||||
const titleEl = document.getElementById('styled-confirm-title');
|
||||
const okBtn = document.getElementById('styled-confirm-ok');
|
||||
const cancelBtn = document.getElementById('styled-confirm-cancel');
|
||||
let altBtn = document.getElementById('styled-confirm-alt');
|
||||
if (!altBtn) {
|
||||
altBtn = document.createElement('button');
|
||||
altBtn.id = 'styled-confirm-alt';
|
||||
okBtn.parentNode.insertBefore(altBtn, okBtn);
|
||||
}
|
||||
|
||||
if (titleEl) titleEl.textContent = title || 'Confirm';
|
||||
msgEl.textContent = message;
|
||||
okBtn.textContent = confirmText;
|
||||
cancelBtn.textContent = cancelText;
|
||||
altBtn.textContent = alternateText || '';
|
||||
okBtn.className = danger ? 'confirm-btn confirm-btn-danger' : 'confirm-btn confirm-btn-primary';
|
||||
cancelBtn.className = 'confirm-btn confirm-btn-secondary';
|
||||
altBtn.className = 'confirm-btn confirm-btn-secondary';
|
||||
altBtn.style.display = alternateText ? '' : 'none';
|
||||
|
||||
// Remember what had focus so we can restore it when the dialog closes.
|
||||
const _prevFocus = document.activeElement;
|
||||
@@ -619,20 +633,23 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
||||
overlay.style.display = 'none';
|
||||
okBtn.removeEventListener('click', onOk);
|
||||
cancelBtn.removeEventListener('click', onCancel);
|
||||
altBtn.removeEventListener('click', onAlt);
|
||||
overlay.removeEventListener('click', onBackdrop);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
try { _prevFocus && _prevFocus.focus && _prevFocus.focus(); } catch {}
|
||||
resolve(result);
|
||||
}
|
||||
function onOk() { cleanup(true); }
|
||||
function onAlt() { cleanup('alternate'); }
|
||||
function onCancel() { cleanup(false); }
|
||||
function onBackdrop(e) { if (e.target === overlay) cleanup(false); }
|
||||
function onKey(e) {
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
const active = document.activeElement;
|
||||
if (active === okBtn) cancelBtn.focus();
|
||||
else okBtn.focus();
|
||||
const f = alternateText ? [cancelBtn, altBtn, okBtn] : [cancelBtn, okBtn];
|
||||
const i = f.indexOf(document.activeElement);
|
||||
const dir = e.key === 'ArrowRight' ? 1 : -1;
|
||||
f[(i + dir + f.length) % f.length].focus();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -641,7 +658,7 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
||||
} else if (e.key === 'Tab') {
|
||||
// Trap focus inside the dialog so Tab can't wander to the page behind.
|
||||
e.preventDefault();
|
||||
const f = [cancelBtn, okBtn];
|
||||
const f = alternateText ? [cancelBtn, altBtn, okBtn] : [cancelBtn, okBtn];
|
||||
const i = f.indexOf(document.activeElement);
|
||||
const n = e.shiftKey ? (i <= 0 ? f.length - 1 : i - 1) : (i >= f.length - 1 ? 0 : i + 1);
|
||||
f[n].focus();
|
||||
@@ -649,6 +666,7 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
||||
}
|
||||
|
||||
okBtn.addEventListener('click', onOk);
|
||||
altBtn.addEventListener('click', onAlt);
|
||||
cancelBtn.addEventListener('click', onCancel);
|
||||
overlay.addEventListener('click', onBackdrop);
|
||||
document.addEventListener('keydown', onKey);
|
||||
|
||||
Reference in New Issue
Block a user