diff --git a/static/app.js b/static/app.js
index 0eeb2c39b1..426be5f66a 100644
--- a/static/app.js
+++ b/static/app.js
@@ -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';
@@ -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;
@@ -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);
diff --git a/static/js/admin.js b/static/js/admin.js
index 6162708fdc..6fd4ce057a 100644
--- a/static/js/admin.js
+++ b/static/js/admin.js
@@ -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;
@@ -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 () => {
@@ -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();
}
});
}
@@ -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 = '
No tools found
'; return; }
@@ -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;
@@ -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'));
});
});
@@ -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);
});
});
diff --git a/static/js/appConfig.js b/static/js/appConfig.js
new file mode 100644
index 0000000000..f1ec75442c
--- /dev/null
+++ b/static/js/appConfig.js
@@ -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;
+}
diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js
index b17d531796..dfb936d4f3 100644
--- a/static/js/chatRenderer.js
+++ b/static/js/chatRenderer.js
@@ -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 = '';
const REPORT_ICON = '';
@@ -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));
diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js
index 91fe11d801..89d3496afc 100644
--- a/static/js/emailLibrary.js
+++ b/static/js/emailLibrary.js
@@ -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';
@@ -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);
diff --git a/static/js/keyboard-shortcuts.js b/static/js/keyboard-shortcuts.js
index dd7c88f2a8..a15d1ff8cc 100644
--- a/static/js/keyboard-shortcuts.js
+++ b/static/js/keyboard-shortcuts.js
@@ -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',
@@ -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(() => {});
diff --git a/static/js/search.js b/static/js/search.js
index 51d780edaf..65baa96b48 100644
--- a/static/js/search.js
+++ b/static/js/search.js
@@ -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 */ }
@@ -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();
}
diff --git a/static/js/settings.js b/static/js/settings.js
index b4bf69ab3d..09552b26c5 100644
--- a/static/js/settings.js
+++ b/static/js/settings.js
@@ -26,11 +26,37 @@ import { sortModelIds } from './modelSort.js';
import { providerLogo } from './providers.js';
import { isAltGrEvent } from './platform.js';
import { bindMenuDismiss } from './escMenuStack.js';
+import { invalidateSettings } from './appConfig.js';
let initialized = false;
let modalEl = null;
let _authPolicy = { password_min_length: 8 };
+/**
+ * POST a settings patch, then drop the shared snapshot in appConfig.js.
+ *
+ * Every write in this file goes through here so no save path can forget the
+ * invalidation — a stale settings object served for the rest of the session is
+ * a worse bug than the duplicate fetches the cache removes. The invalidation is
+ * in a `finally` because a request that throws on the way back may still have
+ * been applied server-side.
+ *
+ * Reads in this file deliberately stay direct fetches: this panel is the writer
+ * and edits what it reads, so it must see the authoritative state, not a cache.
+ */
+async function _postSettings(body) {
+ try {
+ return await fetch('/api/auth/settings', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ } finally {
+ invalidateSettings();
+ }
+}
+
const el = byId;
function esc(s) { return uiModule.esc(s); }
function safeRasterDataUrl(raw) {
@@ -276,10 +302,7 @@ function _bindFallbackWidget(opts) {
var body = {};
body[settingKey] = clean;
try {
- await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body)
- });
+ await _postSettings(body);
} catch (e) { console.warn('[fallback] save failed for ' + settingKey, e); }
}
@@ -389,12 +412,9 @@ async function initDefaultChat() {
async function saveDefault() {
try {
- await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- default_endpoint_id: epSel.value,
- default_model: modelSel.value
- })
+ await _postSettings({
+ default_endpoint_id: epSel.value,
+ default_model: modelSel.value
});
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(function() { msg.textContent = ''; }, 2000);
@@ -449,12 +469,9 @@ async function initUtilityModel() {
// no toggle, "—" means "unset, use chat").
async function saveUtility() {
try {
- await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- utility_endpoint_id: epSel.value || '',
- utility_model: modelSel.value || ''
- })
+ await _postSettings({
+ utility_endpoint_id: epSel.value || '',
+ utility_model: modelSel.value || ''
});
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(function() { msg.textContent = ''; }, 1500);
@@ -547,10 +564,7 @@ async function initTeacherModel() {
spec = ep ? (modelSel.value + '@' + ep.name) : modelSel.value;
}
var enabled = enabledToggle ? !!enabledToggle.checked : false;
- await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ teacher_enabled: enabled, teacher_model: spec })
- });
+ await _postSettings({ teacher_enabled: enabled, teacher_model: spec });
msg.textContent = enabled ? (spec ? 'Saved' : 'Pick an endpoint + model') : 'Disabled';
msg.style.color = enabled && !spec ? 'var(--red)' : 'var(--fg)';
setTimeout(function() { msg.textContent = ''; }, 2000);
@@ -625,8 +639,7 @@ async function initImageSettings() {
async function saveSettings() {
try {
- 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 }) });
+ const res = await _postSettings({ 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)'; }
@@ -700,8 +713,7 @@ async function initVisionSettings() {
async function saveSettings() {
try {
- await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ vision_enabled: enabledToggle ? enabledToggle.checked : true, vision_model: vlSel.value }) });
+ await _postSettings({ vision_enabled: enabledToggle ? enabledToggle.checked : true, vision_model: vlSel.value });
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(() => { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
@@ -782,8 +794,7 @@ async function initTtsSettings() {
async function saveTTS() {
try {
- await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' }) });
+ await _postSettings({ tts_enabled: ttsEnabledToggle ? ttsEnabledToggle.checked : true, tts_provider: provSel.value, tts_model: getModel() || 'tts-1', tts_voice: getVoice() || 'alloy', tts_speed: speedSelect.value || '1' });
ttsMsg.textContent = 'Saved'; ttsMsg.style.color = 'var(--fg)'; setTimeout(() => { ttsMsg.textContent = ''; }, 2000);
if (window.aiTTSManager) window.aiTTSManager.checkAvailability();
} catch (e) { ttsMsg.textContent = 'Failed to save'; ttsMsg.style.color = 'var(--red)'; }
@@ -944,9 +955,7 @@ async function initSttSettings() {
async function saveSTT() {
try {
var enabled = sttEnabledToggle ? sttEnabledToggle.checked : false;
- await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() }) });
+ await _postSettings({ stt_enabled: enabled, stt_provider: provSel.value, stt_model: getModel() || 'base', stt_language: langInput.value.trim() });
sttMsg.textContent = 'Saved'; sttMsg.style.color = 'var(--fg)'; setTimeout(() => { sttMsg.textContent = ''; }, 2000);
// Notify voiceRecorder of effective provider and update send button icon
if (window.voiceRecorderModule) window.voiceRecorderModule._sttProvider = effectiveProvider();
@@ -1102,10 +1111,7 @@ async function initSearchSettings() {
payload[kf] = keyInput.value.trim();
_settings[kf] = keyInput.value.trim();
}
- await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload)
- });
+ await _postSettings(payload);
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(refreshStatus, 2000);
if (searchModule && searchModule.refresh) searchModule.refresh();
@@ -1257,11 +1263,7 @@ async function initSearchSettings() {
async function _saveFallbackChain(chain) {
_settings.search_fallback_chain = chain;
try {
- await fetch('/api/auth/settings', {
- method: 'POST', credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ search_fallback_chain: chain }),
- });
+ await _postSettings({ search_fallback_chain: chain });
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(refreshStatus, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
@@ -1425,10 +1427,7 @@ async function initResearchSettings() {
}
}
try {
- await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload)
- });
+ await _postSettings(payload);
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(showStatus, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
@@ -1492,10 +1491,7 @@ async function initResearchSearchSettings() {
async function saveResearchSearch() {
try {
- await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ research_search_provider: searchSel.value })
- });
+ await _postSettings({ research_search_provider: searchSel.value });
msg.textContent = 'Saved'; msg.style.color = 'var(--fg)';
setTimeout(function() { msg.textContent = ''; }, 2000);
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
@@ -1537,10 +1533,7 @@ async function initAgentSettings() {
if (rounds != null) payload.agent_max_rounds = rounds;
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
try {
- await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload)
- });
+ await _postSettings(payload);
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
(rounds != null ? ' · ' + rounds + ' steps/message' : '') +
(supInput && supInput.checked ? ' · supervisor on' : '');
@@ -1935,11 +1928,7 @@ async function initShortcuts() {
async function saveKeybinds() {
try {
- await fetch('/api/auth/settings', {
- method: 'POST', credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ keybinds }),
- });
+ await _postSettings({ keybinds });
// Update global keybinds so they take effect immediately
window._odysseusKeybinds = keybinds;
if (uiModule && uiModule.showToast) uiModule.showToast('Shortcut saved');
@@ -2232,11 +2221,7 @@ async function initReminderSettings() {
pubDebounce = setTimeout(async () => {
try {
const val = pubUrlIn.value.trim().replace(/\/+$/, '');
- await fetch('/api/auth/settings', {
- method: 'POST', credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ app_public_url: val }),
- });
+ await _postSettings({ app_public_url: val });
if (pubUrlMsg) {
pubUrlMsg.textContent = val ? 'Saved' : 'Cleared (deep-links disabled)';
pubUrlMsg.style.color = 'var(--green,#50fa7b)';
@@ -2534,12 +2519,7 @@ async function initReminderSettings() {
async function save(patch) {
try {
- await fetch('/api/auth/settings', {
- method: 'POST',
- credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(patch),
- });
+ await _postSettings(patch);
} catch (e) { console.warn('Failed to save reminder settings', e); }
}
diff --git a/static/js/slashCommands.js b/static/js/slashCommands.js
index fa3210b254..149b914d05 100644
--- a/static/js/slashCommands.js
+++ b/static/js/slashCommands.js
@@ -22,6 +22,7 @@ import settingsModule from './settings.js';
import cookbookModule from './cookbook.js';
import { EVAL_PROMPTS } from './compare/index.js';
import { PROVIDER_DEVICE_FLOWS, formatDeviceFlowError, runProviderDeviceFlow } from './providerDeviceFlow.js';
+import { getSettings } from './appConfig.js';
// ── Module state ──────────────────────────────────────────────────────
@@ -5220,8 +5221,7 @@ async function _cmdShortcuts(args, ctx) {
};
try {
- const res = await fetch(`${API_BASE}/api/auth/settings`, { credentials: 'same-origin' });
- const settings = await res.json();
+ const settings = await getSettings();
if (settings.keybinds) {
keybinds = { ...keybinds, ...settings.keybinds };
}
diff --git a/static/js/tasks.js b/static/js/tasks.js
index 09719c4528..337b97791a 100644
--- a/static/js/tasks.js
+++ b/static/js/tasks.js
@@ -10,6 +10,7 @@ import { topPortalZ } from './toolWindowZOrder.js';
import { sortModelIds } from './modelSort.js';
import { ordinalSuffix } from './util/ordinal.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
+import { getSettings, invalidateSettings } from './appConfig.js';
const API_BASE = window.location.origin;
let _open = false;
@@ -214,31 +215,31 @@ async function _fetchActions() {
return _builtinActions;
}
-let _urgentEmailSettings = null;
async function _fetchUrgentEmailSettings() {
- if (_urgentEmailSettings) return _urgentEmailSettings;
try {
- const res = await fetch('/api/auth/settings', { credentials: 'same-origin' });
- _urgentEmailSettings = await res.json();
+ return await getSettings();
} catch (e) {
- _urgentEmailSettings = { urgent_email_prompt: '' };
+ return { urgent_email_prompt: '' };
}
- return _urgentEmailSettings;
}
async function _saveUrgentEmailSettings(prompt) {
- _urgentEmailSettings = {
- ...(_urgentEmailSettings || {}),
- urgent_email_prompt: prompt || '',
- };
- await fetch('/api/auth/settings', {
- method: 'POST',
- credentials: 'same-origin',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- urgent_email_prompt: prompt || '',
- }),
- });
+ try {
+ await fetch('/api/auth/settings', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ urgent_email_prompt: prompt || '',
+ }),
+ });
+ } finally {
+ // The shared snapshot still carries the old prompt — drop it so the next
+ // read (here or in any other module) sees what was just written. In a
+ // `finally` because a request that throws on the way back may still have
+ // been applied.
+ invalidateSettings();
+ }
}
const _EMAIL_ACCOUNT_ACTIONS = new Set([
diff --git a/static/js/tts-ai.js b/static/js/tts-ai.js
index 9bb6f4012b..a6222f3394 100644
--- a/static/js/tts-ai.js
+++ b/static/js/tts-ai.js
@@ -1,6 +1,8 @@
// static/js/tts-ai.js
// AI Text-to-Speech Module — supports server TTS and browser Web Speech API
+import { getSettings } from './appConfig.js';
+
class AITTSManager {
constructor() {
this.currentAudio = null;
@@ -30,10 +32,11 @@ class AITTSManager {
async checkAvailability() {
try {
- // Check user setting first — if TTS is disabled in settings, don't show buttons
+ // Check user setting first — if TTS is disabled in settings, don't show buttons.
+ // settings.js re-calls this right after saving TTS settings; it invalidates
+ // the shared cache before doing so, so this still sees the new value.
try {
- const settingsRes = await fetch('/api/auth/settings', { credentials: 'same-origin' });
- const settings = await settingsRes.json();
+ const settings = await getSettings();
if (settings.tts_enabled === false) {
this.available = false;
this._provider = 'disabled';
diff --git a/static/sw.js b/static/sw.js
index d72332d575..fb24d3fe62 100644
--- a/static/sw.js
+++ b/static/sw.js
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
-const CACHE_NAME = 'odysseus-v377-lazy-image-editor';
+const CACHE_NAME = 'odysseus-v378-shared-config-image-editor';
// Two lists, two jobs — they are no longer the same set and must not be
// "resynced" back into one:
@@ -28,6 +28,7 @@ const PRECACHE = [
'/static/style.css',
'/static/app.js',
'/static/js/storage.js',
+ '/static/js/appConfig.js',
'/static/js/ui.js',
'/static/js/markdown.js',
'/static/js/dragSort.js',
diff --git a/tests/helpers/test_settings_shell_coordinator.mjs b/tests/helpers/test_settings_shell_coordinator.mjs
index 64ec7380d2..3fe8562714 100644
--- a/tests/helpers/test_settings_shell_coordinator.mjs
+++ b/tests/helpers/test_settings_shell_coordinator.mjs
@@ -817,6 +817,12 @@ const STUBS = new Map([
bindMenuDismiss() {},
},
],
+ [
+ path.join(JS, 'appConfig.js'),
+ {
+ invalidateSettings() {},
+ },
+ ],
[
path.join(JS, 'windowDrag.js'),
{
diff --git a/tests/test_app_config_shared_fetch_js.py b/tests/test_app_config_shared_fetch_js.py
new file mode 100644
index 0000000000..bcc51774a4
--- /dev/null
+++ b/tests/test_app_config_shared_fetch_js.py
@@ -0,0 +1,375 @@
+"""Pin the shared config cache in static/js/appConfig.js.
+
+Background: /api/auth/settings was fetched independently by six modules and
+/api/tools by three (chatRenderer.js is imported under three different ?v=
+query strings, so it is three separate module instances) — 4 and 3 requests on
+one cold load. Beyond the redundant work, each caller could observe a different
+snapshot of the same object. appConfig.js gives them one promise each.
+
+The two properties that matter are opposites, so both are tested here:
+concurrent and later callers must NOT refetch, and a caller after a write MUST
+see the new value — which only holds if every writer invalidates. The last test
+is a source scan that checks exactly that, since a forgotten invalidation
+serves a stale settings object for the rest of the session, which is worse than
+the duplicate fetches this replaces.
+
+Driven through `node --input-type=module` so the real module runs, same idiom as
+test_esc_menu_stack_js.py. The module source is inlined rather than imported by
+path because the repo has no `"type": "module"` in package.json; appConfig.js
+has no imports of its own, so inlining is exact. `fetch` and `sessionStorage`
+are stubbed, so nothing here touches the network or depends on timing.
+"""
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+_REPO = Path(__file__).resolve().parent.parent
+_MODULE = _REPO / "static" / "js" / "appConfig.js"
+_HAS_NODE = shutil.which("node") is not None
+_SRC = _MODULE.read_text(encoding="utf-8") if _MODULE.exists() else ""
+
+# Browser stand-ins. Every fetch is recorded and resolved from a queue the test
+# controls, so "how many requests went out" is an exact count, not a guess.
+_STUBS = r"""
+const calls = [];
+let responses = [];
+globalThis.__queue = (fn) => { responses.push(fn); };
+globalThis.fetch = (url, opts) => {
+ calls.push([url, opts]);
+ const next = responses.shift();
+ if (!next) throw new Error('unexpected fetch: ' + url);
+ return next();
+};
+globalThis.__calls = () => calls;
+globalThis.__json = (value) => () => Promise.resolve({ json: () => Promise.resolve(value) });
+globalThis.__fail = (msg) => () => Promise.reject(new Error(msg));
+
+const store = new Map();
+globalThis.sessionStorage = {
+ getItem: (k) => (store.has(k) ? store.get(k) : null),
+ setItem: (k, v) => { store.set(k, String(v)); },
+ removeItem: (k) => { store.delete(k); },
+};
+globalThis.__seedPrefetch = (value) => {
+ store.set('ody-prefetch-settings', JSON.stringify(value));
+};
+globalThis.__prefetchLeft = () => store.has('ody-prefetch-settings');
+"""
+
+
+def _run(body: str) -> str:
+ js = _STUBS + "\n" + _SRC + "\n" + body
+ proc = subprocess.run(
+ ["node", "--input-type=module"],
+ input=js, capture_output=True, text=True, encoding="utf-8",
+ cwd=str(_REPO), timeout=30,
+ )
+ assert proc.returncode == 0, proc.stderr
+ return proc.stdout.strip()
+
+
+@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+def test_concurrent_callers_share_one_request():
+ # The startup case: several modules ask before the first response lands.
+ body = """
+ __queue(__json({ tts_enabled: true }));
+ const [a, b, c] = await Promise.all([getSettings(), getSettings(), getSettings()]);
+ console.log(JSON.stringify({
+ requests: __calls().length,
+ url: __calls()[0][0],
+ credentials: __calls()[0][1].credentials,
+ sameObject: a === b && b === c,
+ value: a.tts_enabled,
+ }));
+ """
+ assert json.loads(_run(body)) == {
+ "requests": 1,
+ "url": "/api/auth/settings",
+ "credentials": "same-origin",
+ "sameObject": True,
+ "value": True,
+ }
+
+
+@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+def test_later_caller_reuses_the_resolved_snapshot():
+ # A panel opened long after boot must not re-request.
+ body = """
+ __queue(__json({ search_provider: 'brave' }));
+ const first = await getSettings();
+ const second = await getSettings();
+ console.log(JSON.stringify({ requests: __calls().length, sameObject: first === second }));
+ """
+ assert json.loads(_run(body)) == {"requests": 1, "sameObject": True}
+
+
+@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+def test_invalidate_forces_the_next_read_to_refetch():
+ # The write path: save a setting, then read it back and see the new value.
+ body = """
+ __queue(__json({ tts_enabled: true }));
+ __queue(__json({ tts_enabled: false }));
+ const before = await getSettings();
+ invalidateSettings();
+ const after = await getSettings();
+ console.log(JSON.stringify({
+ requests: __calls().length,
+ before: before.tts_enabled,
+ after: after.tts_enabled,
+ }));
+ """
+ assert json.loads(_run(body)) == {"requests": 2, "before": True, "after": False}
+
+
+@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+def test_a_failed_fetch_does_not_poison_the_cache():
+ # Plain `??=` memoisation would keep the rejected promise, so one blip at
+ # boot would leave keybinds/TTS/search on defaults for the whole session.
+ body = """
+ __queue(__fail('offline'));
+ __queue(__json({ tts_enabled: true }));
+ let rejected = false;
+ try { await getSettings(); } catch (e) { rejected = e.message === 'offline'; }
+ const retry = await getSettings();
+ console.log(JSON.stringify({
+ rejected,
+ requests: __calls().length,
+ recovered: retry.tts_enabled,
+ }));
+ """
+ assert json.loads(_run(body)) == {"rejected": True, "requests": 2, "recovered": True}
+
+
+@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+def test_settings_and_tools_are_independent_slots():
+ body = """
+ __queue(__json({ tts_enabled: true }));
+ __queue(__json({ tools: [{ id: 'web_search' }] }));
+ await getSettings();
+ const tools = await getTools();
+ const toolsUrl = __calls()[1][0];
+ invalidateSettings(); // must not drop the tools snapshot
+ const toolsAgain = await getTools();
+ console.log(JSON.stringify({
+ requests: __calls().length,
+ toolsUrl,
+ sameObject: tools === toolsAgain,
+ }));
+ """
+ assert json.loads(_run(body)) == {
+ "requests": 2,
+ "toolsUrl": "/api/tools",
+ "sameObject": True,
+ }
+
+
+@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+def test_login_prefetch_is_used_once_and_then_consumed():
+ # login.html stashes a settings snapshot in sessionStorage just before it
+ # redirects, so the first load after a login should issue no request at all.
+ body = """
+ __seedPrefetch({ tts_enabled: false, from: 'prefetch' });
+ __queue(__json({ tts_enabled: true, from: 'network' }));
+ const first = await getSettings();
+ const consumed = !__prefetchLeft();
+ invalidateSettings();
+ const second = await getSettings();
+ console.log(JSON.stringify({
+ requests: __calls().length,
+ first: first.from,
+ consumed,
+ second: second.from,
+ }));
+ """
+ assert json.loads(_run(body)) == {
+ "requests": 1,
+ "first": "prefetch",
+ "consumed": True,
+ "second": "network",
+ }
+
+
+# ── Writers must invalidate ─────────────────────────────────────────────────
+
+_WRITE_ENDPOINTS = {
+ "/api/auth/settings": "invalidateSettings",
+ "/api/tools": "invalidateTools",
+}
+# login.html is the pre-app login page: it has no module graph and its only call
+# is the prefetch GET, so it is not a writer and cannot import appConfig.js.
+_SCANNED = [_REPO / "static" / "app.js"] + sorted((_REPO / "static" / "js").rglob("*.js"))
+
+
+def _post_sites(source: str, endpoint: str):
+ """Yield the 1-based line of every fetch() to `endpoint` that is a POST."""
+ for m in re.finditer(re.escape(f"'{endpoint}'"), source):
+ window = source[m.start():m.start() + 240]
+ if re.search(r"method:\s*'POST'", window):
+ yield source[:m.start()].count("\n") + 1
+
+
+def test_every_settings_writer_invalidates_the_shared_cache():
+ """A POST that skips the invalidation serves a stale object for the session.
+
+ Checked by source scan rather than at runtime: the failure mode is a call
+ site that was never wired up, which no unit test of the cache itself can
+ see. `appConfig.js` itself is skipped — it is the cache, not a writer.
+ """
+ missing = []
+ for path in _SCANNED:
+ if path.name == "appConfig.js":
+ continue
+ source = path.read_text(encoding="utf-8")
+ for endpoint, invalidator in _WRITE_ENDPOINTS.items():
+ for line in _post_sites(source, endpoint):
+ # The invalidation belongs in the same function as the POST;
+ # accept it anywhere in the surrounding 20 lines either way.
+ lines = source.splitlines()
+ near = "\n".join(lines[max(0, line - 20):line + 20])
+ if invalidator + "(" not in near:
+ missing.append(f"{path.relative_to(_REPO)}:{line} POST {endpoint}")
+ assert not missing, (
+ "POST sites with no nearby cache invalidation — the UI will serve a "
+ "stale snapshot after these writes:\n " + "\n ".join(missing)
+ )
+
+
+@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+def test_out_of_band_tool_change_is_not_undone_by_an_unrelated_panel_save():
+ """Admin > Tools must render authoritative state, not the startup snapshot.
+
+ The save posts the whole disabled-tool list rebuilt from the checkboxes, so
+ a stale render turns any unrelated toggle into a lost update: a tool
+ disabled out of band (manage_settings, another tab) comes back enabled.
+ refreshAll() calls loadBuiltinTools() on every panel open, which is why the
+ editor drops the shared entry before reading it.
+ """
+ body = """
+ // Boot: chatRenderer.js reads the tool list for the exec-fence regex.
+ __queue(__json({ tools: [{ id: 'web_search', enabled: true }, { id: 'shell', enabled: true }] }));
+ const boot = await getTools();
+
+ // Out of band, this page hearing nothing about it: web_search is disabled.
+ __queue(__json({ tools: [{ id: 'web_search', enabled: false }, { id: 'shell', enabled: true }] }));
+
+ // Admin > Tools opens. loadBuiltinTools() invalidates, then reads.
+ invalidateTools();
+ const panel = await getTools();
+
+ // The user toggles one unrelated tool off. The save posts every unchecked
+ // box, so the list is only right if the render was authoritative.
+ const post = (snapshot) => {
+ const boxes = snapshot.tools.map(t => ({ id: t.id, checked: t.enabled }));
+ boxes.find(b => b.id === 'shell').checked = false;
+ return boxes.filter(b => !b.checked).map(b => b.id);
+ };
+
+ console.log(JSON.stringify({
+ requests: __calls().length,
+ posted: post(panel),
+ postedFromStaleSnapshot: post(boot),
+ }));
+ """
+ assert json.loads(_run(body)) == {
+ "requests": 2,
+ # web_search stays disabled, which is the point.
+ "posted": ["web_search", "shell"],
+ # What the page-lifetime snapshot would have posted: web_search silently
+ # re-enabled by a toggle that had nothing to do with it.
+ "postedFromStaleSnapshot": ["shell"],
+ }
+
+
+@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
+def test_out_of_band_tool_change_after_panel_open_is_preserved_on_save():
+ """Saving must merge the user's edit onto a fresh authoritative snapshot."""
+ body = """
+ // Panel opens while both tools are enabled.
+ __queue(__json({ tools: [
+ { id: 'web_search', enabled: true },
+ { id: 'shell', enabled: true },
+ ] }));
+ invalidateTools();
+ const panel = await getTools();
+
+ // Another tab disables web_search after this panel has already rendered.
+ __queue(__json({ tools: [
+ { id: 'web_search', enabled: false },
+ { id: 'shell', enabled: true },
+ ] }));
+
+ // The user only disables shell. Saving refreshes the authoritative state
+ // first and applies that one intended change on top of it.
+ invalidateTools();
+ const latest = await getTools();
+ const state = new Map(latest.tools.map(t => [t.id, !!t.enabled]));
+ state.set('shell', false);
+
+ const disabled = Array.from(state.entries())
+ .filter(([, enabled]) => !enabled)
+ .map(([id]) => id);
+
+ console.log(JSON.stringify({
+ panelWebSearchEnabled: panel.tools.find(t => t.id === 'web_search').enabled,
+ requests: __calls().length,
+ disabled,
+ }));
+ """
+ assert json.loads(_run(body)) == {
+ "panelWebSearchEnabled": True,
+ "requests": 2,
+ "disabled": ["web_search", "shell"],
+ }
+
+
+def test_admin_tool_save_refreshes_before_full_state_post():
+ """Pin the lost-update guard in the Admin Tools full-list writer."""
+ source = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8")
+ match = re.search(
+ r"async function _saveToolState\(changes\) \{(.*?)\n \}\n"
+ r" function _updateCatCounter",
+ source,
+ re.S,
+ )
+ assert match, "_saveToolState(changes) not found in static/js/admin.js"
+
+ body = match.group(1)
+ invalidate = body.find("invalidateTools()")
+ refresh = body.find("getTools()")
+ post = body.find("fetch('/api/tools'")
+
+ assert -1 not in (invalidate, refresh, post)
+ assert invalidate < refresh < post, (
+ "Admin Tools must invalidate and refresh authoritative tool state before "
+ "posting the endpoint's full disabled-tools replacement list"
+ )
+ assert "for (const change of changes)" in body
+
+
+def test_the_admin_tools_editor_does_not_read_a_cached_snapshot():
+ """Pin the invalidate-before-read in loadBuiltinTools().
+
+ A source scan because the failure is an ordering in a call site, not
+ behaviour of the cache: getTools() is doing exactly its job either way.
+ """
+ source = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8")
+ match = re.search(r"\nasync function loadBuiltinTools\(\) \{\n(.*?)\n\}\n", source, re.S)
+ assert match, "loadBuiltinTools() not found in static/js/admin.js"
+ body = match.group(1)
+ read = body.find("getTools(")
+ assert read != -1, "loadBuiltinTools() no longer reads the shared tool cache"
+ assert "invalidateTools(" in body[:read], (
+ "loadBuiltinTools() reads the shared /api/tools snapshot without dropping "
+ "it first, so a reopened panel can render tool state that changed out of "
+ "band and re-post it on the next unrelated toggle"
+ )
+
+
+def test_appconfig_is_precached_by_the_service_worker():
+ """PRECACHE is hand-maintained; a module missing from it breaks offline."""
+ sw = (_REPO / "static" / "sw.js").read_text(encoding="utf-8")
+ assert "'/static/js/appConfig.js'" in sw