/* web/js/state.js — small app-wide state: run mode, theme, and the * transcript accumulated so far. No framework, just a plain object plus a * handful of pub/sub-free helper functions; main.js owns when to re-render. */ "use strict"; const STORAGE_THEME_KEY = "lsa-web-theme"; const STORAGE_BASE_KEY = "lsa-web-base"; const STORAGE_SESSION_KEY = "lsa-web-session-id"; const STORAGE_INTERPRET_KEY = "lsa-web-interpret"; const STORAGE_LANG_KEY = "lsa-web-lang"; export const state = { // "live" is the default. A deployment is live and the analysts opening // this page expect real answers; defaulting to "simulated" would mean // synthetic, made-up numbers render in the exact same UI as real ones // — clearly labelled, but still the wrong DEFAULT once the system is // actually serving people. Simulated mode stays fully available (it is // genuinely used for demos and training — see web/README.md's // "Modes"); only the default changes. See resolveBootMode below for // how `?live=0` / `?live=1` override this — the only override left; // run mode is a deploy/debug concern now, not a topbar control an // analyst could mis-click into showing synthetic data as real. // Consequence to hold honestly: on a first load against // an unreachable backend, an analyst now sees an error (from // refreshHealth in main.js) instead of a working simulated demo — see // that error's own wording for why that is the right trade. mode: "live", // "simulated" | "live" // Placeholder only -- every real page boot (web/js/main.js, // web/admin/main.js) overwrites this immediately via // resolveDefaultBaseUrl (config.js's DEFAULT_BASE_URL/DEFAULT_API_PORT) // before loadPersisted() even runs. Kept non-empty here only so a // module that imports state.js without running either boot sequence // (a stray test, say) still gets a syntactically valid URL rather than // an empty string. baseUrl: "http://localhost:8000", theme: "system", // "system" | "light" | "dark" lang: "fa", // "fa" | "en" — chrome only; questions may be either sessionId: null, sessions: [], // SessionSummary[] — the conversation-list sidebar's index turns: [], // Turn[] rendered so far, in order memory: null, // {entries, rememberable} | null — the memory panel's last fetch // Whether each turn asks for a plain-language summary. Off by default, // matching the API: an interpretation sends up to twenty rows of real // result data to the model, and defaulting that on would be deciding // for the analyst rather than asking them. interpret: false, busy: false, nextScriptedIndex: 0, // pointer into SCENARIO.turns for the "sample" flow }; export function loadPersisted() { try { const t = localStorage.getItem(STORAGE_THEME_KEY); if (t === "light" || t === "dark" || t === "system") state.theme = t; } catch { /* localStorage unavailable (private mode, etc.) — keep default */ } try { const b = localStorage.getItem(STORAGE_BASE_KEY); if (b) { // A value saved before normalizeBaseUrl existed (or saved by a // build that had a bug, like the real "172.16.101.42:8076" no // scheme/trailing-path incident this normalisation exists for) // must not go on shadowing the deploy default forever just // because it once made it into localStorage. Repair it in place // when it is fixable (e.g. missing scheme); when it is not // parseable/valid at all, drop it instead of loading a value that // would only fail every request — the deploy-time default (already // in state.baseUrl at this point in the boot sequence) stands. const result = normalizeBaseUrl(b); if (result.ok) { state.baseUrl = result.url; if (result.url !== b) { try { localStorage.setItem(STORAGE_BASE_KEY, result.url); } catch { /* ignore */ } } } else { try { localStorage.removeItem(STORAGE_BASE_KEY); } catch { /* ignore */ } } } } catch { /* ignore */ } try { const s = localStorage.getItem(STORAGE_SESSION_KEY); if (s) state.sessionId = s; } catch { /* ignore — reload just won't return the analyst to the same conversation */ } try { state.interpret = localStorage.getItem(STORAGE_INTERPRET_KEY) === "1"; } catch { /* ignore — the toggle just won't survive a reload */ } try { const lang = localStorage.getItem(STORAGE_LANG_KEY); if (lang === "fa" || lang === "en") state.lang = lang; } catch { /* ignore — default fa */ } } /** Remembers the analyst's interpretation choice for this browser. */ export function persistInterpret(on) { state.interpret = !!on; try { if (state.interpret) localStorage.setItem(STORAGE_INTERPRET_KEY, "1"); else localStorage.removeItem(STORAGE_INTERPRET_KEY); } catch { /* ignore — it just won't survive a reload */ } } /** Remembers which conversation the analyst was on, so a reload returns * them to it (see resolveActiveSessionId, which reconciles this stored id * against the real index once it's fetched — the id might be stale). */ export function persistSessionId(sessionId) { state.sessionId = sessionId; try { if (sessionId) localStorage.setItem(STORAGE_SESSION_KEY, sessionId); else localStorage.removeItem(STORAGE_SESSION_KEY); } catch { /* ignore — the choice just won't survive a reload */ } } /** Picks which session should be active once the real index is known. * Pure, total, no localStorage access (loadPersisted already read the * raw stored id; this just reconciles it against the index): * - an empty index -> null (first-run: nothing to select yet). * - a stored id still present in the index -> kept as-is. * - a stored id that is stale, missing, or was never set -> the most * recently active session in the index, never a thrown error and * never an arbitrary (e.g. array-order) pick. */ export function resolveActiveSessionId(storedId, sessions) { if (!sessions || sessions.length === 0) return null; if (storedId && sessions.some((s) => s.session_id === storedId)) return storedId; const newest = sessions.slice().sort( (a, b) => new Date(b.last_active_at).getTime() - new Date(a.last_active_at).getTime(), )[0]; return newest.session_id; } /** Resolves the effective run mode for THIS page load from the `?live=` * URL param, falling back to *defaultMode* (state.mode's own initial * value, "live" — see its comment above) when the param is absent or * unrecognized. Pure, total, no DOM/localStorage access, so the exact * precedence rule is unit-testable without standing up the whole page: * - `?live=1` -> "live" (explicit opt-in — e.g. a bookmarked/shared link) * - `?live=0` -> "simulated" (explicit opt-out — demos, training, CI) * - anything else (param absent, or an unrecognized value) -> defaultMode * Keeping this a pure function (rather than the inline if/if it used to * be in main.js's boot) is what lets the DEFAULT itself change in exactly * one place (state.mode's initializer) without this precedence rule * needing to change, or be re-verified, along with it. */ export function resolveBootMode(searchParams, defaultMode) { const live = searchParams.get("live"); if (live === "1") return "live"; if (live === "0") return "simulated"; return defaultMode; } /** Resolves the effective backend base URL for THIS page load. `?base=` * is the highest-precedence override (an operator pointing this one load * somewhere else for debugging) and always wins when present; otherwise * *currentBaseUrl* stands unchanged — by the time main.js calls this, * loadPersisted() has already folded in a localStorage override (if any) * on top of web/js/config.js's deploy-time DEFAULT_BASE_URL, so "leave it * alone" here already means "the persisted value, or the deploy default". * Pure, total, no DOM access. */ export function resolveBootBaseUrl(searchParams, currentBaseUrl) { const base = searchParams.get("base"); return base ? base : currentBaseUrl; } /** Derives the effective deploy-time DEFAULT backend base URL from * web/js/config.js's two constants and THIS page's own location, so both * web/ and web/admin/ resolve the same default the same way without * either one hardcoding a host. * * *defaultBaseUrl* (config.js's DEFAULT_BASE_URL) wins whenever it is a * non-empty string, unchanged — this is the existing, documented "one * file a deployment edits" path, kept exactly as it already worked. * Only when it is empty does this derive an address from *pageOrigin* * (an object with `protocol`/`hostname`, e.g. `window.location` itself, * or a plain object in a test) and *defaultApiPort* (config.js's * DEFAULT_API_PORT): `//:`. This is * what lets a deployment move the API+UI pair to a new host with no JS * edit at all — the API is assumed reachable on the same hostname the * page itself was loaded from, just on its own port. * * Pure, total, no DOM access beyond reading the two fields off * *pageOrigin* that are passed in explicitly — unit-testable without a * real `window.location`. */ export function resolveDefaultBaseUrl(defaultBaseUrl, defaultApiPort, pageOrigin) { const trimmed = (defaultBaseUrl || "").trim(); if (trimmed) return trimmed; return `${pageOrigin.protocol}//${pageOrigin.hostname}:${defaultApiPort}`; } /** Closed set of reasons normalizeBaseUrl can reject a value — each * page renders its OWN message (in its own language) for each reason * rather than this shared module owning any UI copy; see * web/js/main.js's / web/admin/main.js's own mapping. */ export const BASE_URL_INVALID_EMPTY = "empty"; export const BASE_URL_INVALID_UNPARSEABLE = "unparseable"; export const BASE_URL_INVALID_SCHEME = "scheme"; /** Normalises and validates a typed or saved API base-address string — * an admin top-bar entry, a `?base=` query param, or a value already * sitting in localStorage from a previous, possibly-bad save. * * A real deployment typed "172.16.101.42:8076" (no scheme) into the * admin top bar; `fetch` then treated it as a RELATIVE path, and every * request went to `GET /admin/172.16.101.42:8076/admin/...` on the * static file server instead of the API. This exists so that never * happens silently again: * 1. trim whitespace; * 2. if there is no `scheme://` prefix, prepend `http://` (the exact * repair the incident above needed); * 3. parse with `new URL()` — a value that still does not parse (spaces, * garbage, ...) is rejected, not silently used as a relative path; * 4. reject anything whose scheme is not `http`/`https` (e.g. * `javascript:`, `file:`); * 5. on success, keep only the origin — `protocol//host` — discarding * any path, query, or trailing slash, so `http://host:port/some/path` * and `http://host:port/` both normalise to `http://host:port`. * * A second, narrower repair lives here too: a value that does NOT start * with a scheme but has one embedded further in ("/http://host:port" — * an accidental leading slash pasted in front of an otherwise-correct * address) is deliberately REJECTED rather than guessed at. Blindly * prepending "http://" to that one (as step 2 does for the ordinary * no-scheme case) parses "successfully" into a nonsense origin * (`new URL("http:///http://host:port")` resolves the second "http" as * the HOSTNAME, silently) — a wrong answer that never surfaces as an * error. An address that already contains "://" somewhere is only safe * to accept when that occurrence is the leading scheme; anywhere else, * this rejects it with a clear reason instead of risking a silent * mis-parse toward the wrong host. * * Returns `{ok: true, url}` or `{ok: false, reason}` (one of the * `BASE_URL_INVALID_*` constants above) — never throws. Pure, total, no * DOM/localStorage access. */ export function normalizeBaseUrl(raw) { const trimmed = (raw || "").trim(); if (!trimmed) return { ok: false, reason: BASE_URL_INVALID_EMPTY }; const hasSchemePrefix = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed); const hasSchemeElsewhere = !hasSchemePrefix && /:\/\//.test(trimmed); if (hasSchemeElsewhere) return { ok: false, reason: BASE_URL_INVALID_UNPARSEABLE }; const candidate = hasSchemePrefix ? trimmed : `http://${trimmed}`; let parsed; try { parsed = new URL(candidate); } catch { return { ok: false, reason: BASE_URL_INVALID_UNPARSEABLE }; } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { return { ok: false, reason: BASE_URL_INVALID_SCHEME }; } return { ok: true, url: parsed.origin }; } export function persistTheme(theme) { state.theme = theme; try { localStorage.setItem(STORAGE_THEME_KEY, theme); } catch { /* ignore — theme just won't survive a reload */ } } export function persistBaseUrl(url) { state.baseUrl = url; try { localStorage.setItem(STORAGE_BASE_KEY, url); } catch { /* ignore */ } } export function applyTheme() { const root = document.documentElement; if (state.theme === "system") root.removeAttribute("data-theme"); else root.setAttribute("data-theme", state.theme); } export function addTurn(turn) { state.turns.push(turn); return turn; } export function findTurn(turnId) { return state.turns.find((t) => t.turn_id === turnId) || null; } export function resetTranscript() { state.turns = []; state.nextScriptedIndex = 0; }