mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-20 05:42:20 +02:00
Compare commits
4
Commits
67e08cce1b
...
04b8829fb2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04b8829fb2 | ||
|
|
895bf896e3 | ||
|
|
cc42f38a89 | ||
|
|
d5514da3ab |
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,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.
|
||||
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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); }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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'),
|
||||
{
|
||||
|
||||
@@ -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
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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"],
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user