Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import * as researchPanelModule from './js/research/panel.js?v=20260630researcht
import ttsModule from './js/tts-ai.js';
import spinnerModule from './js/spinner.js';
import { initKeyboardShortcuts } from './js/keyboard-shortcuts.js';
import { getSettings } from './js/appConfig.js';
import { initSidebarLayout, syncRailSide } from './js/sidebar-layout.js?v=20260715startupclean';
import { initSectionCollapse, initSectionDrag } from './js/section-management.js';

Expand Down Expand Up @@ -1518,13 +1519,11 @@ function initializeEventListeners() {
})
.catch(() => {});

// Hide Gallery when image generation is disabled in settings
const _prefetchedSettings = sessionStorage.getItem('ody-prefetch-settings');
sessionStorage.removeItem('ody-prefetch-settings');
window._initSettingsReady = (_prefetchedSettings
? Promise.resolve(JSON.parse(_prefetchedSettings))
: fetch(`${API_BASE}/api/auth/settings`, { credentials: 'same-origin' }).then(r => r.json())
).then(settings => {
// Hide Gallery when image generation is disabled in settings.
// getSettings() consumes the login prefetch itself, so every other module
// that asks for settings this load gets the same snapshot without a request.
window._initSettingsReady = getSettings()
.then(settings => {
// NOTE: image_gen_enabled only governs *generating* images in chat — the
// tool is blocked server-side (chat_routes / agent_loop). The Gallery
// holds uploads and past images too, so it stays visible regardless;
Expand Down Expand Up @@ -3705,7 +3704,7 @@ function startOdysseusApp() {
modelsModule.init(API_BASE);
ragModule.init(API_BASE);
presetsModule.init(API_BASE);
searchModule.init(API_BASE);
searchModule.init();
chatModule.init(API_BASE);
chatModule.initListeners();
groupModule.init(API_BASE);
Expand Down
82 changes: 65 additions & 17 deletions static/js/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import settingsModule from './settings.js';
import { providerLogo, providerLogoFromUrl } from './providers.js';
import { sortModelObjects } from './modelSort.js';
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
import { getSettings, getTools, invalidateSettings, invalidateTools } from './appConfig.js';

let initialized = false;
let modalEl = null;
Expand Down Expand Up @@ -345,8 +346,7 @@ function initSignupToggle() {

function initShareDefaultsToggle() {
const toggle = el('adm-shareDefaultsToggle');
fetch('/api/auth/settings', { credentials: 'same-origin' })
.then(r => r.json())
getSettings()
.then(d => { toggle.checked = !!d.share_defaults_with_users; })
.catch(e => console.warn('Settings fetch failed:', e));
toggle.addEventListener('change', async () => {
Expand All @@ -361,6 +361,9 @@ function initShareDefaultsToggle() {
toggle.checked = !!data.share_defaults_with_users;
} catch (e) {
toggle.checked = !toggle.checked;
} finally {
// Drop the shared snapshot: it still says what this toggle used to be.
invalidateSettings();
}
});
}
Expand Down Expand Up @@ -1893,8 +1896,16 @@ async function loadBuiltinTools() {
const list = el('adm-builtin-tools-list');
if (!list) return;
try {
const res = await fetch('/api/tools', { credentials: 'same-origin' });
const data = await res.json();
// This panel is an editor, and its save posts the whole disabled list
// rebuilt from the checkboxes below. So it has to render authoritative
// state: a snapshot that went stale out of band (the manage_settings tool,
// another tab) would be re-posted wholesale on the next unrelated toggle
// and would silently undo the newer state. refreshAll() calls this on every
// panel open, so drop the shared entry and refill it. The startup read that
// chatRenderer.js shares is unaffected; this panel just never edits a cache,
// which is the same rule the settings panel follows by reading directly.
invalidateTools();
const data = await getTools();
const tools = data.tools || [];
if (!tools.length) { list.innerHTML = '<div class="admin-empty">No tools found</div>'; return; }

Expand Down Expand Up @@ -1968,17 +1979,50 @@ async function loadBuiltinTools() {
});
});

// Helper: save disabled tools + update counters
async function _saveToolState() {
const allChecks = list.querySelectorAll('input[data-tool-id]');
const disabled = [];
allChecks.forEach(c => { if (!c.checked) disabled.push(c.dataset.toolId); });
await fetch('/api/tools', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled }),
credentials: 'same-origin',
});
// Merge only the user's intended changes onto authoritative server state.
// /api/tools replaces the full disabled list, so rebuilding it from this
// panel's DOM can undo a change made by another tab or manage_settings
// after the panel was opened.
async function _saveToolState(changes) {
invalidateTools();
const latest = await getTools();
const state = new Map(
(latest.tools || []).map(t => [t.id, !!t.enabled])
);

for (const change of changes) {
if (state.has(change.id)) {
state.set(change.id, !!change.enabled);
}
}

const disabled = Array.from(state.entries())
.filter(([, enabled]) => !enabled)
.map(([id]) => id);

try {
const res = await fetch('/api/tools', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled }),
credentials: 'same-origin',
});
if (!res.ok) throw new Error(`Failed to update tools (${res.status})`);

// Bring the still-open editor forward to the same merged snapshot so an
// out-of-band change is visible instead of leaving stale checkboxes.
list.querySelectorAll('input[data-tool-id]').forEach(c => {
if (state.has(c.dataset.toolId)) {
c.checked = state.get(c.dataset.toolId);
}
});
list.querySelectorAll('.admin-tool-category').forEach(_updateCatCounter);
} finally {
// This route persists disabled_tools into the settings store
// (routes/model_routes.py), so both snapshots are now stale.
invalidateTools();
invalidateSettings();
}
}
function _updateCatCounter(catEl) {
if (!catEl) return;
Expand All @@ -1993,7 +2037,9 @@ async function loadBuiltinTools() {
// Wire individual tool toggles
list.querySelectorAll('input[data-tool-id]').forEach(chk => {
chk.addEventListener('change', async () => {
await _saveToolState();
await _saveToolState([
{ id: chk.dataset.toolId, enabled: chk.checked },
]);
_updateCatCounter(chk.closest('.admin-tool-category'));
});
});
Expand All @@ -2004,8 +2050,10 @@ async function loadBuiltinTools() {
const catEl = chk.closest('.admin-tool-category');
if (!catEl) return;
const checked = chk.checked;
const changes = Array.from(catEl.querySelectorAll('input[data-tool-id]'))
.map(c => ({ id: c.dataset.toolId, enabled: checked }));
catEl.querySelectorAll('input[data-tool-id]').forEach(c => { c.checked = checked; });
await _saveToolState();
await _saveToolState(changes);
_updateCatCounter(catEl);
});
});
Expand Down
86 changes: 86 additions & 0 deletions static/js/appConfig.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// static/js/appConfig.js
//
// One shared, invalidatable cache for the two config endpoints that every
// module wants at startup.
//
// Before this, /api/auth/settings was fetched independently by six modules and
// /api/tools by three, none of them aware of the others — 4 and 3 requests on a
// single cold load. Worse than the requests: each caller could observe a
// different snapshot of the same object, and chatRenderer.js is imported under
// three different ?v= query strings, so it is three separate module instances
// each issuing its own /api/tools fetch. Caching here fixes both, because the
// cache lives in one module every instance imports by the same specifier.
//
// URLs are bare paths on purpose. The callers that used `${API_BASE}/api/...`
// resolved to the identical URL — API_BASE is `window.location.origin`
// (app.js) — so nothing about the request changes for them.
//
// WRITERS MUST INVALIDATE. Anything that POSTs /api/auth/settings calls
// invalidateSettings(); anything that POSTs /api/tools calls invalidateTools()
// *and* invalidateSettings(), because that route persists `disabled_tools`
// into the same settings store (routes/model_routes.py). Miss one and the UI
// serves a stale settings object for the rest of the session, which is worse
// than the duplicate fetches this replaces.
//
// The resolved object is shared by reference, so treat it as read-only: copy
// before mutating (`{ ...await getSettings() }`).

// Written by login.html immediately before it redirects to '/', so the first
// load after a login can skip the request entirely. Consumed once per page
// load, by whichever module asks for settings first.
const PREFETCH_KEY = 'ody-prefetch-settings';

const _URLS = { settings: '/api/auth/settings', tools: '/api/tools' };
const _cache = { settings: null, tools: null };

function _readPrefetchedSettings() {
try {
const raw = sessionStorage.getItem(PREFETCH_KEY);
if (!raw) return null;
sessionStorage.removeItem(PREFETCH_KEY);
return JSON.parse(raw);
} catch (_) {
return null;
}
}

// A rejected promise must not stay in the slot. Plain `??=` memoisation would
// keep it, so one transient blip during boot would leave keybinds, TTS and the
// search provider on their defaults for the whole session with no retry. Clear
// the slot on failure — unless a later invalidate/refetch already replaced it —
// and rethrow, so every caller's existing .catch() still runs exactly as before.
function _get(key) {
if (_cache[key]) return _cache[key];
const pending = fetch(_URLS[key], { credentials: 'same-origin' })
.then(r => r.json())
.catch(err => {
if (_cache[key] === pending) _cache[key] = null;
throw err;
});
_cache[key] = pending;
return pending;
}

/** GET /api/auth/settings, once per page load (or once per invalidation). */
export function getSettings() {
if (!_cache.settings) {
const prefetched = _readPrefetchedSettings();
if (prefetched) _cache.settings = Promise.resolve(prefetched);
}
return _get('settings');
}

/** GET /api/tools, once per page load (or once per invalidation). */
export function getTools() {
return _get('tools');
}

/** Call after any write that can change settings. */
export function invalidateSettings() {
_cache.settings = null;
}

/** Call after any write that can change the tool enable/disable state. */
export function invalidateTools() {
_cache.tools = null;
}
9 changes: 7 additions & 2 deletions static/js/chatRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import spinnerModule from './spinner.js';
import { bindMenuDismiss } from './escMenuStack.js';
import { loadPanel } from './panels.js';
import { matchModelKey } from './model/matchKey.js';
import { getTools } from './appConfig.js';

const SEARCH_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
const REPORT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>';
Expand Down Expand Up @@ -446,8 +447,12 @@ function stripExecutedFence(match, tag, inline, body) {

async function loadExecFenceRegex() {
try {
const res = await fetch('/api/tools', { credentials: 'same-origin' });
const data = await res.json();
// Shared with admin.js, and — more to the point — with the other copies of
// this module: chatRenderer.js is imported under three different ?v= query
// strings, so it is instantiated three times per load and used to issue
// three identical /api/tools requests. appConfig.js is imported by one
// specifier from all of them, so they now share a single fetch.
const data = await getTools();
const tags = (data.tools || [])
.map((t) => t.id)
.filter((id) => id && !EXEC_FENCE_NON_TOOL.has(id));
Expand Down
4 changes: 2 additions & 2 deletions static/js/emailLibrary.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
_tryFoldHintSig, _foldSignature, _SIG_ICON, _QUOTE_ICON,
} from './emailLibrary/signatureFold.js';
import { state } from './emailLibrary/state.js';
import { getSettings } from './appConfig.js';
import { collapseSidebarToRail } from './modalSnap.js';
import { emailApiUrl } from './emailShared.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
Expand Down Expand Up @@ -993,8 +994,7 @@ function _syncEmailReminderBellVisibility(enabled) {

async function _loadEmailReminderBellVisibility() {
try {
const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
const settings = await res.json();
const settings = await getSettings();
_syncEmailReminderBellVisibility(settings.reminder_channel === 'email');
} catch (_) {
_syncEmailReminderBellVisibility(false);
Expand Down
4 changes: 2 additions & 2 deletions static/js/keyboard-shortcuts.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// ============================================

import { IS_MAC, isAltGrEvent } from './platform.js';
import { getSettings } from './appConfig.js';

const _defaultKeybinds = {
search: 'ctrl+k', toggle_sidebar: 'ctrl+alt+b', new_session: 'ctrl+alt+n',
Expand Down Expand Up @@ -56,8 +57,7 @@ export function initKeyboardShortcuts(modules) {
window._odysseusKeybinds = { ..._defaultKeybinds };

// Load saved keybinds
fetch('/api/auth/settings', { credentials: 'same-origin' })
.then(r => r.json())
getSettings()
.then(s => { if (s.keybinds) window._odysseusKeybinds = { ..._defaultKeybinds, ...s.keybinds }; })
.catch(() => {});

Expand Down
14 changes: 9 additions & 5 deletions static/js/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,21 @@
* Search settings management — reads active provider from admin settings.
*/

let API_BASE = '';
import { getSettings, invalidateSettings } from './appConfig.js';

let _provider = 'searxng';
let _loaded = false;

export function init(apiBase) {
API_BASE = apiBase;
// No API base parameter any more: the settings request lives in appConfig.js and
// resolves against the document origin, which is exactly what API_BASE held.
export function init() {
// Fetch provider on init so it's ready when chat needs it
_fetchProvider();
}

async function _fetchProvider() {
try {
const res = await fetch((API_BASE || '') + '/api/auth/settings', { credentials: 'same-origin' });
const s = await res.json();
const s = await getSettings();
_provider = s.search_provider || 'searxng';
_loaded = true;
} catch (e) { /* keep default */ }
Expand All @@ -39,6 +40,9 @@ export function getProviderLabel() {

/** Re-fetch after admin saves new settings */
export function refresh() {
// Drop the shared snapshot first: the point of this call is to observe the
// settings that were just written, so it must not be served from cache.
invalidateSettings();
_fetchProvider();
}

Expand Down
Loading