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