Compare commits

...
4 Commits
Author SHA1 Message Date
LéoandAlexandre Teixeira 04b8829fb2 perf(frontend): share one cached fetch for /api/auth/settings and /api/tools (#5997)
* perf(frontend): share one cached fetch for settings and tools

/api/auth/settings was fetched independently by eight modules and /api/tools by
three on a single load — 4 and 3 requests measured — and any two of those
callers could observe a different snapshot of the same object. chatRenderer.js
is imported under three different ?v= query strings, so it is three separate
module instances each issuing its own /api/tools request.

appConfig.js holds one promise per endpoint, so concurrent and later callers
share it. Every writer invalidates: the settings panel routes its 16 saves
through a single helper, and the admin tools save drops both snapshots because
that route persists disabled_tools into the same settings store. A rejected
fetch clears its slot rather than being memoised, so one blip at boot cannot
leave keybinds, TTS and the search provider on defaults for the session.

The settings panel keeps reading directly: it is the writer and edits what it
reads, so it must see authoritative state.

Cold load, Resource Timing: /api/auth/settings 4 -> 1, /api/tools 3 -> 1, and
0 settings requests on the first load after a login, because the cache now
consumes the sessionStorage prefetch that login.html writes.

Fixes #5996

* fix(admin): refetch tool state when the Agent Tools panel opens

The shared cache made Admin > Tools render the boot snapshot on every
reopen. Its save posts the whole disabled list rebuilt from the checkboxes,
so a tool disabled out of band (the manage_settings tool, another tab) came
back enabled on the next unrelated toggle. Reproduced against the running
app: with api_call disabled by a separate client, toggling app_api off
posted ['app_api'] and silently re-enabled api_call.

The panel now drops the shared entry before reading it, which restores what
dev does today and keeps the startup read that chatRenderer.js shares. Cold
load is still 1 request each for /api/auth/settings and /api/tools, and the
panel costs the same 2 requests per open as dev.

* fix(static): preserve concurrent tool setting changes

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-16 21:03:05 +01:00
Léo 895bf896e3 refactor(static): load the image editor on first use (#6074)
galleryEditor.js and its js/editor/ graph are 54 modules / 576 KB, and
gallery.js imported them statically. Every page load paid for the whole
image editor even though most sessions never open the Edit tab: 54 of the
173 JS files on a cold load, and 576 KB of the decoded JS, were for a
panel that was never displayed.

Add a small panel-loader registry (static/js/panels.js) that imports a
panel's module on first use and memoises the promise, so a double-click
cannot start two loads and a failed load can still be retried. Convert
the image editor to it, and route the two existing dynamic imports in
chat.js and chatRenderer.js through the same entry so all three call
sites share one module instance instead of two.

closeEditor() and isEditorOpen() stay synchronous: if the module was
never loaded there is no edit session to close and none can be open.

The service worker keeps precaching the editor, in a separate
PANEL_PRECACHE list, so the panel stays available offline even though
index.html no longer loads it. The two lists now serve different
purposes and the header comment says so.
2026-08-16 17:54:24 +01:00
Léo cc42f38a89 fix(ci): match the screenshot checkbox by wording, not emphasis (#6073)
The PR-description check folded the template's asterisks into the pattern,
so a ticked box written without them read as unchecked while rendering
identically on the PR page. `ready for review` was silently withheld and
the bot reported missing visual evidence even with screenshots attached,
with no way to tell from the rendered PR what was wrong.

The two attestations directly above it already anchor on the wording
alone. This one now does the same, accepting `**bold**`, `*italic*`,
`__underscores__` and plain text.

Fixes #6071
2026-08-16 16:26:08 +01:00
Joeseph Grey d5514da3ab fix(tasks): scope action_tidy_research broken-file sweep to admins (#6069)
action_tidy_research took an `owner` argument and never used it. Any user's
scheduled tidy task swept data/deep_research globally, unlinking every empty
or unparseable file regardless of who owned it.

A broken file has no readable owner stamp, so it cannot be matched against
`owner` the way _find_owned_research_path does, which is why the HTTP path and
manage_research already treat parse failure as not-owned. Clearing one is a
privileged act rather than an ownership one, so gate it on the canonical
owner_is_admin_or_single_user helper: admins and the single-user operator keep
the janitor, a regular user does not, and neither does the pre-setup window
before an admin exists.

Returns before the directory glob rather than filtering inside the loop, so a
denied run reports why instead of reporting "none broken" over files it never
inspected. That reason string surfaces in Activity as a skipped row.
2026-08-16 13:19:56 +01:00
22 changed files with 1209 additions and 135 deletions
+5 -1
View File
@@ -96,7 +96,11 @@ module.exports = async ({ github, context, core }) => {
const appRan = /- \[x\]\s+I actually ran the app\b/i.test(body);
const appNotRun = /- \[x\]\s+I did not run the app\/runtime validation\b/i.test(body);
const screenshotChecked = /- \[x\]\s+\*\*Screenshot or short clip\*\*/i.test(body);
// Anchor on the wording, not the template's emphasis: a ticked box the author
// retyped without the surrounding ** renders identically on the PR page, so
// treating it as unchecked is invisible from their side. Matches the two
// attestations above, which already ignore formatting.
const screenshotChecked = /- \[x\]\s+[*_]{0,2}Screenshot or short clip[*_]{0,2}/i.test(body);
const screenshotSection = section('Screenshots / clips');
const hasVisualEvidence = /!\[[^\]]*\]\([^)]+\)|<(?:img|video|source)\b[^>]*(?:src|href)=|https?:\/\/[^\s)]+/i.test(screenshotSection);
const evidenceGaps = [];
+15 -1
View File
@@ -810,13 +810,27 @@ async def action_tidy_research(owner: str, **kwargs) -> Tuple[str, bool]:
Research history lives entirely in data/deep_research/<id>.json and is NOT
backed by chat-session rows — so a file must never be deleted just because
no chat session matches its id. Only prune files that fail to load."""
no chat session matches its id. Only prune files that fail to load.
A broken file has no readable owner stamp, so it cannot be matched against
`owner`. Clearing one is privileged: admins and the single-user operator
(AUTH_ENABLED=false) may, a regular user may not, and neither may anyone
during the pre-setup window before an admin exists.
"""
try:
from pathlib import Path
import json as _json
from src.tool_security import owner_is_admin_or_single_user
research_dir = Path(DEEP_RESEARCH_DIR)
if not research_dir.exists():
raise TaskNoop("no research directory")
if not owner_is_admin_or_single_user(owner):
# Return before the glob rather than filtering inside the loop: the
# loop reports "none broken" off an empty `removed`, which reaches
# Activity as a false report to a user whose files it skipped, and a
# regular user need not read every owner's file to learn it may
# delete none of them.
raise TaskNoop("not permitted to remove unattributable research files")
files = list(research_dir.glob("*.json"))
removed = []
for p in files:
+7 -8
View File
@@ -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);
+65 -17
View File
@@ -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 = '<div class="admin-empty">No tools found</div>'; 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);
});
});
+86
View File
@@ -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;
}
+2 -1
View File
@@ -35,6 +35,7 @@ import {
inheritModelRouteState,
} from './chatModelProvenance.js';
import { createTerminalStreamError, isRecoverableStreamError } from './chatStreamErrors.js';
import { loadPanel } from './panels.js';
const RESEARCH_TIMEOUT_MS = 360000;
const DEFAULT_TIMEOUT_MS = 120000;
@@ -6606,7 +6607,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
// Images → Gallery editor.
if (isImage) {
try {
const gx = await import('./galleryEditor.js');
const gx = await loadPanel('editor');
if (gx.openEditor) { gx.openEditor(url, id, null, name); return; }
} catch (e) { console.warn('gallery open failed', e); }
window.open(url, '_blank');
+9 -3
View File
@@ -9,7 +9,9 @@ import { providerLogo, providerLabel } from './providers.js';
import settingsModule from './settings.js';
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>';
@@ -445,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));
@@ -1548,7 +1554,7 @@ export function buildImageBubble(imageUrl, prompt, model, size, quality, imageId
try {
const [galleryMod, editorMod] = await Promise.all([
import('./gallery.js'),
import('./galleryEditor.js'),
loadPanel('editor'),
]);
// Ensure the Gallery modal is open so the editor has a container
// to render into; switch its tabs to the Edit tab.
+2 -2
View File
@@ -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);
+49 -1
View File
@@ -3,7 +3,7 @@
*/
import uiModule from './ui.js';
import { openEditor, closeEditor, isEditorOpen } from './galleryEditor.js?v=20260708match1';
import { loadPanel } from './panels.js';
import spinnerModule from './spinner.js';
import { makeWindowDraggable } from './windowDrag.js';
import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
@@ -15,6 +15,54 @@ const API_BASE = window.location.origin;
let _open = false;
let _galleryResizeHandler = null;
// ── Image editor, loaded on first use ──
// galleryEditor.js plus everything under js/editor/ is 54 modules / 576 KB.
// It used to be a static import here, so every page load paid for it even
// though most sessions never touch the Edit tab. The wrappers below keep the
// three call shapes the rest of this file already uses.
//
// closeEditor() and isEditorOpen() stay synchronous on purpose: if the module
// was never loaded there is no edit session to close, and none can be open.
let _editorMod = null;
let _editorLoading = false;
async function _loadEditor() {
_editorLoading = true;
try {
_editorMod = await loadPanel('editor');
return _editorMod;
} finally {
_editorLoading = false;
}
}
async function openEditor(...args) {
let mod = _editorMod;
if (!mod) {
try {
mod = await _loadEditor();
} catch (e) {
// Previously unreachable — a static import either loaded or the whole
// page failed. Now it can fail on its own (offline before the panel was
// ever cached), so say so instead of doing nothing.
console.error('[gallery] image editor failed to load', e);
uiModule?.showError?.('Failed to load the image editor');
return;
}
}
return mod.openEditor(...args);
}
function closeEditor(...args) {
return _editorMod ? _editorMod.closeEditor(...args) : undefined;
}
// True while the module is still in flight as well — the gallery-close paths
// use this to refuse to tear the container down under an edit that is opening.
function isEditorOpen() {
return _editorLoading || (_editorMod ? _editorMod.isEditorOpen() : false);
}
// Auto-refresh gallery when new image is generated
window.addEventListener('gallery-refresh', (e) => {
if (e?.detail?.source === 'chat-upload' && _sort !== 'recent') {
+2 -2
View File
@@ -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(() => {});
+53
View File
@@ -0,0 +1,53 @@
/**
* Panel loader registry — imports the modules behind a feature panel the
* first time that panel is actually used.
*
* The panels already populate themselves on open (each fetches its own data).
* They were just not *loaded* on demand: every one of them sat on the critical
* path of every page load, opened or not.
*
* A panel only belongs here once it has been checked for import-time side
* effects that something outside the panel depends on at startup. Entries get
* added one panel at a time, not in bulk.
*
* Note the service worker still precaches these modules (PANEL_PRECACHE in
* sw.js) — they are off the critical path, not off the offline manifest.
*/
const LOADERS = {
editor: () => import('./galleryEditor.js'),
};
/**
* Build a memoising loader over a name -> import-thunk map. Exported so the
* behaviour can be tested without pulling a real panel's module graph in.
*/
export function createPanelLoader(loaders) {
const cache = new Map();
return function load(name) {
const cached = cache.get(name);
if (cached) return cached;
const loader = loaders[name];
if (!loader) throw new Error(`loadPanel: unknown panel "${name}"`);
// A failed load (offline, 404, syntax error) is not memoised — caching the
// rejection would leave the panel broken for the rest of the session even
// after the network came back.
const pending = Promise.resolve().then(loader).catch((err) => {
cache.delete(name);
throw err;
});
cache.set(name, pending);
return pending;
};
}
/**
* Load a panel's module, once. Returns the same promise on every call for the
* same panel; throws for a name that is not registered.
*/
export const loadPanel = createPanelLoader(LOADERS);
/** Registered panel names — the registry is the list, not a second copy of it. */
export function panelNames() {
return Object.keys(LOADERS);
}
+9 -5
View File
@@ -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();
}
+46 -66
View File
@@ -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); }
}
+2 -2
View File
@@ -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 };
}
+19 -18
View File
@@ -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([
+6 -3
View File
@@ -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';
+77 -5
View File
@@ -7,16 +7,28 @@
// - 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-v376-settings-title-icons';
const CACHE_NAME = 'odysseus-v378-shared-config-image-editor';
// Core shell precached on install so repeat opens are instant without any
// network wait. Keep this list in sync with the <script type="module"> tags
// and <link rel="stylesheet"> in index.html.
// Two lists, two jobs — they are no longer the same set and must not be
// "resynced" back into one:
//
// PRECACHE = the app shell. Mirrors the <script type="module"> tags
// and <link rel="stylesheet"> in index.html — i.e. what
// loads before first paint.
// PANEL_PRECACHE = modules that index.html deliberately does NOT load,
// because js/panels.js imports them on first use. They are
// off the critical path, not out of the offline manifest:
// without them here, a panel the user never opened while
// online could not open offline at all.
//
// Both are fetched at install time, in the background. Entries must match the
// exact URL the browser requests, query string included.
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',
@@ -63,13 +75,73 @@ const PRECACHE = [
'/static/lib/highlight.min.js',
];
// Lazily-imported panel modules (js/panels.js). Not in index.html by design;
// precached so the panel still opens with no network.
const PANEL_PRECACHE = [
// Image editor — galleryEditor.js and its js/editor/ graph.
'/static/js/galleryEditor.js',
'/static/js/editor/ai-inpaint.js?v=20260708match1',
'/static/js/editor/ai-models.js',
'/static/js/editor/ai-rembg.js',
'/static/js/editor/ai-tool-runner.js',
'/static/js/editor/ai-tools-misc.js',
'/static/js/editor/build/controls.js?v=20260708match1',
'/static/js/editor/build/popups.js',
'/static/js/editor/build/right-panel.js',
'/static/js/editor/build/toolbar.js?v=20260708sam3',
'/static/js/editor/build/topbar.js',
'/static/js/editor/build/transform-popup.js',
'/static/js/editor/canvas-coords.js',
'/static/js/editor/canvas-events.js',
'/static/js/editor/canvas-transforms.js',
'/static/js/editor/checkerboard.js',
'/static/js/editor/clipboard-and-drop.js',
'/static/js/editor/composite-helpers.js',
'/static/js/editor/filters/blur.js',
'/static/js/editor/filters/edge-feather.js',
'/static/js/editor/fx/adj-popup.js',
'/static/js/editor/fx/filter-string.js',
'/static/js/editor/fx/histogram.js',
'/static/js/editor/fx/pixel-pass.js',
'/static/js/editor/harmonize-masks.js',
'/static/js/editor/history-panel.js',
'/static/js/editor/keyboard-shortcuts.js',
'/static/js/editor/layer-helpers.js',
'/static/js/editor/layer-panel.js',
'/static/js/editor/mask-utils.js',
'/static/js/editor/shortcuts-popover.js',
'/static/js/editor/slider-ux.js',
'/static/js/editor/snap.js',
'/static/js/editor/state.js',
'/static/js/editor/stroke-pipeline.js',
'/static/js/editor/stroke-tool-sliders.js',
'/static/js/editor/tools/clone.js',
'/static/js/editor/tools/crop.js',
'/static/js/editor/tools/flood-fill.js',
'/static/js/editor/tools/lasso-mask.js',
'/static/js/editor/tools/lasso.js',
'/static/js/editor/tools/move.js',
'/static/js/editor/tools/stroke.js',
'/static/js/editor/tools/transform-drag.js',
'/static/js/editor/tools/transform-handles.js',
'/static/js/editor/tools/transform-session.js',
'/static/js/editor/tools/wand.js',
'/static/js/editor/wire-import.js',
'/static/js/editor/wire-inpaint-controls.js?v=20260708match1',
'/static/js/editor/wire-merge-buttons.js',
'/static/js/editor/wire-selection-controls.js',
'/static/js/editor/wire-topbar-menus.js',
'/static/js/editor/wire-topbar-overflow.js',
'/static/js/editor/wire-topbar.js',
];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE_NAME).then(cache =>
// addAll is atomic — if any item fails, none are cached. Use individual
// puts so a single 404 can't block the whole install.
Promise.all(
PRECACHE.map(url =>
[...PRECACHE, ...PANEL_PRECACHE].map(url =>
fetch(url, { cache: 'reload' })
.then(res => res.ok ? cache.put(url, res) : null)
.catch(() => null)
@@ -817,6 +817,12 @@ const STUBS = new Map([
bindMenuDismiss() {},
},
],
[
path.join(JS, 'appConfig.js'),
{
invalidateSettings() {},
},
],
[
path.join(JS, 'windowDrag.js'),
{
+375
View File
@@ -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
+201
View File
@@ -0,0 +1,201 @@
r"""Behaviour of the panel-loader registry in `static/js/panels.js`.
The registry is what keeps a lazily-loaded panel honest: it must import a
panel's module exactly once no matter how many times the user clicks, it must
fail loudly on a name nobody registered (rather than handing back `undefined`
and blowing up somewhere unrelated), and it must not memoise a *failed* load —
otherwise a panel that failed to load once while offline would stay broken for
the rest of the session.
`createPanelLoader` is exercised with stub thunks so these tests need no DOM and
no real panel module graph. The production `loadPanel` is the same function
applied to the real registry, checked here only for its registered names.
"""
import json
import os
import re
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_PANELS = (_REPO / "static" / "js" / "panels.js").as_posix()
_HAS_NODE = shutil.which("node") is not None
pytestmark = pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def _run(js: str) -> str:
proc = subprocess.run(
["node", "--input-type=module"],
input=js, capture_output=True, text=True, cwd=str(_REPO), timeout=30,
)
assert proc.returncode == 0, proc.stderr
return proc.stdout.strip()
def test_repeated_loads_import_once_and_share_one_promise():
# A double-click on a rail button calls the loader twice before the first
# import has resolved. Both calls must ride the same in-flight import.
js = textwrap.dedent(
f"""
const {{ createPanelLoader }} = await import('{_PANELS}');
let calls = 0;
const load = createPanelLoader({{
demo: () => {{ calls += 1; return Promise.resolve({{ open: () => 'opened' }}); }},
}});
const a = load('demo');
const b = load('demo');
const mod = await a;
const c = load('demo'); // after it resolved, still cached
console.log(JSON.stringify({{
calls,
sameWhilePending: a === b,
sameAfterResolve: a === c,
opened: (await c).open(),
modOpened: mod.open(),
}}));
"""
)
out = json.loads(_run(js))
assert out["calls"] == 1
assert out["sameWhilePending"] is True
assert out["sameAfterResolve"] is True
assert out["opened"] == "opened"
assert out["modOpened"] == "opened"
def test_unknown_panel_throws_instead_of_returning_undefined():
js = textwrap.dedent(
f"""
const {{ createPanelLoader }} = await import('{_PANELS}');
const load = createPanelLoader({{ demo: () => Promise.resolve({{}}) }});
let message = null, returned = 'not-reached';
try {{ returned = load('nope'); }} catch (e) {{ message = e.message; }}
console.log(JSON.stringify({{ message, returned: String(returned) }}));
"""
)
out = json.loads(_run(js))
assert out["message"] is not None
assert 'nope' in out["message"]
assert out["returned"] == "not-reached"
def test_a_failed_load_is_not_memoised_so_a_retry_can_succeed():
# First open happens offline and the import 404s; the second, back online,
# must actually retry rather than replay the cached rejection.
js = textwrap.dedent(
f"""
const {{ createPanelLoader }} = await import('{_PANELS}');
let calls = 0;
const load = createPanelLoader({{
demo: () => {{
calls += 1;
return calls === 1
? Promise.reject(new Error('offline'))
: Promise.resolve({{ open: () => 'opened' }});
}},
}});
let firstError = null;
try {{ await load('demo'); }} catch (e) {{ firstError = e.message; }}
const second = await load('demo');
console.log(JSON.stringify({{ calls, firstError, opened: second.open() }}));
"""
)
out = json.loads(_run(js))
assert out["firstError"] == "offline"
assert out["calls"] == 2
assert out["opened"] == "opened"
def test_a_thunk_that_throws_synchronously_rejects_rather_than_escaping():
js = textwrap.dedent(
f"""
const {{ createPanelLoader }} = await import('{_PANELS}');
const load = createPanelLoader({{ demo: () => {{ throw new Error('boom'); }} }});
let sync = null, rejected = null;
let p;
try {{ p = load('demo'); }} catch (e) {{ sync = e.message; }}
try {{ await p; }} catch (e) {{ rejected = e.message; }}
console.log(JSON.stringify({{ sync, rejected }}));
"""
)
out = json.loads(_run(js))
assert out["sync"] is None, "a broken thunk must not throw at call time"
assert out["rejected"] == "boom"
def test_the_image_editor_is_registered():
js = textwrap.dedent(
f"""
const {{ panelNames }} = await import('{_PANELS}');
console.log(JSON.stringify(panelNames()));
"""
)
assert "editor" in json.loads(_run(js))
# ── Offline coverage ──────────────────────────────────────────────────────
# A lazily-loaded panel is never fetched during a normal page load, so it only
# lands in the service-worker cache if sw.js precaches it explicitly. Miss one
# module and the panel opens fine online and dies offline — the failure mode is
# invisible until someone is on a plane. This walks the editor's import graph
# and pins that every file in it is listed in PANEL_PRECACHE, query string
# included (the SW matches on the full URL).
_SW = _REPO / "static" / "sw.js"
_JS_DIR = _REPO / "static" / "js"
_STATIC_IMPORT = re.compile(
r"""(?:^|\n)\s*(?:import\s+(?:[^;'"()]*?\s+from\s+)?|export\s+(?:\*|\{[^}]*\})\s+from\s+)['"]([^'"]+)['"]"""
)
def _editor_module_graph() -> set[str]:
"""Every module statically reachable from galleryEditor.js, as '<path>[?query]'."""
seen: set[str] = set()
stack = ["galleryEditor.js"]
while stack:
current = stack.pop()
if current in seen:
continue
seen.add(current)
source = (_JS_DIR / current.split("?")[0]).read_text(encoding="utf-8")
for spec in _STATIC_IMPORT.findall(source):
if not spec.startswith("."):
continue
path, _, query = spec.partition("?")
target = os.path.normpath(
os.path.join(os.path.dirname(current.split("?")[0]), path)
)
stack.append(f"{target}?{query}" if query else target)
return seen
def _panel_precache_entries() -> set[str]:
block = re.search(
r"const PANEL_PRECACHE = \[(.*?)\];", _SW.read_text(encoding="utf-8"), re.S
)
assert block, "PANEL_PRECACHE not found in static/sw.js"
return set(re.findall(r"'([^']+)'", block.group(1)))
def test_every_lazy_editor_module_is_precached_for_offline_use():
graph = _editor_module_graph()
precached = _panel_precache_entries()
# Shared modules (ui.js, spinner.js, ...) are still eagerly loaded by
# index.html, so they are cached by the normal page load. Only the part of
# the graph that nothing else pulls in needs an explicit entry.
lazy_only = {
m for m in graph
if m == "galleryEditor.js" or m.split("?")[0].startswith("editor/")
}
missing = {f"/static/js/{m}" for m in lazy_only} - precached
assert not missing, (
"these editor modules load lazily but are not in PANEL_PRECACHE, so the "
f"editor would not open offline: {sorted(missing)}"
)
+14
View File
@@ -169,6 +169,20 @@ def test_ui_checkbox_without_media_still_needs_visual_evidence():
assert not any(call["method"] == "setFailed" for call in calls)
def test_ticked_screenshot_box_counts_without_the_template_bolding():
body = _body(
app_ran=True,
screenshot=True,
media="https://github.com/user-attachments/assets/example",
).replace("**Screenshot or short clip**", "Screenshot or short clip")
calls = _run_checker(["static/js/example.js"], body)
assert _added_labels(calls) == {"ready for review"}
assert not _comment(calls)
assert not any(call["method"] == "setFailed" for call in calls)
def test_explicit_not_run_is_honest_but_not_ready():
calls = _run_checker(
["services/example.py"],
+159
View File
@@ -0,0 +1,159 @@
"""Owner-scope tests for action_tidy_research.
Broken research files (empty or unparseable JSON) have no readable owner
stamp. The HTTP path and _find_owned_research_path treat parse failure as
not-owned, so a regular user's tidy task must not unlink them. Clearing a
corrupt record is a privileged act instead: admins, and the operator in
single-user mode (AUTH_ENABLED=false), keep the janitor.
Every case installs a fake AuthManager rather than relying on ambient state.
Setting AUTH_ENABLED=true alone leaves is_configured False, so the admin check
these tests exist to pin would never be reached.
"""
import json
import pytest
from src.builtin_actions import TaskNoop, action_tidy_research
@pytest.fixture
def research_dir(tmp_path, monkeypatch):
data_dir = tmp_path / "deep_research"
data_dir.mkdir()
monkeypatch.setattr("src.builtin_actions.DEEP_RESEARCH_DIR", str(data_dir))
return data_dir
def _install_auth(monkeypatch, *, is_configured: bool):
"""Auth enabled, with `root` as the only admin. `is_configured` False models
the pre-setup window, where no admin exists yet."""
import core.auth as core_auth
configured = is_configured
class FakeAuthManager:
is_configured = configured
def is_admin(self, user):
return user == "root"
monkeypatch.setenv("AUTH_ENABLED", "true")
monkeypatch.setattr(core_auth, "AuthManager", FakeAuthManager)
@pytest.fixture
def configured_auth(monkeypatch):
_install_auth(monkeypatch, is_configured=True)
def _write(data_dir, name: str, text: str):
path = data_dir / f"{name}.json"
path.write_text(text, encoding="utf-8")
return path
def _write_owned(data_dir, name: str, owner: str):
return _write(data_dir, name, json.dumps({"owner": owner, "query": name}))
@pytest.mark.asyncio
async def test_configured_non_admin_skips_unparseable_and_empty_files(
research_dir, configured_auth
):
alice = _write_owned(research_dir, "alice-ok", "alice")
bob = _write_owned(research_dir, "bob-ok", "bob")
empty = _write(research_dir, "broken-empty", "")
garbage = _write(research_dir, "broken-json", "{not json")
alice_txt = alice.read_text(encoding="utf-8")
bob_txt = bob.read_text(encoding="utf-8")
with pytest.raises(TaskNoop, match="not permitted"):
await action_tidy_research("alice")
assert alice.exists()
assert alice.read_text(encoding="utf-8") == alice_txt
assert bob.exists()
assert bob.read_text(encoding="utf-8") == bob_txt
assert empty.exists(), "empty file has no owner stamp; non-admin tidy must not unlink it"
assert garbage.exists(), "unparseable file has no owner stamp; non-admin tidy must not unlink it"
@pytest.mark.asyncio
async def test_configured_non_admin_skip_reason_does_not_claim_none_broken(
research_dir, configured_auth
):
"""The skip reason reaches Activity as `skipped -- <reason>`, so it must not
report a clean scan over files it declined to look at."""
_write(research_dir, "broken-empty", "")
with pytest.raises(TaskNoop) as excinfo:
await action_tidy_research("alice")
assert "none broken" not in str(excinfo.value)
@pytest.mark.asyncio
async def test_configured_admin_still_removes_broken_files(research_dir, configured_auth):
valid = _write_owned(research_dir, "ok", "alice")
empty = _write(research_dir, "broken-empty", " ")
garbage = _write(research_dir, "broken-json", "not-json")
message, ok = await action_tidy_research("root")
assert ok is True
assert "Removed 2" in message
assert valid.exists(), "admin sweep clears corrupt records, not valid ones"
assert not empty.exists()
assert not garbage.exists()
@pytest.mark.asyncio
async def test_pre_setup_denies_owner_who_would_be_admin_once_configured(
research_dir, monkeypatch
):
"""Auth on, no admin created yet: fail closed even for `root`.
The owner here is non-empty and would satisfy is_admin, so this fails if the
is_configured guard is removed. An empty owner would not discriminate -- it
is denied by the `owner and ...` short-circuit either way.
"""
_install_auth(monkeypatch, is_configured=False)
empty = _write(research_dir, "broken-empty", "")
garbage = _write(research_dir, "broken-json", "{")
with pytest.raises(TaskNoop, match="not permitted"):
await action_tidy_research("root")
assert empty.exists()
assert garbage.exists()
@pytest.mark.asyncio
async def test_empty_owner_does_not_delete_broken_files(research_dir, configured_auth):
empty = _write(research_dir, "broken-empty", "")
garbage = _write(research_dir, "broken-json", "{")
with pytest.raises(TaskNoop, match="not permitted"):
await action_tidy_research("")
assert empty.exists()
assert garbage.exists()
@pytest.mark.asyncio
async def test_auth_disabled_still_removes_broken_files(research_dir, monkeypatch):
monkeypatch.setenv("AUTH_ENABLED", "false")
valid = _write_owned(research_dir, "ok", "alice")
empty = _write(research_dir, "broken-empty", " ")
garbage = _write(research_dir, "broken-json", "not-json")
message, ok = await action_tidy_research("alice")
assert ok is True
assert "Removed 2" in message
assert valid.exists()
assert not empty.exists()
assert not garbage.exists()