web/js/main.js
Rama main · réplica de lectura del repositorio original.
/* web/js/main.js — application bootstrap and the ask/render flow.
*
* SIMULATED mode replays SCENARIO from data.js turn by turn (sample buttons
* drive the intended story; free-typed text is matched best-effort against
* the same scripted turns). LIVE mode talks to the real backend per
* docs/api-contract-v2.md — since /v2/* does not exist on the backend yet
* (only /query and /health — see api/models.py), it must degrade honestly:
* a 404 on /v2/sessions is shown as "this backend doesn't support v2 yet",
* never papered over with a fabricated result.
*/
"use strict";
import {
SCENARIO, SCENARIO_MATCH_HINTS,
SCENARIO_SESSIONS, SCENARIO_MEMORY, getSimulatedSessionTurns, FULL_TURNS_BY_ID,
} from "./data.js";
import {
state, loadPersisted, persistTheme, persistSessionId, persistInterpret,
applyTheme, addTurn, findTurn, resetTranscript, resolveActiveSessionId,
resolveBootMode, resolveBootBaseUrl, resolveDefaultBaseUrl, normalizeBaseUrl,
} from "./state.js";
import { Api, V2NotSupportedError, ApiError, UnauthorizedError, RateLimitError } from "./api.js";
import { setApiKey, clearApiKey, hasApiKey } from "./apikey.js";
import {
CODE_UNEXPECTED_PAYLOAD, createDiagnoser, hideDiagnosisBanner, isApiHealthShape, showDiagnosisBanner,
} from "./diagnosis.js";
import { DEFAULT_BASE_URL, DEFAULT_API_PORT } from "./config.js";
import { createTurnCard, FAILURE_BY_CODE } from "./render/turn.js";
import { canConfirm, renderConfirmationControl } from "./render/confirmation.js";
import { runSimulatedStages } from "./render/pipeline.js";
import { renderSessionList } from "./render/sessions.js";
import { memoryKeyForField, renderMemoryPanel } from "./render/memory.js";
import { ensureTsqlPrismReady } from "./sql-display.js";
import { t, loadLang, setLang, applyLang } from "./i18n.js";
const $ = (id) => document.getElementById(id);
/** Every "bring this into view" call in this module routes through here.
* style.css already forces `scroll-behavior: auto` under
* `prefers-reduced-motion: reduce` for CSS-driven scrolling (e.g. anchor
* jumps), but `Element.scrollIntoView({behavior: "smooth"})` is a
* script-requested behaviour and, per spec, an explicit "smooth" wins over
* that CSS property rather than deferring to it — so a hardcoded "smooth"
* here would silently defeat the reduced-motion rule for the one motion
* an analyst cannot opt out of by ignoring the page: their own submitted
* question relocating the viewport out from under them. */
function scrollIntoViewMaybeSmooth(el, opts) {
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
el.scrollIntoView({ ...opts, behavior: reduced ? "auto" : "smooth" });
}
/** D3-hidden-chart: the `{ block: "start" }` scroll that fires the moment a
* turn card is appended (below) only ever sees the card's *early* content —
* the result (a chart, table, etc.) is still `hidden` at that point
* (turn.js's tagLate/revealLate) and gets unhidden afterward, once the
* pipeline settles. A chart is tall (~440px measured), so that late reveal
* routinely grows the card past the bottom of the viewport, and nothing
* re-scrolls to account for it — it just sits there, entirely covered by
* the sticky composer (measured 0px of a 440px chart visible; see
* style.css's --composer-clearance comment for the full numbers).
*
* Call this once a turn's late content is actually in the DOM (right after
* `revealLate()`) to close that gap, passing `card.result` (turn.js) —
* NOT `card.el` (the whole turn card). A first version targeted `card.el`
* with `block: "end"`: the *card* keeps going well past the result
* (interpretation, then the collapsed pipeline/LLM/feedback drawer), so
* aligning the card's bottom with the viewport scrolled the chart itself
* off the TOP of the screen — visible measurement: the svg ended up at
* y=-347, above the fold, not above the composer. `card.result` is just
* the "نتیجه" card, which is short enough that `block: "nearest"` — only
* scroll if it is not already fully visible — is both correct and the
* gentler choice: a turn that already fits does not get an extra jump on
* top of the start-scroll above. `.turn`'s `scroll-margin-bottom:
* var(--composer-clearance)` (style.css) is what makes "fully visible"
* mean "visible above the composer" rather than merely on screen — since
* `card.result` sits inside `.turn`, it inherits that clearance. Falls
* back to `el` itself (the whole card) for a turn with no result (an
* error or a guard rejection), where there is nothing else to target. */
function scrollSettledResultAboveComposer(el) {
if (el) scrollIntoViewMaybeSmooth(el, { block: "nearest" });
}
// Declared before the boot section: setMode(state.mode) runs at module load
// and, in simulated mode, calls setHealth() synchronously — so the label map
// setHealth reads must already be initialized here, not in the module's lower
// half. Declaring it below the boot call put it in the temporal dead zone at
// boot, which threw a ReferenceError and killed the entire simulated UI.
const HEALTH_STATE_LABEL_KEY = { ok: "healthStateUp", down: "healthStateDown", unknown: "healthStateUnknown" };
// Declared here for the same reason: refreshHealth() runs from the boot call
// below. Decides, once per failed status check, whether the address this page
// calls is the API at all (diagnosis.js), so the page can say so in one banner
// instead of three red lights and a tooltip.
const diagnoser = createDiagnoser({
getBaseUrl: () => state.baseUrl,
getPageOrigin: () => location.origin,
});
let healthCheckSeq = 0;
/* ── Boot ──────────────────────────────────────────────────────────── */
// T-SQL Prism patch must run before the first Prism.tokenize — Prism
// expands greedy grammar rules in place on first use. Patching after a
// highlight has already run makes the next one throw. See sql-display.js.
ensureTsqlPrismReady();
const params = new URLSearchParams(location.search);
// Deploy-time default (web/js/config.js's DEFAULT_BASE_URL, or — when
// that is left empty — this page's own protocol/host plus
// DEFAULT_API_PORT, see resolveDefaultBaseUrl) first; loadPersisted()
// then overrides it with a localStorage value if one was ever saved
// (e.g. an operator debugging against a different backend from this
// browser) — loadPersisted() itself now repairs/discards a bad saved
// value via normalizeBaseUrl rather than loading it verbatim.
// resolveBootBaseUrl's `?base=` param is the final, highest-precedence
// override, for a one-off load; it is validated below the same way
// before being trusted. Same layering for mode: state.js's own default
// ("live" — see its comment) unless `?live=0`/`?live=1` says otherwise.
// Run mode is URL-only now, decided once at boot; there is no topbar
// control left that can change it afterward.
state.baseUrl = resolveDefaultBaseUrl(DEFAULT_BASE_URL, DEFAULT_API_PORT, location);
loadPersisted();
state.mode = resolveBootMode(params, state.mode);
state.lang = loadLang();
applyTheme();
applyLang(state.lang);
const rawBootBaseUrl = resolveBootBaseUrl(params, state.baseUrl);
if (rawBootBaseUrl !== state.baseUrl) {
// A `?base=` param was actually supplied for this load -- validate it
// instead of trusting it silently (the same
// "172.16.101.42:8076"-style incident this normalisation exists for,
// just arriving via the URL instead of a typed field). An invalid
// value is REJECTED, not guessed at: whatever state.baseUrl already
// resolved to (the persisted value, or the deploy default) stands.
const result = normalizeBaseUrl(rawBootBaseUrl);
if (result.ok) {
state.baseUrl = result.url;
} else {
showNotice("error", `${t("invalidBaseUrlNotice")} (?base=${rawBootBaseUrl})`);
}
}
let api = new Api(state.baseUrl);
// turn_id -> Turn[], keyed by session_id: keeps a conversation's in-tab
// progress when the analyst switches away and back, so flipping between
// sessions in the sidebar never discards what was just asked (see
// switchToSession). Session-local to this tab; never persisted.
const turnsCache = new Map();
// Turn ids the signed-in analyst has confirmed ("پاسخ درست است") in the
// sessions opened so far -- what each turn card's confirmation toggle reads
// its state from, so re-rendering a card (or switching away and back) shows
// the same state. Turn ids are unique, so one set serves every session.
// Declared here, above the boot calls below, for the reason
// HEALTH_STATE_LABEL_KEY is: a const read before its line has run is a
// ReferenceError (see tests/web_ui/test_web_ui_boot_order.py).
const confirmedTurnIds = new Set();
wireComposer();
wireTopbar();
wireSidebar();
wireMemoryPanel();
setMode(state.mode);
tickClock();
setInterval(tickClock, 1000);
/* ── Theme / user menu ─────────────────────────────────────────────── */
function wireTopbar() {
wireUserMenu();
$("live-key-save").addEventListener("click", () => {
const val = $("live-key-input").value;
if (!val.trim()) return;
setApiKey(val);
$("live-key-input").value = "";
updateKeyStatus(); // leaves the "rejected" state: a new key was given
showNotice("ok", t("apiKeyStoredNotice"));
refreshHealth();
});
// Per-analyst, persisted, and read fresh on every ask (see askLive).
const interpretToggle = $("interpret-toggle");
interpretToggle.checked = state.interpret;
interpretToggle.addEventListener("change", () => {
persistInterpret(interpretToggle.checked);
});
$("live-key-change").addEventListener("click", () => {
// Reveals the entry field without touching the stored key, so a
// mistyped replacement does not leave the analyst with neither.
$("live-key-entry").hidden = false;
$("live-key-change").hidden = true;
$("live-key-input").focus();
});
$("live-key-clear").addEventListener("click", () => {
clearApiKey();
$("live-key-input").value = "";
updateKeyStatus();
showNotice("warn", t("apiKeyClearedNotice"));
});
updateKeyStatus();
updateThemeSegment();
updateLangSegment();
}
/** ADR-004 part 1: populate the account menu's "درخواستهای دسترسی"
* (access requests) status list -- the minimal place an analyst can see
* their own requests' status and, once denied, the reason. Reuses the
* account/menu area (theme/language/API key already live there) instead
* of adding a new panel, and refreshes on every menu open rather than
* polling, since this list only ever changes as a side effect of the
* analyst's own actions (submitting a request) or an admin's (approving/
* denying one) elsewhere.
*
* LIVE mode only -- SIMULATED mode has no server-side request store to
* read (turnCtx().onRequestAccess never actually submits one in that
* mode either). Every value rendered here (column name, denial reason) is
* server-supplied and untrusted -- built with `textContent` assignments
* only, the same rule turn.js's own denied-column rendering follows;
* never string-built HTML.
*/
async function refreshAccessRequestsList() {
const host = $("access-requests-list");
if (!host) return;
const showEmpty = (message) => {
host.innerHTML = "";
const p = document.createElement("p");
p.className = "access-requests-empty";
p.textContent = message;
host.appendChild(p);
};
if (state.mode !== "live") {
showEmpty(t("accessRequestsEmpty"));
return;
}
const STATUS_LABELS = {
open: t("accessRequestStatusOpen"),
approved: t("accessRequestStatusApproved"),
denied: t("accessRequestStatusDenied"),
};
try {
const { access_requests: rows } = await api.listAccessRequests();
if (!rows || !rows.length) {
showEmpty(t("accessRequestsEmpty"));
return;
}
host.innerHTML = "";
for (const row of rows) {
const item = document.createElement("div");
item.className = "access-request-item";
const columnEl = document.createElement("span");
columnEl.className = "access-request-column";
columnEl.textContent = row.column_name;
item.appendChild(columnEl);
const statusEl = document.createElement("span");
statusEl.className = `access-request-status access-request-status-${row.status}`;
statusEl.textContent = STATUS_LABELS[row.status] || row.status;
item.appendChild(statusEl);
if (row.status === "denied" && row.resolution_note) {
const reasonEl = document.createElement("p");
reasonEl.className = "access-request-reason";
reasonEl.textContent = row.resolution_note;
item.appendChild(reasonEl);
}
host.appendChild(item);
}
} catch (err) {
// A missing/unsupported endpoint or a network error must not break
// the rest of the menu -- shown as the same empty-state paragraph,
// with the real message when there is one.
showEmpty((err && err.message) || t("accessRequestsEmpty"));
}
}
/** User menu: theme, language, API key — one popover, not topbar clutter. */
function wireUserMenu() {
const btn = $("user-menu-btn");
const panel = $("user-menu-panel");
if (!btn || !panel) return;
const close = () => {
panel.hidden = true;
btn.setAttribute("aria-expanded", "false");
};
const open = () => {
panel.hidden = false;
btn.setAttribute("aria-expanded", "true");
refreshAccessRequestsList();
};
btn.addEventListener("click", () => {
if (panel.hidden) open();
else close();
});
document.addEventListener("click", (e) => {
if (panel.hidden) return;
if (!$("user-menu").contains(/** @type {Node} */ (e.target))) close();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !panel.hidden) {
close();
btn.focus();
}
});
$("theme-segment").querySelectorAll("button[data-theme-val]").forEach((b) => {
b.addEventListener("click", () => {
const val = b.getAttribute("data-theme-val");
if (val === "system" || val === "light" || val === "dark") {
persistTheme(val);
applyTheme();
updateThemeSegment();
}
});
});
$("lang-segment").querySelectorAll("button[data-lang]").forEach((b) => {
b.addEventListener("click", () => {
const lang = b.getAttribute("data-lang");
if (lang === "fa" || lang === "en") {
state.lang = lang;
setLang(lang);
updateLangSegment();
updateKeyStatus();
setMode(state.mode); // refresh foot strings in the new language
}
});
});
}
function updateThemeSegment() {
document.querySelectorAll("#theme-segment button[data-theme-val]").forEach((b) => {
b.classList.toggle("active", b.getAttribute("data-theme-val") === state.theme);
});
}
function updateLangSegment() {
document.querySelectorAll("#lang-segment button[data-lang]").forEach((b) => {
b.classList.toggle("active", b.getAttribute("data-lang") === state.lang);
});
}
/* ── API key state (topbar) ────────────────────────────────────────
* Three states, because "a key is stored" and "the server accepts it"
* are different facts and the difference is actionable:
*
* stored -> pill + "تغییر کلید". No entry field: an empty password
* box is what a signed-OUT page shows, and this one is
* always empty (it is cleared after a save), so leaving it
* on screen told every analyst they were logged out.
* unset -> entry field, no clear/change button (nothing to clear).
* rejected -> entry field AND the stored key kept. The key is printed
* exactly once by scripts/issue_api_key.py and is not
* recoverable, so a 401 -- which can be transient, e.g. a
* server restarted mid-configuration -- must not destroy
* the analyst's only copy of it. They can retype it, or
* clear it deliberately.
*/
function updateKeyStatus(rejected = false) {
const el = $("live-key-status");
const entry = $("live-key-entry");
const change = $("live-key-change");
const clear = $("live-key-clear");
const stored = hasApiKey();
if (rejected) {
el.textContent = t("apiKeyRejected");
el.className = "live-key-status unset";
} else if (stored) {
el.textContent = t("apiKeySaved");
el.className = "live-key-status set";
} else {
el.textContent = t("apiKeyUnset");
el.className = "live-key-status unset";
}
const showEntry = rejected || !stored;
entry.hidden = !showEntry;
change.hidden = showEntry;
clear.hidden = !stored;
}
/** Open the user menu and focus the API-key input — used on first live
* use and after a 401. Never puts the key itself into the input. */
function promptForApiKey(message, { rejected = false } = {}) {
const panel = $("user-menu-panel");
const btn = $("user-menu-btn");
if (panel && btn) {
panel.hidden = false;
btn.setAttribute("aria-expanded", "true");
}
updateKeyStatus(rejected);
showNotice("warn", message);
$("live-key-input").focus();
}
/* ── Mode switch ───────────────────────────────────────────────────── */
function setMode(mode) {
state.mode = mode;
updateKeyStatus();
const foot = $("foot-mode");
if (mode === "simulated") {
foot.textContent =
state.lang === "en"
? "Demo mode — data is synthetic; no real query has run."
: "حالت نمایشی — دادهها از پیش تعریفشده و کاملاً مصنوعیاند؛ هیچ پرسوجوی واقعی اجرا نشده است.";
setHealth(true, true, true, "simulated");
hideDiagnosisBanner($("api-diagnosis"));
} else {
foot.textContent =
state.lang === "en"
? `Live API — backend: ${state.baseUrl}`
: `حالت زندهٔ API — بکاند: ${state.baseUrl}`;
refreshHealth();
}
refreshSessionsForMode();
}
// The dot alone carried the state for nobody but a reader who both sees
// colour and can tell #10b981 apart from #e5484d at a glance. WCAG 1.4.1:
// colour may never be the *only* carrier. Two populations got nothing from
// the old markup — a colour-blind analyst staring at the topbar, and a
// screen-reader user for whom #health's role="status" aria-live="polite"
// read "API LLM DB" on every poll, identically, whatever the state. The
// fix is the state word itself, in two places: as real, visible text next
// to the name (so a colour-blind reader sees the difference, not just a
// hue) and as the pill's aria-label (so the live region announces "API:
// down" instead of re-reading three names that never change). Values from
// /health (h.llmDetail, h.dbDetail, forwarded to setHealth as `label`) are
// never interpolated into markup here — see DESIGN-INVARIANTS.md §1.3.
function setHealth(api_, llm, db, label) {
const dot = (ok) => (ok === null ? "unknown" : ok ? "ok" : "down");
const host = $("health");
host.replaceChildren(
...[["API", api_], ["LLM", llm], ["DB", db]].map(([name, ok]) => {
const state = dot(ok);
const stateLabel = t(HEALTH_STATE_LABEL_KEY[state]);
const dotEl = document.createElement("span");
dotEl.className = `dot dot-${state}`;
const nameEl = document.createElement("span");
nameEl.className = "pill-name";
nameEl.textContent = name;
const stateEl = document.createElement("span");
stateEl.className = "pill-state";
stateEl.textContent = stateLabel;
const pill = document.createElement("span");
pill.className = "pill";
pill.append(dotEl, nameEl, stateEl);
// aria-label wins over the pill's own text nodes as its accessible
// name, which is what keeps the live-region announcement a clean
// "API: down" rather than a run-on of three separately-read spans.
pill.setAttribute("aria-label", `${name}: ${stateLabel}`);
return pill;
}),
);
host.title = label;
}
async function refreshHealth() {
const seq = ++healthCheckSeq;
try {
const h = await api.health();
// A 200 whose body is not the API's health response is some other
// service answering at this address, not a healthy API: judged here,
// not trusted as "API up".
if (!isApiHealthShape(h.raw)) {
throw new ApiError("/health did not return the API's health response", 200, CODE_UNEXPECTED_PAYLOAD);
}
// The tooltip carries the backend's own reason for each light, so a
// red LLM indicator says WHY rather than leaving the reader to guess
// between a wrong host, a wrong key, and an endpoint we never call.
const lines = [`/health: ${h.status}, model ${h.model || "?"}`];
if (h.llmDetail) lines.push(`LLM: ${h.llmDetail}`);
if (h.dbDetail) lines.push(`DB: ${h.dbDetail}`);
setHealth(h.api, h.llm, h.db, lines.join("\n"));
hideDiagnosisBanner($("api-diagnosis"));
} catch (err) {
// TRANSPORT_ERROR (api.js's health()) means the fetch itself never
// got a response -- unreachable host, or (the incident this branch
// exists for) a CORS rejection that looks identical to it from the
// page's point of view. err.message already carries
// describeTransportFailure's bilingual, actionable hint (naming this
// page's own origin and CORS_ALLOWED_ORIGINS); a real HTTP error
// response (e.g. 500) falls through to the plain fallback instead,
// since that is a live backend, not a transport problem.
const hint = err && err.code === "TRANSPORT_ERROR" && err.message
? err.message
: "بکاند در دسترس نیست — uvicorn api.server:app را اجرا کنید یا حالت نمایشی را انتخاب کنید";
setHealth(false, false, false, hint);
// The tooltip above is read by nobody who does not hover the lights; a
// wrong address or a refused page is said once, in the banner.
diagnoser.reset();
const diagnosis = await diagnoser.diagnose(err);
// A newer check started while this one was probing: its answer wins.
if (seq === healthCheckSeq) showDiagnosisBanner($("api-diagnosis"), diagnosis, "analyst");
}
}
/* ── Sessions (sidebar) ────────────────────────────────────────────────
* The sidebar's own index (`state.sessions`) and the active transcript
* (`state.turns`) are two separate concerns: the index lists every
* conversation (title, recency, turn count); the transcript is whichever
* ONE conversation is currently open. Switching sessions never mutates
* the index itself, only which transcript is loaded. */
function renderSessionSidebar() {
const host = $("session-list-host");
host.innerHTML = "";
host.appendChild(renderSessionList(state.sessions, {
selectedId: state.sessionId,
onSelect: (sessionId) => { if (!state.busy) switchToSession(sessionId); },
onRename: (sessionId, title) => renameSession(sessionId, title),
onDelete: (sessionId) => deleteSessionAndFollowUp(sessionId),
}));
}
/** (Re)resolves and loads the active session for the CURRENT mode.
* Called on boot and every time the mode switch is clicked — simulated
* and live modes each have their own, entirely separate, session index. */
async function refreshSessionsForMode() {
if (state.mode === "live") {
if (!hasApiKey()) {
state.sessions = [];
// Without this, switching from simulated to live-without-a-key left
// whatever simulated transcript (and its sample-story chips — B5)
// was already on screen just sitting there under the "live" mode
// switch, which is exactly the demo-scaffolding-in-production leak
// this decision exists to close.
resetTranscript();
renderTranscriptFromTurns([]);
renderSessionSidebar();
return;
}
try {
const res = await api.listSessions();
state.sessions = res.sessions || [];
} catch {
// No v2 support yet, unreachable backend, a blocked cross-origin
// request, or an expired key — the sidebar just shows no
// conversations rather than an error banner; askLive's own error
// handling covers the moment the analyst actually tries to do
// something.
//
// The active session MUST be cleared, not merely left alone. This
// used to `return` here with state.sessionId untouched, so a failure
// to list left whatever was active before — and if the analyst had
// been in simulated mode, that was a scripted id from data.js. The
// next question then went to the real backend as
// POST /v2/sessions/s_demo_1404/turns: a demo id, invented locally,
// sent to a server that has never heard of it.
//
// Clearing it means ensureLiveSession() opens a real one on the next
// question, which is the only correct thing to do when this mode's
// own index could not be read.
state.sessions = [];
state.sessionId = null;
persistSessionId(null);
resetTranscript();
renderTranscriptFromTurns([]);
renderSessionSidebar();
return;
}
} else {
state.sessions = SCENARIO_SESSIONS.map((s) => ({ ...s }));
}
const resolved = resolveActiveSessionId(state.sessionId, state.sessions);
if (resolved) {
await switchToSession(resolved);
} else {
state.sessionId = null;
resetTranscript();
renderTranscriptFromTurns([]);
renderSessionSidebar();
}
}
/** Loads *sessionId*'s transcript into the transcript pane. Stashes the
* outgoing session's turns in `turnsCache` first (so switching back
* later restores exactly what was there, instead of refetching and
* losing anything asked in this tab), then either restores from that
* cache or loads fresh — from the simulated scenario data, or from the
* real GET /v2/sessions/{sid} transcript endpoint in live mode. */
async function switchToSession(sessionId) {
if (sessionId === state.sessionId && state.turns.length > 0) {
renderSessionSidebar();
return;
}
if (state.sessionId) turnsCache.set(state.sessionId, state.turns);
persistSessionId(sessionId);
const cached = turnsCache.get(sessionId);
if (cached) {
state.turns = cached;
} else if (state.mode === "live") {
try {
const session = await api.getSession(sessionId);
state.turns = session.turns || [];
} catch (err) {
handleLiveError(err);
state.turns = [];
}
await loadConfirmedTurns(sessionId);
} else {
state.turns = getSimulatedSessionTurns(sessionId);
}
state.nextScriptedIndex = 0;
renderTranscriptFromTurns(state.turns);
renderSessionSidebar();
}
/** Fills confirmedTurnIds from the server for one session. Best effort: a
* failure leaves the toggles unpressed rather than blocking the transcript
* (the server stays the source of truth, and pressing a toggle is
* idempotent). */
async function loadConfirmedTurns(sessionId) {
try {
const found = await api.listConfirmations(sessionId);
for (const id of found.turn_ids || []) confirmedTurnIds.add(id);
} catch {
// Not fatal; see above.
}
}
/** Replaces the whole transcript pane with *turns*, already-resolved (no
* pipeline animation — these are turns from switching sessions, not a
* freshly asked question). */
function renderTranscriptFromTurns(turns) {
const host = $("transcript");
host.innerHTML = "";
for (const turn of turns) {
const card = createTurnCard(turn, turnCtx());
host.appendChild(card.el);
}
if (turns.length === 0) renderTranscriptEmptyState();
refreshSampleButtonsDisabledState();
}
/** The empty-conversation placeholder — DESIGN.md §5.1's resolution for
* the sample-story chips that used to sit inside the composer card
* permanently (overlapping #question's placeholder text) and, per
* decision B5, have no business appearing in a live production session
* at all. Rebuilt from scratch on every call rather than toggled with
* `hidden`, since it must reflect whichever mode is CURRENT, not
* whichever mode last rendered it. Only called while the active
* session's transcript is empty — see renderTranscriptFromTurns and
* appendTurnWithAnimation/askLive's removal of it once a turn lands. */
function renderTranscriptEmptyState() {
const empty = document.createElement("div");
empty.className = "transcript-empty";
empty.id = "transcript-empty";
const hint = document.createElement("p");
hint.className = "transcript-empty-hint";
hint.textContent = t("transcriptEmptyHint");
empty.appendChild(hint);
if (state.mode === "simulated") {
const wrap = document.createElement("div");
wrap.className = "samples";
wrap.id = "samples";
const label = document.createElement("span");
label.className = "samples-label";
label.textContent = t("samples");
wrap.appendChild(label);
empty.appendChild(wrap);
renderSamples(wrap);
} else if (state.mode === "live" && hasApiKey()) {
// Questions this deployment's examples.yaml curates, offered as
// one-click starts. Filled in when they arrive; if they never do (an
// older server, no examples, an error), the hint above is the whole
// empty state, as before.
const wrap = document.createElement("div");
wrap.className = "samples start-examples";
wrap.id = "start-examples";
wrap.hidden = true;
empty.appendChild(wrap);
renderStartExamples(wrap);
}
$("transcript").appendChild(empty);
}
/** The start questions, fetched once per page load (they change only when
* the operator edits examples.yaml); a failed fetch is not remembered, so
* the next empty conversation tries again. */
let startExamplesPromise = null;
async function renderStartExamples(wrap) {
if (!startExamplesPromise) {
startExamplesPromise = api.listExamples(6).then(
(body) => (Array.isArray(body && body.examples) ? body.examples : []),
() => { startExamplesPromise = null; return []; },
);
}
const examples = await startExamplesPromise;
if (!wrap.isConnected || !examples.length) return;
const label = document.createElement("span");
label.className = "samples-label";
label.textContent = t("startExamples");
wrap.appendChild(label);
for (const ex of examples) {
const question = typeof ex.question === "string" ? ex.question : "";
if (!question) continue;
const btn = document.createElement("button");
btn.className = "sample start-example";
btn.type = "button";
btn.textContent = question;
btn.dir = "auto";
btn.addEventListener("click", () => {
if (state.busy) return;
$("question").value = question;
ask();
});
wrap.appendChild(btn);
}
wrap.hidden = false;
}
/** Removes the empty-state placeholder (and, with it, any sample-story
* chips) the instant a turn is about to land in the transcript — called
* right before appendTurnWithAnimation/askLive append the new turn/
* placeholder card, since state.turns itself is not always updated yet
* at that point (askLive's placeholder predates the "done" event's
* addTurn call). */
function clearTranscriptEmptyState() {
document.getElementById("transcript-empty")?.remove();
}
/** A sample button is disabled once its scripted turn has been asked in
* ANY session this tab has touched (turnsCache + the active session) —
* these are one-shot "story" prompts, not per-conversation state. */
function refreshSampleButtonsDisabledState() {
const usedIds = new Set(state.turns.map((t) => t.turn_id));
for (const cached of turnsCache.values()) for (const t of cached) usedIds.add(t.turn_id);
document.querySelectorAll(".sample").forEach((btn) => {
btn.disabled = usedIds.has(btn.dataset.turnId);
});
}
async function renameSession(sessionId, title) {
if (state.mode === "live") {
try {
const updated = await api.renameSession(sessionId, title);
const idx = state.sessions.findIndex((s) => s.session_id === sessionId);
if (idx >= 0) state.sessions[idx] = { ...state.sessions[idx], ...updated };
renderSessionSidebar();
} catch (err) {
handleLiveError(err);
}
return;
}
const idx = state.sessions.findIndex((s) => s.session_id === sessionId);
if (idx >= 0) {
state.sessions[idx] = { ...state.sessions[idx], title };
renderSessionSidebar();
}
}
async function deleteSessionAndFollowUp(sessionId) {
if (state.mode === "live") {
try {
await api.deleteSession(sessionId);
} catch (err) {
handleLiveError(err);
return;
}
}
state.sessions = state.sessions.filter((s) => s.session_id !== sessionId);
turnsCache.delete(sessionId);
if (state.sessionId !== sessionId) {
renderSessionSidebar();
return;
}
const next = resolveActiveSessionId(null, state.sessions);
if (next) {
await switchToSession(next);
} else {
state.sessionId = null;
persistSessionId(null);
resetTranscript();
renderTranscriptFromTurns([]);
renderSessionSidebar();
}
}
async function createNewSession() {
const nowIso = new Date().toISOString();
if (state.mode === "live") {
if (!hasApiKey()) {
promptForApiKey("برای ساخت گفتگوی جدید در حالت زندهٔ API، ابتدا کلید API خود را وارد کنید.");
return;
}
try {
const session = await api.createSession();
const sid = session.session_id || session.id;
state.sessions.unshift({
session_id: sid, title: "گفتگوی جدید", created_at: nowIso, last_active_at: nowIso,
turn_count: 0, expires_at: session.expires_at || null,
});
turnsCache.set(sid, []);
await switchToSession(sid);
} catch (err) {
handleLiveError(err);
}
return;
}
const sid = `s_local_${Date.now()}`;
state.sessions.unshift({
session_id: sid, title: "گفتگوی جدید", created_at: nowIso, last_active_at: nowIso,
turn_count: 0, expires_at: null,
});
turnsCache.set(sid, []);
await switchToSession(sid);
}
/** Reflects a just-completed turn back into the sidebar's own summary
* (turn count, recency) — the index and the transcript would otherwise
* silently drift apart the moment the very first question is asked. */
function bumpActiveSessionMeta() {
const idx = state.sessions.findIndex((s) => s.session_id === state.sessionId);
if (idx < 0) return;
state.sessions[idx] = {
...state.sessions[idx], turn_count: state.turns.length, last_active_at: new Date().toISOString(),
};
renderSessionSidebar();
}
function wireSidebar() {
$("btn-new-session").addEventListener("click", createNewSession);
$("sidebar-toggle").addEventListener("click", () => {
const body = document.querySelector(".app-body");
const open = body.classList.toggle("sidebar-open");
$("sidebar-toggle").setAttribute("aria-expanded", String(open));
});
}
/* ── Memory panel ──────────────────────────────────────────────────────
* Reachable from the topbar, beside the API-key controls. Memory is
* created EXPLICITLY only — the panel's own "set" controls, or a "📌 به
* خاطر بسپار" pin on an editable assumption chip (see turnCtx's onPin
* below) — never inferred by this UI. */
function wireMemoryPanel() {
$("memory-toggle").addEventListener("click", () => {
if ($("memory-panel").hidden) openMemoryPanel();
else closeMemoryPanel();
});
$("memory-panel-close").addEventListener("click", closeMemoryPanel);
$("memory-overlay").addEventListener("click", closeMemoryPanel);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !$("memory-panel").hidden) closeMemoryPanel();
});
}
async function openMemoryPanel() {
$("memory-panel").hidden = false;
$("memory-overlay").hidden = false;
$("memory-toggle").setAttribute("aria-expanded", "true");
await loadAndRenderMemory();
}
function closeMemoryPanel() {
$("memory-panel").hidden = true;
$("memory-overlay").hidden = true;
$("memory-toggle").setAttribute("aria-expanded", "false");
}
async function loadAndRenderMemory(opts = {}) {
if (state.mode === "live") {
if (!hasApiKey()) {
if (opts.quiet) return;
promptForApiKey("برای مشاهدهٔ حافظهٔ تحلیلی، ابتدا کلید API خود را وارد کنید.");
renderMemoryPanelBody({ entries: [], rememberable: [] });
return;
}
try {
state.memory = await api.getMemory();
} catch (err) {
handleLiveError(err);
return;
}
} else if (!state.memory) {
state.memory = JSON.parse(JSON.stringify(SCENARIO_MEMORY));
}
// `quiet` is for the pin path, which needs the rememberable set loaded
// but must not disturb a panel the analyst has not opened.
if (!opts.quiet) renderMemoryPanelBody(state.memory);
}
function renderMemoryPanelBody(memory) {
const host = $("memory-panel-host");
host.innerHTML = "";
host.appendChild(renderMemoryPanel(memory, {
onSetValue: (key, value, errorEl) => setMemoryValue(key, value, errorEl),
onClearOne: (key) => clearMemoryEntry(key),
onClearAll: () => clearAllMemory(),
}));
}
async function setMemoryValue(key, value, errorEl) {
if (state.mode === "live") {
try {
await api.putMemory(key, value);
state.memory = await api.getMemory();
renderMemoryPanelBody(state.memory);
} catch (err) {
// A 422 (bad value) renders inline, next to the field it came
// from — not as a page-level notice — since the panel is the only
// context that makes it actionable. Anything else (401/429/network)
// gets the usual page-level handling.
if (err instanceof ApiError && !(err instanceof UnauthorizedError) && !(err instanceof RateLimitError)) {
errorEl.textContent = err.message;
} else {
handleLiveError(err);
}
}
return;
}
upsertSimulatedMemory(key, value);
renderMemoryPanelBody(state.memory);
}
async function clearMemoryEntry(key) {
if (state.mode === "live") {
try {
await api.deleteMemoryEntry(key);
state.memory = await api.getMemory();
} catch (err) {
handleLiveError(err);
return;
}
} else {
state.memory.entries = state.memory.entries.filter((e) => e.key !== key);
}
renderMemoryPanelBody(state.memory);
}
async function clearAllMemory() {
if (state.mode === "live") {
try {
await api.clearMemory();
state.memory = await api.getMemory();
} catch (err) {
handleLiveError(err);
return;
}
} else {
state.memory.entries = [];
}
renderMemoryPanelBody(state.memory);
}
/** Simulated-mode-only: upserts one memory entry locally, matching the
* field label from `rememberable` when the key is a known one (the panel's
* own "set" controls always are; a pinned assumption's field usually is
* too, but falls back to using the field name verbatim if not). */
function upsertSimulatedMemory(key, value) {
if (!state.memory) state.memory = JSON.parse(JSON.stringify(SCENARIO_MEMORY));
const knownField = state.memory.rememberable.find((r) => r.key === key);
const field = (knownField && knownField.field) || key;
const entry = { key, field, value, updated_at: new Date().toISOString(), applicable: true };
const idx = state.memory.entries.findIndex((e) => e.key === key);
if (idx >= 0) state.memory.entries[idx] = entry;
else state.memory.entries.push(entry);
}
/* ── Composer ──────────────────────────────────────────────────────── */
/** Populates *wrap* (the empty-transcript state's .samples container —
* see renderTranscriptEmptyState) with one button per scripted turn. */
function renderSamples(wrap) {
for (const turn of SCENARIO.turns) {
const btn = document.createElement("button");
btn.className = "sample";
btn.type = "button";
btn.textContent = turn.question;
btn.dataset.turnId = turn.turn_id;
btn.dir = "auto";
btn.addEventListener("click", () => {
if (state.busy) return;
$("question").value = turn.question;
ask();
});
wrap.appendChild(btn);
}
}
function markSampleUsed(turnId) {
const btn = document.querySelector(`.sample[data-turn-id="${turnId}"]`);
if (btn) btn.disabled = true;
}
function wireComposer() {
$("btn-ask").addEventListener("click", ask);
$("question").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
ask();
}
});
}
function showNotice(kind, text) {
const area = $("notice-area");
const div = document.createElement("div");
div.className = `notice ${kind}`;
div.setAttribute("role", kind === "error" ? "alert" : "status");
div.textContent = text;
area.innerHTML = "";
area.appendChild(div);
}
function clearNotice() {
$("notice-area").innerHTML = "";
}
/* ── Ask flow ──────────────────────────────────────────────────────── */
async function ask() {
const q = $("question").value.trim();
if (!q || state.busy) return;
state.busy = true;
$("btn-ask").disabled = true;
clearNotice();
try {
if (state.mode === "live") await askLive(q);
else await askSimulated(q);
} finally {
state.busy = false;
$("btn-ask").disabled = false;
$("question").value = "";
}
}
/* ── Simulated ─────────────────────────────────────────────────────── */
function matchScriptedTurn(q) {
const exact = SCENARIO.turns.find((t) => t.question === q);
if (exact) return exact;
const norm = q.trim();
let best = null, bestScore = 0;
for (const t of SCENARIO.turns) {
const hints = SCENARIO_MATCH_HINTS[t.turn_id] || [];
let score = 0;
for (const h of hints) if (norm.includes(h)) score++;
if (score > bestScore) { bestScore = score; best = t; }
}
return bestScore > 0 ? best : null;
}
async function askSimulated(q) {
const scripted = matchScriptedTurn(q);
if (!scripted) {
showNotice("info", "این پرسش در نسخهٔ نمایشی از پیش تعریف نشده است — یکی از نمونههای «داستان نمونه» را امتحان کنید.");
return;
}
if (findTurn(scripted.turn_id)) {
showNotice("info", "این پرسش پیشتر در همین گفتگو پاسخ داده شده — به پایین اسکرول کنید.");
scrollToTurn(scripted.turn_id);
return;
}
await appendTurnWithAnimation(scripted);
markSampleUsed(scripted.turn_id);
}
async function appendTurnWithAnimation(turn) {
addTurn(turn);
clearTranscriptEmptyState();
const card = createTurnCard(turn, turnCtx());
$("transcript").appendChild(card.el);
scrollIntoViewMaybeSmooth(card.el, { block: "start" });
const failAt = turn.error ? "generate" : (turn.guard && turn.guard.verdict === "rejected" ? "validate" : null);
await runSimulatedStages(card.pipeline.setStage, {
failAt,
onStageSettled: (key) => {
if (key === "understand") card.revealEarly();
},
});
card.revealEarly();
card.revealLate();
scrollSettledResultAboveComposer(card.result || card.el);
bumpActiveSessionMeta();
}
function scrollToTurn(turnId) {
const el = document.getElementById(`turn-${turnId}`);
if (el) {
el.classList.remove("collapsed");
scrollIntoViewMaybeSmooth(el, { block: "start" });
el.animate(
[{ boxShadow: "0 0 0 3px rgba(13,148,136,.5)" }, { boxShadow: "0 0 0 0 rgba(13,148,136,0)" }],
{ duration: 900 },
);
}
}
/* ── Assumption editing / clarification (simulated: re-renders the same
* scripted turn with the edited value patched in locally — there is no
* real inference engine behind the demo, so this simulates what a
* PATCH .../assumptions round trip would change in the UI). ─────────── */
function turnCtx() {
return {
onJumpToTurn: (turnId) => scrollToTurn(turnId),
onEditAssumption: (turnId, field, value) => {
if (state.mode === "live") {
patchLiveAssumption(turnId, field, value);
return;
}
const t = findTurn(turnId);
if (!t) return;
const a = t.ambiguity.assumptions.find((x) => x.field === field);
if (a) a.value = value;
rerenderTurn(t);
showNotice("ok", `مفروضهٔ «${field}» به «${value}» تغییر کرد (شبیهسازی محلی — بدون اجرای مجدد واقعی).`);
},
onClarify: (turnId, field, option) => {
if (state.mode === "live") {
patchLiveAssumption(turnId, field, option);
return;
}
const t = findTurn(turnId);
if (!t) return;
const a = t.ambiguity.assumptions.find((x) => x.field === field);
if (a) { a.value = option; a.source = "question"; }
rerenderTurn(t);
showNotice("ok", `پاسخ شما («${option}») بهعنوان مفروضهٔ صریح ثبت شد (شبیهسازی محلی).`);
},
// "پین کردن" — the ONLY way memory gets created. Never inferred.
onPin: (turnId, field, value) => {
if (state.mode === "live") pinLiveAssumption(field, value);
else pinSimulatedAssumption(field, value);
},
// "دوباره اجرا کن" — the rows_omitted re-run affordance (turn.js's
// result block). Not a PATCH/re-fetch of this exact turn (the
// contract has no such endpoint); it re-asks the same question,
// which is the closest honest equivalent to "run it again".
onRerun: (turnId) => rerunTurn(turnId),
// "ویرایش پرسش" — a guard-rejected turn's failure state (turn.js's
// renderFailureState). Deliberately NOT onRerun: that re-submits the
// exact same question, which would just be rejected again by the
// same PolicyRejection. This puts the original question back in the
// box and stops — an analyst edits it before sending, they don't get
// it resent for them.
onRephrase: (turnId) => {
const t = findTurn(turnId);
if (!t) return;
$("question").value = t.question;
$("question").focus();
scrollIntoViewMaybeSmooth($("question"), { block: "center" });
},
// "پرسش بدون «ستون»" — the targeted action DESIGN-INVARIANTS.md §8's
// table specifies for a denied-column refusal ("Ask without that
// column"), offered only when the guard named exactly one column
// (turn.js reads `turn.guard.subject`). Mirrors onRephrase's own
// model — puts an edited question back in the box and stops, rather
// than resubmitting for the analyst — but seeds an explicit exclusion
// instruction instead of the bare original question: resending the
// identical question would just be denied again by the same policy.
onAskWithoutColumn: (turnId, column) => {
const t = findTurn(turnId);
if (!t) return;
$("question").value = `${t.question} (بدون ستون «${column}»)`;
$("question").focus();
scrollIntoViewMaybeSmooth($("question"), { block: "center" });
},
// "درخواست دسترسی" (ADR-004 part 1) -- the denied-column failure
// state's second action, beside "پرسش بدون «ستون»"
// (DESIGN-INVARIANTS.md §8: "Ask without that column · Request
// access"). LIVE mode really submits it, scoped to the session this
// transcript is currently showing; the requester is stamped
// server-side and the column is re-derived server-side from the audit
// record, so this call sends nothing but the turn's own identifiers.
// SIMULATED mode has no server to send it to, so it is accepted
// locally and shown as such -- mirrors onFlag's own simulated-mode
// behaviour just below.
onRequestAccess: async (turnId, _column) => {
if (state.mode === "live") {
if (!state.sessionId) throw new Error("هیچ گفتگوی زندهای در جریان نیست.");
return await api.submitAccessRequest(state.sessionId, turnId);
}
showNotice(
"ok",
"در حالت نمایشی، درخواستهای دسترسی به سرور واقعی ارسال نمیشوند (فقط شبیهسازی محلی).",
);
return { already_pending: false };
},
// "این عدد درست نیست" (admin panel phase 4). LIVE mode really submits
// it to the backend, against the session this transcript is currently
// showing; SIMULATED mode has no server to send it to, so it is
// accepted locally and shown as such rather than pretending to
// round-trip a demo scenario through a real endpoint.
onFlag: async (turnId, category, note) => {
if (state.mode === "live") {
if (!state.sessionId) throw new Error("هیچ گفتگوی زندهای در جریان نیست.");
await api.submitFeedback(state.sessionId, turnId, category, note);
// The server drops this principal's confirmation of the same turn
// (the later action wins); the local record follows.
confirmedTurnIds.delete(turnId);
return;
}
showNotice("ok", "در حالت نمایشی، گزارشها به سرور واقعی ارسال نمیشوند (فقط شبیهسازی محلی).");
},
// "پاسخ درست است" -- confirms an answer; the positive twin of onFlag.
// LIVE mode really submits it; SIMULATED mode has no server, so it is
// accepted locally and shown as such, like onFlag above.
renderConfirmation: (turn, { onReplacedFlag }) => {
if (!canConfirm(turn)) return null;
const turnId = turn.turn_id;
return renderConfirmationControl(turn, {
confirmed: confirmedTurnIds.has(turnId),
onConfirm: async () => {
if (state.mode === "live") {
if (!state.sessionId) throw new Error("هیچ گفتگوی زندهای در جریان نیست.");
const result = await api.confirmAnswer(state.sessionId, turnId);
confirmedTurnIds.add(turnId);
return result;
}
confirmedTurnIds.add(turnId);
showNotice("ok", "در حالت نمایشی، تأییدها به سرور واقعی ارسال نمیشوند (فقط شبیهسازی محلی).");
return { removed_flags: 0 };
},
onWithdraw: async () => {
if (state.mode === "live") {
if (!state.sessionId) throw new Error("هیچ گفتگوی زندهای در جریان نیست.");
await api.withdrawConfirmation(state.sessionId, turnId);
}
confirmedTurnIds.delete(turnId);
},
// The server removed this analyst's open flag on the turn (the later
// action wins); the flag control mirrors that.
onConfirmed: (result) => {
const replaced = Boolean(result && result.removed_flags > 0);
if (replaced) onReplacedFlag();
return replaced;
},
});
},
};
}
function rerenderTurn(turn) {
const old = document.getElementById(`turn-${turn.turn_id}`);
const card = createTurnCard(turn, turnCtx());
if (old) {
old.replaceWith(card.el);
} else {
clearTranscriptEmptyState();
$("transcript").appendChild(card.el);
// A turn with no card yet lands at the foot of the transcript: in live
// mode, answering a clarification or editing an assumption re-asks the
// question and the server returns it as a NEW turn (TurnEngine.ask
// appends it with its own turn_id). Bring the analyst to its answer,
// as a streamed answer does once it settles (#219).
scrollSettledResultAboveComposer(card.result || card.el);
}
}
function rerunTurn(turnId) {
const t = findTurn(turnId);
if (!t) return;
if (state.mode === "live") {
$("question").value = t.question;
ask();
return;
}
rerunSimulatedTurn(turnId);
}
/** Simulated-mode re-run: restores the turn's REAL data (the version it
* had before rows were stripped for the "reopened conversation" demo —
* see data.js's getSimulatedSessionTurns/FULL_TURNS_BY_ID) rather than
* re-running the ask() pipeline, since askSimulated's own dedup check
* would just report "already answered" for a turn_id already in
* state.turns — which is exactly this one. */
function rerunSimulatedTurn(turnId) {
const full = FULL_TURNS_BY_ID[turnId];
if (!full) {
showNotice("info", "بازاجرای این نوبت در نسخهٔ نمایشی پشتیبانی نمیشود.");
return;
}
const restored = JSON.parse(JSON.stringify(full));
const idx = state.turns.findIndex((t) => t.turn_id === turnId);
if (idx >= 0) state.turns[idx] = restored;
rerenderTurn(restored);
showNotice("ok", "دادهٔ این نوبت دوباره اجرا و بارگذاری شد.");
}
async function pinLiveAssumption(field, value) {
if (!hasApiKey()) {
promptForApiKey("برای بهخاطر سپردن این مقدار، ابتدا کلید API خود را وارد کنید.");
return;
}
try {
// The analyst can pin without ever having opened the memory panel, so
// the rememberable set may not be loaded yet. Fetch it on demand
// rather than assuming the panel populated it.
const rem = () => (state.memory && state.memory.rememberable) || [];
if (!memoryKeyForField(rem(), field)) await loadAndRenderMemory({ quiet: true });
const key = memoryKeyForField(rem(), field);
if (key === null) {
showNotice(
"warn",
`«${field}» جزو مواردی نیست که بتوان بهخاطر سپرد. فهرست موارد قابل ذخیره در پنل حافظه است.`,
);
return;
}
await api.putMemory(key, value);
showNotice("ok", `مفروضهٔ «${field}: ${value}» بهعنوان اولویت ثابت ذخیره شد — از این پس در گفتگوهای بعدی هم اعمال میشود.`);
if (!$("memory-panel").hidden) await loadAndRenderMemory();
} catch (err) {
handleLiveError(err);
}
}
function pinSimulatedAssumption(field, value) {
upsertSimulatedMemory(field, value);
showNotice("ok", `مفروضهٔ «${field}: ${value}» بهعنوان اولویت ثابت ذخیره شد (شبیهسازی محلی) — از این پس در گفتگوهای بعدی هم اعمال میشود.`);
if (!$("memory-panel").hidden) renderMemoryPanelBody(state.memory);
}
/* ── Live ──────────────────────────────────────────────────────────── */
async function ensureLiveSession() {
if (state.sessionId) return state.sessionId;
const session = await api.createSession();
const sid = session.session_id || session.id;
persistSessionId(sid);
const nowIso = new Date().toISOString();
state.sessions.unshift({
session_id: sid, title: "گفتگوی جدید", created_at: nowIso, last_active_at: nowIso,
turn_count: 0, expires_at: session.expires_at || null,
});
renderSessionSidebar();
return sid;
}
async function askLive(q) {
// First use (or after a clear/401): prompt before even attempting the
// network call, rather than let an entirely predictable 401 round-trip
// happen first. A stale/revoked key is still caught below, reactively,
// by the UnauthorizedError branch of handleLiveError.
if (!hasApiKey()) {
promptForApiKey("برای پرسیدن سؤال در حالت زندهٔ API، ابتدا کلید API خود را وارد کنید (از مدیر سامانه بگیرید).");
return;
}
let sessionId;
try {
sessionId = await ensureLiveSession();
} catch (err) {
handleLiveError(err);
return;
}
// Build a placeholder card immediately so the pipeline shows "running"
// while we wait on SSE — filled in from `stage`/`resolved`/etc. events.
let working = emptyTurn(q, sessionId);
clearTranscriptEmptyState();
const card = createTurnCard(working, turnCtx());
$("transcript").appendChild(card.el);
scrollIntoViewMaybeSmooth(card.el, { block: "start" });
card.revealEarly();
card.revealLate();
const stageStates = {};
// Tracks the turn_id of whatever DOM node is CURRENTLY on screen, so the
// lookup below always targets the right element even across the "done"
// event's swap from the placeholder id (`live_...`) to the server's real
// turn_id. Reassigning `working` to `data.turn` (see the "done" case)
// changes `working.turn_id` *before* this function runs; looking the old
// node up by the NEW id would silently find nothing (optional chaining
// swallows it) and leave the stale placeholder element in the DOM forever
// — including every click handler still closed over the placeholder id,
// which broke PATCH .../assumptions (it patched a turn_id that never
// existed server-side).
let renderedTurnId = working.turn_id;
function rebuild() {
const fresh = createTurnCard(working, turnCtx());
for (const [k, v] of Object.entries(stageStates)) fresh.pipeline.setStage(k, v);
document.getElementById(`turn-${renderedTurnId}`)?.replaceWith(fresh.el);
renderedTurnId = working.turn_id;
fresh.revealEarly();
fresh.revealLate();
Object.assign(card, fresh);
}
try {
await api.askTurnStreaming(sessionId, q, (event, data) => {
switch (event) {
case "stage":
stageStates[data.stage] = data.state === "running" ? "running" : data.state === "error" ? "error" : "done";
card.pipeline.setStage(data.stage, stageStates[data.stage]);
break;
case "resolved":
Object.assign(working, { resolved_question: data.resolved_question, basis: data.basis });
rebuild();
break;
case "assumptions":
working.ambiguity = data;
rebuild();
break;
case "sql":
// `sql_display` is the server's layout of `sql` (absent from an
// older server): shown as it is until `done` brings the same
// text, instead of the client formatter's different layout.
Object.assign(working, { sql: data.sql, sql_display: data.sql_display, guard: data.guard });
rebuild();
break;
case "rows":
working.result = { columns: data.columns, rows: data.rows, row_count: data.row_count, truncated: !!data.truncated };
rebuild();
break;
case "llm":
working.llm = data;
rebuild();
break;
case "done":
working = data.turn || working;
addTurn(working);
rebuild();
// rebuild() just Object.assign()'d `fresh` (including its
// `result`/`el`) onto `card`, so these reflect the just-rendered
// final turn -- no separate DOM lookup needed.
scrollSettledResultAboveComposer(card.result || card.el);
bumpActiveSessionMeta();
break;
case "error":
working.error = data;
rebuild();
break;
default:
break;
}
}, { interpret: state.interpret });
} catch (err) {
if (err instanceof V2NotSupportedError) {
document.getElementById(`turn-${working.turn_id}`)?.remove();
handleLiveError(err);
return;
}
// 401 (key revoked mid-session) and 429 (throttled on the turns call
// itself, even though session creation above succeeded) both get the
// same distinguishable, actionable notice as everywhere else in live
// mode — not left to render as a generic TRANSPORT_ERROR inside the
// turn card, which is exactly the "staring at a spinner" failure mode
// this branch exists to avoid.
if (err instanceof UnauthorizedError || err instanceof RateLimitError) {
document.getElementById(`turn-${working.turn_id}`)?.remove();
handleLiveError(err);
return;
}
// A real error code from the server (`api.js`'s `ApiError.code`, e.g.
// "MAINTENANCE_MODE" from `require_not_in_maintenance` on this very
// route, or "INJECTION_ATTEMPT") is preserved here instead of being
// relabelled — turn.js's `FAILURE_BY_CODE` renders the right Persian
// sentence for it. Only a genuine pre-response failure (no `code` at
// all, or `err` is not even an ApiError) falls back to
// "TRANSPORT_ERROR", which is what it actually was.
const code = (err instanceof ApiError && err.code) || "TRANSPORT_ERROR";
working.error = { code, message: err.message };
rebuild();
}
}
async function patchLiveAssumption(turnId, field, value) {
try {
const updated = await api.patchAssumptions(state.sessionId, turnId, [{ field, value }]);
// The server re-asks the question and answers with a new turn (a new
// turn_id, appended to the session); the turn it re-asked keeps its
// card, so it keeps its record too. A reply under the same turn_id
// still replaces that turn in place.
const idx = state.turns.findIndex((t) => t.turn_id === updated.turn_id);
if (idx >= 0) state.turns[idx] = updated;
else state.turns.push(updated);
rerenderTurn(updated);
} catch (err) {
handleLiveError(err);
}
}
function handleLiveError(err) {
if (err instanceof V2NotSupportedError) {
showNotice("warn", "بکاند نسخهٔ گفتگویی v2 را هنوز پشتیبانی نمیکند (404 روی /v2/sessions). حالت به «نمایشی» بازگردانده شد.");
setMode("simulated");
return;
}
// 401: distinguished from a generic ApiError so a rejected/expired key
// never just sits there as an unexplained error — the stored key is
// cleared and the analyst is re-prompted immediately (never shown the
// key itself, only this message).
if (err instanceof UnauthorizedError) {
// Deliberately does NOT clear the stored key. It was printed once by
// scripts/issue_api_key.py and is unrecoverable, and a 401 is not
// always the key's fault -- a server restarted with a different
// API_KEYS_JSON, or an app database briefly unreachable, produces one
// from a perfectly good key. Destroying it here made a transient
// server condition into "find that 43-character string again".
promptForApiKey(
"سرور کلید ذخیرهشده را نپذیرفت (۴۰۱). اگر کلید درست است، ممکن است " +
"سرور تازه ریاستارت شده باشد؛ دوباره امتحان کنید. در غیر این صورت " +
"کلید تازهای وارد کنید — کلید فعلی پاک نشده است.",
{ rejected: true },
);
return;
}
// 429: client-side rate limiting, not a query or model failure — see
// api/middleware.py's RateLimitMiddleware. Surfaced with its own
// wording (and the retry-after it carries) instead of the generic
// "backend error" banner, which is exactly what let this exact
// response get misread as a query failure once already (see this
// branch's PR description).
if (err instanceof RateLimitError) {
const retry = err.retryAfterSeconds != null
? `${Math.max(1, Math.ceil(err.retryAfterSeconds))} ثانیهٔ دیگر`
: "چند لحظهٔ دیگر";
showNotice("warn", `محدودیت نرخ درخواست (این خطای محدودسازی سمت کلاینت است، نه خطای پرسش یا مدل) — ${retry} دوباره امتحان کنید. ${err.message}`);
return;
}
if (err instanceof ApiError) {
// A recognised backend code (MAINTENANCE_MODE, SERVER_OVERLOAD,
// INJECTION_ATTEMPT, ...) gets the same Persian sentence a turn-card
// failure banner would show — `render/turn.js`'s `FAILURE_BY_CODE`,
// the one table for both. "TRANSPORT_ERROR" is deliberately excluded
// here: outside a turn card this message is `describeTransportFailure`'s
// own diagnostic (host/CORS guess), not backend text, and it is worth
// more to an analyst here than the generic sentence would be.
const entry = err.code && err.code !== "TRANSPORT_ERROR" ? FAILURE_BY_CODE[err.code] : null;
showNotice("error", entry ? entry.lead : `خطای بکاند: ${err.message}`);
return;
}
showNotice("error", `خطای غیرمنتظره: ${err.message}`);
}
function emptyTurn(question, sessionId) {
return {
turn_id: `live_${Date.now()}`,
session_id: sessionId,
index: state.turns.length + 1,
question,
resolved_question: null,
basis: { kind: "fresh", refines_turn_id: null, composition: "none", inherited: [] },
sql: null,
ambiguity: { is_ambiguous: false, assumptions: [], clarifications: [] },
guard: null,
result: null,
interpretation: null,
tier: null,
warnings: [],
llm: null,
timings: {},
error: null,
};
}
/* ── Footer clock ──────────────────────────────────────────────────── */
function tickClock() {
$("foot-time").textContent = new Date().toLocaleTimeString("fa-IR");
}