Two Paliadin chat surfaces shared a user but not their conversation:
the inline drawer (paliadin-widget.ts) maintained `paliadin:widget:session`
+ `paliadin:widget:history:` while the standalone /paliadin page used
`paliadin:session` + `paliadin:history:`. A turn typed in the drawer
never surfaced on /paliadin and vice versa, and a localStorage wipe
tossed everything.
Fix in three coordinated parts:
1. **Shared session id.** The widget now uses the same `paliadin:session`
key the standalone page already uses. One-time migration in
bootSession copies any legacy `paliadin:widget:session` across so
existing users keep their conversation thread, then deletes the legacy
key. The widget's HISTORY_PREFIX also drops the `widget:` namespace
so both surfaces' render-caches address the same bucket.
2. **DB-driven history.** New endpoint:
GET /api/paliadin/history?session=<id>&limit=<N>
Returns the caller's turns for the session, oldest → newest,
gated by PaliadinOwnerEmail (same gate as POST /api/paliadin/turn).
Backed by paliadinDB.ListHistoryForSession, which mirrors the
existing visibility predicate (own rows always; all rows for
global_admin). Default limit 50, capped at 200.
3. **Hydrate-on-mount, hydrate-on-open.**
- paliadin.ts (standalone page): DOMContentLoaded calls
hydrateFromServer() right after renderHistory() seeds from
localStorage. DB rows replace the cache when present.
- paliadin-widget.ts (inline drawer): revealIfOwner kicks
hydrateFromServer in the background after rehydrateHistory paints
the cache. openDrawer() also calls hydrateFromServer so a turn the
user typed on /paliadin since the last drawer-open shows up
without a manual reload.
Reconciliation: DB > localStorage when DB has rows. DB call fails or
returns empty → keep showing whatever's in cache (offline cushion).
This kills the trap klaus warned about (paliad#19): every render
reconciles against the server, no first-paint short-circuits.
Schema: zero migrations. paliad.paliadin_turns already carries
session_id + user_message + response + ts since the t-paliad-146 PoC;
this slice just adds a typed read path.
Backwards compatible: the standalone /paliadin page's session key is
unchanged; only the widget migrates onto it.
Builds + tests green; i18n unchanged.
Refs: m/paliad#19 (localStorage short-circuit), m/paliad#20 (inline modal),
docs/design-paliadin-inline-2026-05-08.md §3.4.
599 lines
21 KiB
TypeScript
599 lines
21 KiB
TypeScript
// paliadin-widget.ts — runtime for the inline Paliadin floating button +
|
|
// slide-out drawer (t-paliad-161).
|
|
//
|
|
// Lifecycle:
|
|
// 1. On DOMContentLoaded, fetch /api/me. If the email matches the
|
|
// Paliadin owner gate (the same gate the standalone /paliadin
|
|
// route uses) AND the route is one where the widget shows, reveal
|
|
// the trigger button.
|
|
// 2. Click trigger or press Cmd+J / Ctrl+J → open drawer + populate
|
|
// starter prompts from paliadin-starters.ts.
|
|
// 3. Submit form → POST /api/paliadin/turn with structured context
|
|
// from computePaliadinContext() → consume the SSE stream → render
|
|
// assistant bubble.
|
|
// 4. Conversation history persists in localStorage per session id.
|
|
//
|
|
// Notes:
|
|
// - Cmd+K is reserved for the global search palette (client/search.ts).
|
|
// The widget uses Cmd+J / Ctrl+J as the keyboard trigger.
|
|
// - The standalone /paliadin page's client (client/paliadin.ts) is
|
|
// unchanged — this widget reuses /api/paliadin/turn but ships its
|
|
// own UI and history bucket so the two surfaces stay independent.
|
|
// - Visibility predicate mirrors paliadin-context.shouldSendContext()
|
|
// so the widget never sends a turn from a route where it shouldn't
|
|
// show.
|
|
|
|
import { initI18n, getLang, t } from "./i18n";
|
|
import { computePaliadinContext, shouldSendContext, routeNameFor } from "./paliadin-context";
|
|
import { startersFor, type Starter } from "./paliadin-starters";
|
|
import { renderResponseHTML } from "./paliadin-render";
|
|
import { pollForLateResponse, type LateTurn, type LatePollHandle } from "./paliadin-late-poll";
|
|
|
|
interface MeResponse {
|
|
id: string;
|
|
email: string;
|
|
display_name?: string;
|
|
global_role?: string;
|
|
}
|
|
|
|
interface HistoryEntry {
|
|
role: "user" | "assistant";
|
|
text: string;
|
|
ts: string;
|
|
}
|
|
|
|
interface TurnResponse {
|
|
turn_id: string;
|
|
sse_url: string;
|
|
}
|
|
|
|
// Shared session key — the inline drawer and the standalone /paliadin
|
|
// page must use the same browser-session id so both surfaces show the
|
|
// same conversation. Migration on first run: if a legacy
|
|
// `paliadin:widget:session` exists but the shared `paliadin:session`
|
|
// does not, copy across so the user doesn't lose drawer state on the
|
|
// rollover.
|
|
const SESSION_KEY = "paliadin:session";
|
|
const LEGACY_WIDGET_SESSION_KEY = "paliadin:widget:session";
|
|
// History bucket — render-cache only; DB is source of truth (server
|
|
// hydrates via /api/paliadin/history on every mount). The cache is keyed
|
|
// by session id so a session reset gives a clean slate.
|
|
const HISTORY_PREFIX = "paliadin:history:";
|
|
|
|
let sessionId: string;
|
|
let history: HistoryEntry[] = [];
|
|
let drawerOpen = false;
|
|
let activeStream: EventSource | null = null;
|
|
let pending = false;
|
|
// Late-response pollers per turn_id (see paliadin-late-poll.ts).
|
|
const lateWidgetPolls = new Map<string, LatePollHandle>();
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const trigger = document.getElementById("paliadin-widget-trigger");
|
|
const drawer = document.getElementById("paliadin-widget-drawer");
|
|
if (!trigger || !drawer) return; // page didn't include the widget — skip silently
|
|
initI18n();
|
|
bootSession();
|
|
void revealIfOwner();
|
|
wireTrigger();
|
|
wireDrawerControls();
|
|
wireForm();
|
|
wireKeyboardShortcut();
|
|
});
|
|
|
|
function bootSession(): void {
|
|
let s = localStorage.getItem(SESSION_KEY);
|
|
if (!s) {
|
|
// One-time migration: previous widget builds wrote
|
|
// `paliadin:widget:session` instead of the shared key. Carry over
|
|
// the existing id so the user keeps their conversation thread.
|
|
const legacy = localStorage.getItem(LEGACY_WIDGET_SESSION_KEY);
|
|
s = legacy || crypto.randomUUID();
|
|
localStorage.setItem(SESSION_KEY, s);
|
|
}
|
|
// Drop the legacy key now that we've migrated; harmless if it's
|
|
// already absent.
|
|
localStorage.removeItem(LEGACY_WIDGET_SESSION_KEY);
|
|
sessionId = s;
|
|
loadHistory();
|
|
}
|
|
|
|
function loadHistory(): void {
|
|
const stored = localStorage.getItem(HISTORY_PREFIX + sessionId);
|
|
if (!stored) {
|
|
history = [];
|
|
return;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(stored);
|
|
history = Array.isArray(parsed) ? parsed.slice(-30) : [];
|
|
} catch {
|
|
history = [];
|
|
}
|
|
}
|
|
|
|
function saveHistory(): void {
|
|
try {
|
|
localStorage.setItem(HISTORY_PREFIX + sessionId, JSON.stringify(history.slice(-30)));
|
|
} catch {
|
|
/* localStorage quota or disabled — non-fatal */
|
|
}
|
|
}
|
|
|
|
async function revealIfOwner(): Promise<void> {
|
|
if (!shouldSendContext(window.location.pathname)) return; // route excluded
|
|
let me: MeResponse;
|
|
try {
|
|
const r = await fetch("/api/me", { credentials: "same-origin" });
|
|
if (!r.ok) return;
|
|
me = await r.json();
|
|
} catch {
|
|
return;
|
|
}
|
|
// The server-side handler returns 404 for non-owners on every paliadin
|
|
// route, so we don't need to know the owner email client-side. Probe
|
|
// /api/paliadin/me-check (a 200/404 endpoint) — but that endpoint
|
|
// doesn't exist; instead reuse the same reveal hook the sidebar uses,
|
|
// which checks an `is_paliadin_owner` flag the /api/me payload includes
|
|
// when paliadinSvc is wired and the caller matches.
|
|
if (!isPaliadinOwner(me)) return;
|
|
showTrigger();
|
|
renderStarters();
|
|
rehydrateHistory();
|
|
// Refresh from DB in the background so cross-surface activity (a
|
|
// turn typed on the standalone /paliadin page) shows up here without
|
|
// a manual reload.
|
|
void hydrateFromServer();
|
|
}
|
|
|
|
function isPaliadinOwner(me: MeResponse): boolean {
|
|
// Server-driven flag (matches the pattern client/sidebar.ts uses to
|
|
// reveal the /paliadin sidebar entry). Fallback to email match only
|
|
// if the flag is absent — this keeps the widget working on a server
|
|
// build that hasn't shipped the flag yet.
|
|
const flag = (me as unknown as { is_paliadin_owner?: boolean }).is_paliadin_owner;
|
|
if (typeof flag === "boolean") return flag;
|
|
// Fallback: hardcoded owner match. Same string as
|
|
// services.PaliadinOwnerEmail in Go — keep in sync.
|
|
return (me.email || "").toLowerCase() === "matthias.siebels@hoganlovells.com";
|
|
}
|
|
|
|
function showTrigger(): void {
|
|
const trigger = document.getElementById("paliadin-widget-trigger");
|
|
if (trigger) trigger.style.display = "";
|
|
}
|
|
|
|
function wireTrigger(): void {
|
|
const trigger = document.getElementById("paliadin-widget-trigger");
|
|
trigger?.addEventListener("click", () => openDrawer());
|
|
}
|
|
|
|
function wireDrawerControls(): void {
|
|
document.getElementById("paliadin-widget-close")?.addEventListener("click", () => closeDrawer());
|
|
document.getElementById("paliadin-widget-scrim")?.addEventListener("click", () => closeDrawer());
|
|
document.getElementById("paliadin-widget-reset")?.addEventListener("click", () => void resetSession());
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape" && drawerOpen) {
|
|
e.preventDefault();
|
|
closeDrawer();
|
|
}
|
|
});
|
|
}
|
|
|
|
function wireKeyboardShortcut(): void {
|
|
// Cmd+J / Ctrl+J — open or close the drawer. Cmd+K is reserved for
|
|
// global search (client/search.ts), so we use J ("Junior assistant").
|
|
document.addEventListener("keydown", (e) => {
|
|
const isCmdJ = (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "j";
|
|
if (!isCmdJ) return;
|
|
const trigger = document.getElementById("paliadin-widget-trigger");
|
|
if (!trigger || trigger.style.display === "none") return; // widget not revealed
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (drawerOpen) {
|
|
closeDrawer();
|
|
} else {
|
|
openDrawer();
|
|
}
|
|
});
|
|
}
|
|
|
|
function openDrawer(): void {
|
|
if (drawerOpen) return;
|
|
drawerOpen = true;
|
|
const drawer = document.getElementById("paliadin-widget-drawer");
|
|
const scrim = document.getElementById("paliadin-widget-scrim");
|
|
if (drawer) {
|
|
drawer.style.display = "";
|
|
drawer.dataset.open = "true";
|
|
drawer.setAttribute("aria-hidden", "false");
|
|
}
|
|
if (scrim) {
|
|
scrim.style.display = "";
|
|
}
|
|
// Force reflow so the slide-in animation runs (CSS transitions need a
|
|
// flip from off-canvas to on-canvas).
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
drawer?.offsetWidth;
|
|
if (drawer) drawer.classList.add("paliadin-widget-drawer--visible");
|
|
if (scrim) scrim.classList.add("paliadin-widget-scrim--visible");
|
|
|
|
refreshContextChip();
|
|
renderStarters();
|
|
// Pull the canonical conversation from the DB on every open so a
|
|
// turn the user typed on /paliadin (or another tab) since the last
|
|
// open is reflected here.
|
|
void hydrateFromServer();
|
|
setTimeout(() => {
|
|
document.getElementById("paliadin-widget-input")?.focus();
|
|
}, 60);
|
|
}
|
|
|
|
function closeDrawer(): void {
|
|
if (!drawerOpen) return;
|
|
drawerOpen = false;
|
|
const drawer = document.getElementById("paliadin-widget-drawer");
|
|
const scrim = document.getElementById("paliadin-widget-scrim");
|
|
drawer?.classList.remove("paliadin-widget-drawer--visible");
|
|
scrim?.classList.remove("paliadin-widget-scrim--visible");
|
|
// Wait for transition before display:none so the slide-out animates.
|
|
setTimeout(() => {
|
|
if (drawerOpen) return; // re-opened during transition
|
|
if (drawer) {
|
|
drawer.style.display = "none";
|
|
drawer.dataset.open = "false";
|
|
drawer.setAttribute("aria-hidden", "true");
|
|
}
|
|
if (scrim) scrim.style.display = "none";
|
|
}, 220);
|
|
}
|
|
|
|
function refreshContextChip(): void {
|
|
const chip = document.getElementById("paliadin-widget-context-chip");
|
|
const value = document.getElementById("paliadin-widget-context-value");
|
|
if (!chip || !value) return;
|
|
const ctx = computePaliadinContext();
|
|
if (!ctx) {
|
|
chip.style.display = "none";
|
|
return;
|
|
}
|
|
const labelParts: string[] = [];
|
|
if (ctx.primary_entity_type === "project") {
|
|
labelParts.push(getLang() === "en" ? "Project" : "Akte");
|
|
} else if (ctx.primary_entity_type === "deadline") {
|
|
labelParts.push(getLang() === "en" ? "Deadline" : "Frist");
|
|
} else if (ctx.primary_entity_type === "appointment") {
|
|
labelParts.push(getLang() === "en" ? "Appointment" : "Termin");
|
|
}
|
|
labelParts.push(humanRouteName(ctx.route_name));
|
|
value.textContent = labelParts.join(" · ");
|
|
chip.style.display = "";
|
|
}
|
|
|
|
function humanRouteName(route: string): string {
|
|
// Prefer i18n key if present; fall back to a tidied form of the
|
|
// route key itself.
|
|
const key = "paliadin.widget.route." + route;
|
|
const translated = t(key);
|
|
if (translated && translated !== key) return translated;
|
|
return route;
|
|
}
|
|
|
|
function renderStarters(): void {
|
|
const host = document.getElementById("paliadin-widget-starters");
|
|
if (!host) return;
|
|
const route = routeNameFor(window.location.pathname);
|
|
const lang = getLang();
|
|
const list = startersFor(route);
|
|
host.innerHTML = "";
|
|
list.forEach((s) => {
|
|
const btn = document.createElement("button");
|
|
btn.type = "button";
|
|
btn.className = "paliadin-widget-starter";
|
|
btn.textContent = lang === "en" ? s.label_en : s.label_de;
|
|
btn.addEventListener("click", () => onStarterClick(s));
|
|
host.appendChild(btn);
|
|
});
|
|
}
|
|
|
|
function onStarterClick(s: Starter): void {
|
|
const lang = getLang();
|
|
const promptText = lang === "en" ? s.prompt_en : s.prompt_de;
|
|
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
|
|
if (!input) return;
|
|
if (!promptText) {
|
|
input.value = "";
|
|
input.focus();
|
|
return;
|
|
}
|
|
// Prompts that end with ": " are intentional partial seeds — leave
|
|
// the textarea so the user finishes the sentence.
|
|
if (promptText.endsWith(": ")) {
|
|
input.value = promptText;
|
|
input.focus();
|
|
input.setSelectionRange(promptText.length, promptText.length);
|
|
return;
|
|
}
|
|
input.value = promptText;
|
|
// Auto-send for fully-formed prompts.
|
|
void sendTurn();
|
|
}
|
|
|
|
function wireForm(): void {
|
|
const form = document.getElementById("paliadin-widget-form") as HTMLFormElement | null;
|
|
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
|
|
if (!form || !input) return;
|
|
form.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
void sendTurn();
|
|
});
|
|
input.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
void sendTurn();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function sendTurn(): Promise<void> {
|
|
if (pending) return;
|
|
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
|
|
if (!input) return;
|
|
const text = input.value.trim();
|
|
if (!text) return;
|
|
input.value = "";
|
|
|
|
hideEmpty();
|
|
appendBubble("user", text);
|
|
history.push({ role: "user", text, ts: new Date().toISOString() });
|
|
saveHistory();
|
|
|
|
pending = true;
|
|
setSendDisabled(true);
|
|
const placeholder = appendBubble("assistant", "Paliadin denkt nach …");
|
|
placeholder.dataset.streaming = "true";
|
|
|
|
let turnRes: TurnResponse;
|
|
try {
|
|
const ctx = computePaliadinContext();
|
|
const r = await fetch("/api/paliadin/turn", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "same-origin",
|
|
body: JSON.stringify({
|
|
user_message: text,
|
|
session_id: sessionId,
|
|
page_origin: window.location.pathname + window.location.search,
|
|
context: ctx ?? undefined,
|
|
}),
|
|
});
|
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
turnRes = await r.json();
|
|
} catch {
|
|
setBubbleText(placeholder, t("paliadin.error.upstream"));
|
|
placeholder.classList.add("paliadin-widget-bubble--error");
|
|
placeholder.dataset.streaming = "false";
|
|
pending = false;
|
|
setSendDisabled(false);
|
|
return;
|
|
}
|
|
|
|
const es = new EventSource(turnRes.sse_url);
|
|
activeStream = es;
|
|
|
|
let fullText = "";
|
|
es.addEventListener("content", (ev) => {
|
|
try {
|
|
const data = JSON.parse((ev as MessageEvent).data);
|
|
fullText = String(data.text || "");
|
|
setBubbleText(placeholder, fullText);
|
|
} catch {
|
|
/* ignore parse error */
|
|
}
|
|
});
|
|
es.addEventListener("end", () => {
|
|
placeholder.dataset.streaming = "false";
|
|
history.push({ role: "assistant", text: fullText || "", ts: new Date().toISOString() });
|
|
saveHistory();
|
|
cleanupStream();
|
|
});
|
|
es.addEventListener("error", () => {
|
|
const errText = t("paliadin.error.connection_lost");
|
|
setBubbleText(placeholder, errText + " " + t("paliadin.late.waiting"));
|
|
placeholder.classList.add("paliadin-widget-bubble--error");
|
|
placeholder.classList.add("paliadin-widget-bubble--late-pending");
|
|
placeholder.dataset.streaming = "false";
|
|
placeholder.dataset.turnId = turnRes.turn_id;
|
|
startWidgetLatePoll(turnRes.turn_id, placeholder);
|
|
cleanupStream();
|
|
});
|
|
es.addEventListener("ping", () => {
|
|
/* heartbeat */
|
|
});
|
|
}
|
|
|
|
function cleanupStream(): void {
|
|
activeStream?.close();
|
|
activeStream = null;
|
|
pending = false;
|
|
setSendDisabled(false);
|
|
}
|
|
|
|
function startWidgetLatePoll(turnId: string, bubble: HTMLElement): void {
|
|
lateWidgetPolls.get(turnId)?.cancel();
|
|
const handle = pollForLateResponse({
|
|
turnId,
|
|
onLateResponse: (turn) => {
|
|
lateWidgetPolls.delete(turnId);
|
|
applyWidgetLateResponse(bubble, turn);
|
|
},
|
|
onGiveUp: () => {
|
|
lateWidgetPolls.delete(turnId);
|
|
},
|
|
});
|
|
lateWidgetPolls.set(turnId, handle);
|
|
}
|
|
|
|
function applyWidgetLateResponse(bubble: HTMLElement, turn: LateTurn): void {
|
|
if (!turn.response) return;
|
|
bubble.classList.remove(
|
|
"paliadin-widget-bubble--error",
|
|
"paliadin-widget-bubble--late-pending",
|
|
);
|
|
bubble.classList.add("paliadin-widget-bubble--late");
|
|
setBubbleText(bubble, turn.response);
|
|
// Append a small "(verspätet)" tag so the late arrival is visible.
|
|
const tag = document.createElement("span");
|
|
tag.className = "paliadin-widget-bubble-late-tag";
|
|
tag.textContent = " · " + t("paliadin.late.marker");
|
|
bubble.appendChild(tag);
|
|
history.push({
|
|
role: "assistant",
|
|
text: turn.response,
|
|
ts: new Date().toISOString(),
|
|
});
|
|
saveHistory();
|
|
}
|
|
|
|
function setSendDisabled(disabled: boolean): void {
|
|
const btn = document.getElementById("paliadin-widget-send-btn") as HTMLButtonElement | null;
|
|
if (btn) btn.disabled = disabled;
|
|
const input = document.getElementById("paliadin-widget-input") as HTMLTextAreaElement | null;
|
|
if (input) input.disabled = disabled;
|
|
}
|
|
|
|
function hideEmpty(): void {
|
|
const empty = document.getElementById("paliadin-widget-empty");
|
|
if (empty) empty.style.display = "none";
|
|
}
|
|
|
|
function appendBubble(role: "user" | "assistant", text: string): HTMLElement {
|
|
const messages = document.getElementById("paliadin-widget-messages");
|
|
const wrap = document.createElement("div");
|
|
wrap.className = `paliadin-widget-bubble paliadin-widget-bubble--${role}`;
|
|
const body = document.createElement("div");
|
|
body.className = "paliadin-widget-bubble-text";
|
|
// Assistant bubbles get the same markdown + chip pipeline as the
|
|
// standalone /paliadin page (client/paliadin-render.ts). User bubbles
|
|
// stay plain text — no need to interpret the user's typed markup.
|
|
if (role === "assistant") {
|
|
body.innerHTML = renderResponseHTML(text);
|
|
} else {
|
|
body.textContent = text;
|
|
}
|
|
wrap.appendChild(body);
|
|
messages?.appendChild(wrap);
|
|
if (messages) messages.scrollTop = messages.scrollHeight;
|
|
return wrap;
|
|
}
|
|
|
|
function setBubbleText(bubble: HTMLElement, text: string): void {
|
|
const body = bubble.querySelector(".paliadin-widget-bubble-text");
|
|
if (body) {
|
|
const isAssistant = bubble.classList.contains("paliadin-widget-bubble--assistant");
|
|
if (isAssistant) {
|
|
(body as HTMLElement).innerHTML = renderResponseHTML(text);
|
|
} else {
|
|
body.textContent = text;
|
|
}
|
|
}
|
|
const messages = document.getElementById("paliadin-widget-messages");
|
|
if (messages) messages.scrollTop = messages.scrollHeight;
|
|
}
|
|
|
|
function rehydrateHistory(): void {
|
|
if (!history.length) return;
|
|
hideEmpty();
|
|
history.forEach((h) => appendBubble(h.role, h.text));
|
|
}
|
|
|
|
// PaliadinTurnRow mirrors the JSON shape /api/paliadin/history returns
|
|
// (services.PaliadinTurn). Fields we don't render yet (used_tools etc.)
|
|
// are typed as unknown to keep the contract loose.
|
|
interface PaliadinTurnRow {
|
|
turn_id: string;
|
|
session_id: string;
|
|
started_at: string;
|
|
user_message: string;
|
|
response?: string | null;
|
|
error_code?: string | null;
|
|
}
|
|
|
|
// Hydrate from the DB on every mount. Crash-resistant: a typed turn
|
|
// always lands in paliad.paliadin_turns, so even if the user closes
|
|
// the tab mid-flight or the device dies, the next mount picks it up.
|
|
//
|
|
// Reconciliation: DB > localStorage. If the DB returns rows, we trust
|
|
// them entirely and overwrite the cache. If the DB call fails or
|
|
// returns empty, we keep whatever's in localStorage (offline cushion).
|
|
async function hydrateFromServer(): Promise<void> {
|
|
let rows: PaliadinTurnRow[] = [];
|
|
try {
|
|
const r = await fetch(
|
|
"/api/paliadin/history?session=" + encodeURIComponent(sessionId) + "&limit=50",
|
|
{ credentials: "same-origin" },
|
|
);
|
|
if (!r.ok) return;
|
|
const body = (await r.json()) as PaliadinTurnRow[] | null;
|
|
rows = Array.isArray(body) ? body : [];
|
|
} catch {
|
|
return;
|
|
}
|
|
if (!rows.length) return;
|
|
|
|
// Project DB rows into the {role, text, ts} shape the cache + render
|
|
// path expect. Each turn becomes two entries (user prompt then
|
|
// assistant response). Skip turns with no response (in-flight, or
|
|
// errored without a recovery) so the bubble doesn't show
|
|
// half-rendered placeholders on reload.
|
|
const reconstructed: HistoryEntry[] = [];
|
|
for (const row of rows) {
|
|
reconstructed.push({ role: "user", text: row.user_message, ts: row.started_at });
|
|
if (typeof row.response === "string" && row.response.length > 0) {
|
|
reconstructed.push({ role: "assistant", text: row.response, ts: row.started_at });
|
|
}
|
|
}
|
|
history = reconstructed;
|
|
saveHistory();
|
|
|
|
// Re-render: clear the message list + replay the canonical history.
|
|
const messages = document.getElementById("paliadin-widget-messages");
|
|
const empty = document.getElementById("paliadin-widget-empty");
|
|
if (messages) {
|
|
// Strip every prior bubble but keep the empty-state placeholder so
|
|
// it can be hidden by hideEmpty() if we end up rendering anything.
|
|
messages.querySelectorAll(".paliadin-widget-bubble").forEach((n) => n.remove());
|
|
if (empty) empty.style.display = "none";
|
|
history.forEach((h) => appendBubble(h.role, h.text));
|
|
}
|
|
}
|
|
|
|
async function resetSession(): Promise<void> {
|
|
if (!confirm(t("paliadin.widget.reset.confirm"))) return;
|
|
history = [];
|
|
saveHistory();
|
|
const messages = document.getElementById("paliadin-widget-messages");
|
|
if (messages) {
|
|
messages.innerHTML = "";
|
|
const empty = document.createElement("div");
|
|
empty.className = "paliadin-widget-empty";
|
|
empty.id = "paliadin-widget-empty";
|
|
const label = document.createElement("p");
|
|
label.className = "paliadin-widget-empty-prompt";
|
|
label.setAttribute("data-i18n", "paliadin.widget.empty");
|
|
label.textContent = t("paliadin.widget.empty");
|
|
const starters = document.createElement("div");
|
|
starters.className = "paliadin-widget-starters";
|
|
starters.id = "paliadin-widget-starters";
|
|
empty.appendChild(label);
|
|
empty.appendChild(starters);
|
|
messages.appendChild(empty);
|
|
renderStarters();
|
|
}
|
|
try {
|
|
await fetch("/api/paliadin/reset", { method: "POST", credentials: "same-origin" });
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
}
|