UNPKG

@tanstack/ai-persistence

Version:

Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.

160 lines (159 loc) 6.04 kB
import { validateReconstructChatStores } from "./types.js"; import { modelMessagesToUIMessages } from "@tanstack/ai"; //#region src/reconstruct.ts var MAX_PAGE_SIZE = 500; /** * Build the JSON `Response` a server-authoritative client hydrates from on load * (see the client-persistence guide). Reads the thread id from the request query * (`?threadId=` by default) and returns `{ messages, activeRun, interrupts }` * ({@link ReconstructedChat}): * * - `messages` — the stored transcript as UI messages. * - `activeRun` — `{ runId }` if a run is still generating for the thread (so the * client tails it via the durability stream), else `null`. Resolved via the * required `stores.runs.findActiveRun`; `null` when the `runs` store is absent. * - `interrupts` — `{ runId, pending }` if the thread has pending human-in-the-loop * interrupts (a paused approval / wait) and the run they paused, else `null`, so * a reload re-prompts the decision from the server. Resolved via the optional * `stores.interrupts.listPending`; `null` when that store is absent. * * Paging is opt-in. A valid `?limit=` (positive integer, capped at 500) returns * the newest window of UI messages plus `page`. `?before=` walks to an older * window. Invalid `limit` (`0`, negative, NaN) is ignored and the full * transcript is returned. `activeRun` and `interrupts` are never paged. * * Requires `stores.messages`. Returns an empty transcript with no active run * and no interrupts when the thread id is missing or the thread is unknown, so * the caller never has to special-case a first load. * * This helper does **not** enforce tenancy by itself. Pass * {@link ReconstructChatOptions.authorize} (or wrap the call in your own * session gate) before exposing it on a public route. * * ```ts * export async function GET(request: Request) { * return reconstructChat(persistence, request, { * authorize: async (threadId, req) => { * const userId = await getSessionUserId(req) * return userId != null && (await userOwnsThread(userId, threadId)) * }, * }) * } * ``` */ async function reconstructChat(persistence, request, options) { validateReconstructChatStores(persistence); const messageStore = persistence.stores.messages; if (!messageStore) throw new Error("reconstructChat requires stores.messages."); const requestUrl = new URL(request.url); const param = options?.param ?? "threadId"; const threadId = requestUrl.searchParams.get(param) ?? ""; const pageSize = parsePageSize(requestUrl.searchParams.get("limit")); const before = parseBefore(requestUrl.searchParams.get("before")); if (threadId && options?.authorize) { const decision = await options.authorize(threadId, request); if (decision instanceof Response) return decision; if (!decision) return new Response(JSON.stringify({ error: "Forbidden" }), { status: 403, headers: { "content-type": "application/json", "cache-control": "no-store" } }); } const active = threadId ? await persistence.stores.runs?.findActiveRun(threadId) : null; const stored = threadId === "" ? [] : pageSize === void 0 ? await messageStore.loadThread(threadId) : await messageStore.loadThread(threadId, { limit: pageSize + 1, ...before === void 0 ? {} : { before } }); const pending = threadId ? await persistence.stores.interrupts?.listPending(threadId) ?? [] : []; const firstPending = pending[0]; const transcript = !(pageSize !== void 0 && threadId !== "") ? { messages: modelMessagesToUIMessages(threadMessages(stored)) } : Array.isArray(stored) ? await windowFromArray({ stored, messageStore, threadId, pageSize, before }) : windowFromMessagePage(stored, pageSize); const body = { messages: transcript.messages, activeRun: active ? { runId: active.runId } : null, interrupts: firstPending ? { runId: firstPending.runId, pending: pending.map((record) => record.payload) } : null, ..."page" in transcript ? { page: transcript.page } : {} }; return new Response(JSON.stringify(body), { headers: { "content-type": "application/json", "cache-control": "no-store" } }); } function parsePageSize(raw) { if (raw == null) return; const pageSize = Number(raw); if (!(Number.isInteger(pageSize) && pageSize > 0)) return; return Math.min(pageSize, MAX_PAGE_SIZE); } function parseBefore(raw) { if (raw == null || raw === "") return; return raw; } function threadMessages(loaded) { return Array.isArray(loaded) ? loaded : loaded.messages; } function completePage() { return { truncated: false }; } function truncatedPage(cursor) { return { truncated: true, cursor }; } function pageFromCursor(cursor) { if (cursor === void 0 || cursor === "") return completePage(); return truncatedPage(cursor); } function newestUiWindow(messages, pageSize) { if (!(messages.length > pageSize)) return { messages, page: completePage() }; const uiWindow = messages.slice(messages.length - pageSize); return { messages: uiWindow, page: pageFromCursor(uiWindow[0]?.id) }; } function uiBeforeCursor(messages, cursor) { const cut = messages.findIndex((message) => message.id === cursor); if (cut === -1) return; return messages.slice(0, cut); } function windowFromMessagePage(page, pageSize) { const ui = modelMessagesToUIMessages(page.messages); if (ui.length > pageSize) return newestUiWindow(ui, pageSize); if (page.truncated) return { messages: ui, page: pageFromCursor(page.cursor) }; return { messages: ui, page: completePage() }; } async function windowFromArray(input) { const { stored, messageStore, threadId, pageSize, before } = input; if (before === void 0) return newestUiWindow(modelMessagesToUIMessages(stored), pageSize); const full = threadMessages(await messageStore.loadThread(threadId)); const older = uiBeforeCursor(modelMessagesToUIMessages(full), before); if (older === void 0) return { messages: [], page: truncatedPage(before) }; return newestUiWindow(older, pageSize); } //#endregion export { reconstructChat }; //# sourceMappingURL=reconstruct.js.map