Merge verified Odysseus fixes

This commit is contained in:
pewdiepie-archdaemon
2026-07-23 14:49:02 +00:00
parent 4c9a8ca115
commit d8a2059df8
117 changed files with 15693 additions and 3191 deletions
+290 -108
View File
@@ -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&note=/.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 };