/** * Generates `lib/ai-chat/session-store.js`, the home of the chat transcript * while the visitor moves around the site. * * WHY THIS EXISTS. The chat is a component instance placed on each page, so * navigating unmounts it and React throws its state away — the visitor loses * the conversation by clicking a link. * * TWO LAYERS, deliberately: * * - a `globalThis` bag, which carries the transcript through a client-side * navigation (Next's ``) without touching storage at all; * - `sessionStorage`, which carries it through a FULL document load — the * language switcher is a plain ``, and typing a URL does the * same thing. Both tear down the JavaScript context, so an in-memory bag * alone loses the conversation exactly when the visitor changes language. * * ⛔ sessionStorage would also survive a refresh, which is NOT wanted: a * refresh starts a new conversation. That is what the reload check is for — * `PerformanceNavigationTiming.type` tells a reload apart from a navigation, * so the stored transcript is dropped on F5 and kept on everything else. * It is scoped to one tab by definition, so a second tab starts clean. * * The conversation id travels with the transcript because the two are one * thing: the server appends to a conversation row by id (`ON CONFLICT (id) DO * UPDATE`), so restoring messages without the id would show the visitor a * history the database no longer believes in, and restoring the id without the * messages would leave the assistant answering with no visible context. */ /** Emitted next to `localized-messages`, under `lib/ai-chat/`. */ export const AI_CHAT_SESSION_STORE_FILE = 'session-store' export function generateAIChatSessionStoreCode(): string { return `// AI assistant chat session — survives navigation, dies on refresh. // Generated by TeleportHQ. Do not edit. var MEMORY_KEY = '__tqAiChatSession' var STORAGE_KEY = 'tq-ai-chat-session' /** * Whether THIS document was reloaded (F5, Cmd-R, location.reload) as opposed * to navigated to. A back/forward move reports 'back_forward' and counts as * navigation, so the conversation survives it. */ function isPageReload() { try { var entries = performance.getEntriesByType('navigation') if (entries && entries.length > 0) { return entries[0].type === 'reload' } // Older browsers: 1 === TYPE_RELOAD. if (performance.navigation) { return performance.navigation.type === 1 } } catch (error) { /* performance API unavailable — treat as a normal navigation */ } return false } // Every storage call is wrapped: Safari private mode throws on write, and a // browser set to block site data throws on read. Losing the cross-document // hand-off is survivable; throwing inside a render is not. function readStoredSession() { try { return window.sessionStorage.getItem(STORAGE_KEY) } catch (error) { return null } } function writeStoredSession(value) { try { window.sessionStorage.setItem(STORAGE_KEY, value) } catch (error) { /* private mode or quota — the in-memory copy still covers client-side navigation */ } } function removeStoredSession() { try { window.sessionStorage.removeItem(STORAGE_KEY) } catch (error) { /* nothing to do */ } } /** * Drops the stored conversation when this document was reloaded. * * Runs at MODULE LOAD, not on first use. A refresh on a page without the chat * would otherwise leave the transcript in storage untouched, and the next chat * page — a plain navigation — would happily restore it, so the conversation * would outlive the refresh that was meant to end it. The app shell imports * this module for exactly that reason, so the check runs on every page. */ function purgeSessionOnReload() { if (typeof window === 'undefined') { return } if (isPageReload()) { removeStoredSession() } } purgeSessionOnReload() /** * The session for this document, hydrated from storage the first time it is * asked for. Returns null on the server. * * The SSR guard is not optional: one Node process serves every visitor, so a * global touched during server rendering would leak one person's conversation * into another's HTML and break hydration on top of it. */ function getSession() { if (typeof window === 'undefined') { return null } var scope = typeof globalThis !== 'undefined' ? globalThis : window if (scope[MEMORY_KEY]) { return scope[MEMORY_KEY] } var session = { messages: [], conversationId: '' } scope[MEMORY_KEY] = session var stored = readStoredSession() if (stored) { try { var parsed = JSON.parse(stored) if (parsed && Array.isArray(parsed.messages)) { session.messages = parsed.messages session.conversationId = typeof parsed.conversationId === 'string' ? parsed.conversationId : '' } } catch (error) { // Written by an older version, or truncated — start clean rather than // hand React a shape it cannot render. removeStoredSession() } } return session } /** * The transcript from an earlier page, or \`fallback\` (the welcome bubble) when * this is the first page of the visit. */ export function restoreAIChatMessages(fallback) { var session = getSession() if (!session || !Array.isArray(session.messages) || session.messages.length === 0) { return fallback } return session.messages } /** The conversation the visitor is already in, so replies keep appending to it. */ export function restoreAIChatConversationId(fallback) { var session = getSession() if (!session || typeof session.conversationId !== 'string' || !session.conversationId) { return fallback } return session.conversationId } /** Mirrors the live state, so the next page picks up exactly where this left off. */ export function persistAIChatSession(messages, conversationId) { var session = getSession() if (!session) { return } session.messages = Array.isArray(messages) ? messages : [] session.conversationId = typeof conversationId === 'string' ? conversationId : '' // Only the welcome bubble means nothing worth carrying across a document // load; keeping storage empty also keeps a fresh tab genuinely fresh. if (session.messages.length <= 1 && !session.conversationId) { removeStoredSession() return } try { writeStoredSession( JSON.stringify({ messages: session.messages, conversationId: session.conversationId }) ) } catch (error) { /* a message that cannot be serialised must not break the chat */ } } /** Drops the conversation, so the next mount starts from the welcome message. */ export function clearAIChatSession() { var session = getSession() if (session) { session.messages = [] session.conversationId = '' } removeStoredSession() } ` }